diff --git a/.gitignore b/.gitignore index b1a4b865..69914875 100644 --- a/.gitignore +++ b/.gitignore @@ -135,7 +135,13 @@ __pycache__/ .mcp.json -# Written into the CWD by the file-I/O regression test -# (tests/regressions/basics/files/file_io_json_workflow.test.osp), which means -# it lands at the repo root whenever the corpus is run from there. -test_output.txt \ No newline at end of file +# Written into the CWD by regression tests, which means they land at the repo +# root whenever the corpus is run from there. Both of the files below were +# COMMITTED once, in the same change that added the tests writing them — a +# corpus run then leaves the tree dirty, and a stale copy is a test input +# nobody wrote on purpose. +# file_io_json_workflow.test.osp -> test_output.txt, test_stale_reason.txt +# http_state_levels.test.osp -> osprey_http_state_levels.db +test_output.txt +test_stale_reason.txt +osprey_http_state_levels.db \ No newline at end of file diff --git a/Book/EDITORIAL-BRIEF.md b/Book/EDITORIAL-BRIEF.md new file mode 100644 index 00000000..d79ef4d0 --- /dev/null +++ b/Book/EDITORIAL-BRIEF.md @@ -0,0 +1,97 @@ +# Editorial brief + +## Positioning + +*The Osprey Book* is the bridge between “I can copy a code sample” and “I can design a small, honest program.” It teaches programming through Osprey's practical functional core: values, functions, inferred types, pattern matching, explicit failure, effects, and isolated concurrency. + +The book is not a compressed language specification. It is a guided build in which every new idea solves a problem the reader has already met. + +## Reader + +The primary reader is a young or early-career developer, roughly beginner to intermediate. They may have tried a school course, a scripting language, a game engine, or a coding agent, but the book does not assume they know compiler theory or functional-programming vocabulary. + +The reader can create a text file and use a browser. A local terminal is introduced as a useful tool, not an entrance exam. Installation appears beside the no-install Playground path so toolchain setup never blocks the first success. + +More experienced readers should still find a direct account of Osprey's type inference, explicit failure, effects, fibers, memory modes, and flavor boundary. + +## Promise and tone + +- Pragmatic, friendly, and technically exact +- Short paragraphs with one clear move +- Concrete code before abstraction +- Never childish, even when explaining a first principle +- No hype, fake rivalry, or initiation rituals +- Compiler errors are guidance, not proof that the reader is “bad at programming” +- Define the everyday idea first, then offer the precise term +- Prefer one evolving program over unrelated toy fragments +- Keep limitations beside the feature they qualify + +The prose can be energetic. It must never sound breathless. “You made the computer do something” is better than “unlock revolutionary performance.” + +## Functional-programming signal + +The book gives functional programmers quiet proof that Osprey contains the real ideas: immutable bindings, expressions, Hindley–Milner inference, algebraic data types, exhaustive pattern matching, persistent collections, higher-order functions, and first-class effects. + +Those names appear after their behavior is useful. A beginner first learns that a value does not change under their feet; an experienced reader can recognise immutability. A beginner sees every possible state written down; an FP reader can recognise a sum type. No chapter turns that recognition into a lecture aimed past the primary reader. + +## Flavor policy + +Default flavor is the book's teaching surface. Every core lesson and complete running example appears in `.osp` first. + +ML flavor is an optional alternate surface introduced after the reader already understands the shared idea. It is never called a separate language, an advanced mode, or a choice that must be made up front. The book may show a compact ML twin in a “Same flight, different feathers” aside when the comparison reduces confusion. + +The language architecture is open to more flavors. Avoid claims that Osprey will always have exactly two. Say “the currently available Default and ML flavors” when the current count matters. + +A coding agent can translate surface syntax quickly, which makes experimentation approachable. The book still requires the reader to run `--check` and the relevant tests after translation. Agent assistance lowers typing cost; it does not replace evidence. + +## Teaching pattern + +Every chapter follows the same learning loop: + +1. **Make something happen.** Start from an outcome the reader can see. +2. **Read the code.** Name only the syntax needed for that outcome. +3. **Change one thing.** Invite a safe prediction before the reader runs it. +4. **Meet the idea.** Explain the general principle in plain language. +5. **Let the compiler help.** Make one useful mistake and read the location and expectation. +6. **Build the Flight Log.** Add one bounded capability to the running project. +7. **Take the agent handoff.** Provide a paste-ready prompt with a verification command. +8. **Check the result.** Run the program or tests and state what is now known. + +No chapter introduces more than four conceptual families. Code blocks should fit a phone or small e-reader without horizontal scrolling. + +## Running project: Flight Log + +The reader grows a small personal project that records things they want to learn, marks progress, explains failures, and eventually performs outside work. It begins as a printed launch message, then gains typed states, lists, safe parsing, effects, tests, persistence, and concurrent tasks. + +The project is intentionally ordinary. It provides enough domain to make types and effects meaningful without requiring a framework, database, or prior application architecture. + +## Chapter limits + +- 2,200–3,600 words +- Five to eight core sections +- Four to nine short code or command blocks +- Two to four purposeful visuals +- One Flight Log checkpoint +- One compiler-feedback exercise +- One paste-ready agent handoff +- Five to seven closing takeaways + +## Accuracy gates + +- Every example is checked with the pinned Osprey compiler. +- Behavior claims cite a governing specification and an executable test where practical. +- Installation commands come from the maintained installation guide. +- A generated illustration never contains product output, syntax, diagnostics, or labels. +- Future work is visibly labelled as future work. +- Native, WebAssembly, effect-resumption, module, package, GPU, and C-FFI limits remain beside the relevant claim. + +## Explicitly out of scope + +- A compiler implementation textbook +- Category theory as a prerequisite +- A complete standard-library reference +- A promise that alpha software will never change +- Treating ML syntax as mandatory for “real” functional programming +- Pretending a coding agent makes checking and testing optional +- Teaching roadmap-only modules, packages, hardware GPU execution, or strict static memory as shipped features + diff --git a/Book/GLOSSARY.md b/Book/GLOSSARY.md new file mode 100644 index 00000000..df1616ba --- /dev/null +++ b/Book/GLOSSARY.md @@ -0,0 +1,103 @@ +# Glossary + +This glossary is the vocabulary authority for *The Osprey Book*. Definitions favour the meaning a learner needs in the chapter where a term first appears. + +## Argument + +A value supplied when calling a function. In `greet("Mika")`, the string `"Mika"` is an argument. + +## Binding + +A name connected to a value. Default flavor writes an immutable binding as `let name = "Mika"`. The name helps later expressions refer to that value; it does not imply a box that must change. + +## Compiler + +The program that reads Osprey source, checks it, and produces a native program or WebAssembly module. Running with `--check` stops after checking. + +## Default flavor + +The book's teaching surface and Osprey's default source syntax. It uses `.osp` files, braces, `fn`, `let`, and parenthesised calls. + +## Effect + +A typed request for work outside an ordinary calculation, such as logging or storage. The code performing an effect asks for an operation; a handler decides how to answer it. + +## Expression + +Code that produces a value. A string, a function call, a `match`, and many blocks are expressions in Osprey. + +## Fiber + +A lightweight unit of concurrent work. Osprey fibers communicate by sending values rather than sharing mutable state. + +## Flavor + +A source-level way to write Osprey. Default and ML are the currently available flavors. A flavor changes how code is written and read; shared checking and code generation operate after that source has been translated into the language's common program form. More flavors may be added in the future. + +## Function + +A named or anonymous transformation from input values to an output value. A function can be called more than once with different arguments. + +## Handler + +Code that gives meaning to one or more effect operations for a particular region of a program. + +## Immutable + +Unable to be reassigned after creation. Most Osprey bindings are immutable, so a name continues to mean the value it was given. + +## Inference + +The compiler's ability to work out types from how values are created and used. Inference keeps strong checking while removing obvious annotations. + +## ML flavor + +An optional Osprey source flavor using indentation-based layout, whitespace application, and currying by default. This book teaches it as an alternative after the shared language ideas are comfortable. + +## Native program + +A program compiled for a particular operating system and processor, without a virtual machine or JIT warm-up. Osprey produces native code through LLVM and clang. + +## Parameter + +A name in a function declaration that receives an argument. In `fn greet(name) = ...`, `name` is a parameter. + +## Pattern + +A shape used by `match` to recognise and, when needed, unpack a value. + +## Pattern matching + +A decision that compares a value with explicit patterns. For a known union or `Result`, the compiler requires every possible case to be covered. + +## Persistent collection + +An immutable list or map whose updates return a new collection while safely reusing unchanged internal structure. + +## Pipeline + +A left-to-right chain made with `|>`. The value on the left becomes the first argument of the function on the right. + +## Record + +A type or value with named fields that belong together, such as a project with a `name` and `status`. + +## Result + +A value that is either `Success` with a useful value or `Error` with failure information. `Result` keeps expected failure visible in the type. + +## Type + +A description of which values an expression may produce and which operations make sense for them. + +## Union + +A type that lists a closed set of possible cases. Functional programmers may know this as a sum type or algebraic data type. + +## Value + +A piece of data a program can use, such as a string, number, boolean, list, record, union case, or function. + +## WebAssembly + +A portable compilation target that can run in supported browser and server environments. Osprey's WebAssembly target supports a smaller runtime surface than native programs. diff --git a/Book/Makefile b/Book/Makefile new file mode 100644 index 00000000..3822f2c5 --- /dev/null +++ b/Book/Makefile @@ -0,0 +1,77 @@ +PANDOC ?= pandoc +EPUBCHECK ?= epubcheck +RSVG_CONVERT ?= rsvg-convert +MAGICK ?= magick +JQ ?= jq +OSPREY ?= ../target/release/osprey + +BOOK_JSON := book.json +MANUSCRIPT := $(shell $(JQ) -r '.sections[].file' $(BOOK_JSON)) +EPUB := dist/the-osprey-book-outline.epub +HTML := dist/index.html +DIAGRAM_PNGS := \ + assets/diagrams/00-reading-journey.png \ + assets/diagrams/01-program-anatomy.png \ + assets/diagrams/01-source-to-output.png + +.PHONY: check check-examples render-assets epub html release clean + +check: + @$(JQ) -e '(.schemaVersion == 1) and ((.sections | length) == 17)' $(BOOK_JSON) >/dev/null + @$(JQ) -e '(.schemaVersion == 1) and ((.sources | length) >= 15)' sources.json >/dev/null + @$(JQ) -e '(.schemaVersion == 1) and ((.figures | length) >= 5)' figures.json >/dev/null + @for file in $(MANUSCRIPT); do test -f "$$file" || { echo "Missing manuscript file: $$file"; exit 1; }; done + @$(PANDOC) $(MANUSCRIPT) --from=gfm --to=native >/dev/null + +check-examples: + @test -x "$(OSPREY)" || { echo "Missing compiler: $(OSPREY). Run make build at the repository root."; exit 1; } + @book_tmp=$$(mktemp -d); trap 'rm -r "$$book_tmp"' EXIT; \ + $(OSPREY) examples/chapter-01/hello.osp --check >/dev/null; \ + $(OSPREY) examples/chapter-01/first-flight.osp --check >/dev/null; \ + $(OSPREY) examples/chapter-01/first-flight.ospml --check >/dev/null; \ + $(OSPREY) examples/chapter-01/hello.osp --run > "$$book_tmp/hello.out"; \ + $(OSPREY) examples/chapter-01/first-flight.osp --run > "$$book_tmp/first-flight.out"; \ + $(OSPREY) examples/chapter-01/first-flight.ospml --run > "$$book_tmp/first-flight-ml.out"; \ + diff -u examples/chapter-01/hello.expectedoutput "$$book_tmp/hello.out"; \ + diff -u examples/chapter-01/first-flight.expectedoutput "$$book_tmp/first-flight.out"; \ + diff -u examples/chapter-01/first-flight.expectedoutput "$$book_tmp/first-flight-ml.out" + +render-assets: assets/cover/cover.png $(DIAGRAM_PNGS) + +assets/cover/cover.png: assets/cover/cover.svg + @$(RSVG_CONVERT) --width 1600 --height 2560 --output $@.base.png $< + @$(MAGICK) $@.base.png \( ../website/src/assets/images/logo.png -resize 184x184 \) -geometry +112+104 -composite $@ + @rm -f $@.base.png + +assets/diagrams/%.png: assets/diagrams/%.svg + @$(RSVG_CONVERT) --width 1600 --height 1000 --output $@ $< + +epub: check check-examples render-assets + @mkdir -p dist + @$(PANDOC) $(MANUSCRIPT) \ + --from=gfm \ + --to=epub3 \ + --toc \ + --metadata-file=metadata.yaml \ + --css=styles/epub.css \ + --epub-cover-image=assets/cover/cover.png \ + --output=$(EPUB) + @$(EPUBCHECK) $(EPUB) + +html: check render-assets + @mkdir -p dist + @$(PANDOC) $(MANUSCRIPT) \ + --from=gfm \ + --to=html5 \ + --standalone \ + --embed-resources \ + --resource-path=. \ + --toc \ + --metadata-file=metadata.yaml \ + --css=styles/epub.css \ + --output=$(HTML) + +release: check check-examples render-assets epub html + +clean: + @rm -f $(EPUB) $(HTML) assets/cover/cover.png $(DIAGRAM_PNGS) diff --git a/Book/OUTLINE.md b/Book/OUTLINE.md new file mode 100644 index 00000000..c19b0829 --- /dev/null +++ b/Book/OUTLINE.md @@ -0,0 +1,314 @@ +# The Osprey Book — structural outline + +## Shape of the first edition + +The first edition targets about **42,600 words**, **154 print-equivalent pages**, and **42 purposeful visuals**. EPUB pages reflow, so word and visual budgets control scope; the page count is a design target. + +| Material | Words | Print-equivalent pages | Visuals | +|---|---:|---:|---:| +| Front matter | 1,200 | 5 | 1 | +| Part I — Make the computer do something | 10,600 | 37 | 11 | +| Part II — Build honest data | 11,200 | 41 | 11 | +| Part III — Meet the outside world | 8,700 | 32 | 10 | +| Part IV — Make it yours | 8,900 | 31 | 9 | +| Back matter and glossary | 2,000 | 8 | 0 | +| **Total** | **42,600** | **154** | **42** | + +## Reader journey + +The reader begins with one source file and visible output. The book delays setup complexity, type-system terminology, and syntax alternatives until each becomes useful. + +The running **Flight Log** project grows in four passes: + +1. **Make it run.** Print a launch line, name values, write functions, and make decisions. +2. **Make it honest.** Transform collections, model valid states, expose failure, and test behavior. +3. **Let it interact.** Request effects, use files or HTTP, and coordinate fibers. +4. **Make it yours.** Choose a target, optionally choose another source flavor, inspect performance, and plan a capstone. + +Default flavor carries the complete path. ML appears later as an optional translation of concepts the reader already owns. Future flavors can join the same role without restructuring the book. + +## Recurring chapter contract + +Every chapter follows the learning loop from `EDITORIAL-BRIEF.md`: + +1. **Visible outcome** — something runs, changes, or is rejected for a useful reason. +2. **Small source** — Default flavor, runnable, and narrow enough to type by hand. +3. **Prediction** — one change the reader thinks through before running. +4. **Plain-language principle** — behavior first, specialist term second. +5. **Compiler feedback** — one purposeful mistake without invented diagnostics. +6. **Flight Log checkpoint** — one bounded addition to the running project. +7. **Agent handoff** — a paste-ready task plus verification command. +8. **Landing check** — what the reader can now prove. + +No chapter introduces more than four conceptual families. Every factual visual is deterministic or directly captured from the pinned edition. + +## Front matter — How to use this book + +**Target:** 1,200 words · 5 pages · 1 visual + +- Who this is for and what it assumes +- Browser-first and local-toolchain paths +- Why the book leads with Default flavor +- How Flight Log checkpoints work +- How to use a coding agent without outsourcing understanding +- Alpha-software and edition boundaries +- Visual: the four-part reading journey + +## Part I — Make the computer do something + +### Chapter 1 — One file, one result + +**Target:** 3,000 words · 10 pages · 3 visuals + +**Reader outcome:** Create, read, change, and run a small Default-flavor Osprey program; explain `fn`, `main`, a function call, an immutable binding, inference, and a pipeline in everyday language. + +- Start in the Playground or verify a local install +- Run `fn main() = print("Hello from Osprey")` +- Read source from the outside in +- Turn a string into a named function +- Bind two immutable values with `let` +- Let inference remove obvious annotations +- Pass a result into `print` with `|>` +- Make one safe change and one purposeful compiler error +- Optional ML peek, clearly marked as skippable +- Flight Log checkpoint: print the first launch line +- Visuals: First Flight opener; program anatomy; source-to-output path + +### Chapter 2 — Give values useful names + +**Target:** 2,500 words · 9 pages · 2 visuals + +**Reader outcome:** Use strings, booleans, numbers, immutable bindings, function parameters, interpolation, and small expressions without reaching for mutable state. + +- A name points to a value; it is not a storage box that must change +- Choose names from the problem rather than from the type +- String interpolation and function parameters +- Checked integer arithmetic introduces `Result` without teaching recovery yet +- Keep functions small and expression-shaped +- Under the wing: immutability and referential transparency +- Flight Log checkpoint: derive a readable summary from project data +- Visuals: value graph; expression-in/expression-out + +### Chapter 3 — Let the compiler work out the types + +**Target:** 2,600 words · 9 pages · 3 visuals + +**Reader outcome:** Read common Osprey types, trust inference for ordinary code, and interpret a type mismatch as useful information. + +- Types describe possible values +- Inference from literals, calls, fields, and returns +- When an annotation adds real information +- Why a compiler can reject a bad combination before running it +- Generic identity and reusable functions, only as behavior +- Distinguish an inferred type variable from `any` +- Flight Log checkpoint: add a typed record boundary +- Visuals: inference trail; useful versus redundant annotations; mismatch locator + +### Chapter 4 — Make every decision visible + +**Target:** 2,500 words · 9 pages · 3 visuals + +**Reader outcome:** Use `match` for booleans, known cases, and destructuring; explain why every possible case must be handled. + +- Decisions are expressions that produce values +- Exact values, wildcard, and boolean matches +- Destructure one record payload +- Exhaustiveness and unreachable arms +- A first union with two meaningful cases +- Why visible cases beat hidden fall-through +- Flight Log checkpoint: render planned and completed entries +- Visuals: exhaustive fan-out; pattern anatomy; missing-case feedback + +## Part II — Build honest data + +### Chapter 5 — Move a collection through a pipeline + +**Target:** 2,700 words · 10 pages · 3 visuals + +**Reader outcome:** Build lists and maps, then use `range`, `map`, `filter`, `fold`, and `|>` to describe a transformation. + +- Lists and persistent updates +- Map keys and safe lookup +- Pipelines read in data-flow order +- Transform, keep, combine, consume +- Lambdas only after named functions are clear +- No hidden mutable loop counter +- Flight Log checkpoint: filter active goals and produce a summary +- Visuals: pipeline flow; structural sharing; map/filter/fold roles + +### Chapter 6 — Model only valid states + +**Target:** 2,800 words · 10 pages · 3 visuals + +**Reader outcome:** Design records and unions so impossible combinations are difficult or impossible to construct. + +- Records group values that belong together +- Unions list every shape a value may take +- Named and positional payloads +- Pattern matching narrows a union safely +- Record update creates a new value +- Under the wing: products, sums, and algebraic data types +- Flight Log checkpoint: model `Planned`, `Learning`, and `Complete` +- Visuals: record versus union; invalid-state removal; immutable update sharing + +### Chapter 7 — Keep failure in the open + +**Target:** 3,000 words · 11 pages · 3 visuals + +**Reader outcome:** Read and produce `Result`, handle both branches with `match`, and use `?:` only when a real fallback policy exists. + +- Expected failure is an outcome, not a surprise exit +- `Success` and `Error` +- Parsing and checked integer arithmetic +- Preserve the original error value +- Exhaustive recovery versus an explicit fallback +- Why no exception or panic is needed for ordinary failure +- Flight Log checkpoint: parse a goal estimate honestly +- Visuals: two-route Result; propagation versus handling; fallback decision + +### Chapter 8 — Prove what the program does + +**Target:** 2,700 words · 10 pages · 2 visuals + +**Reader outcome:** Write focused Osprey tests, use meaningful assertions, and separate compiler rejection from runtime behavior. + +- A test states a behavior someone cares about +- Arrange a small value, act with a pure function, check the result +- Test every union and Result case +- Golden output for a whole interaction +- Compile-fail examples for forbidden programs +- Read locations and expectations before editing +- Flight Log checkpoint: cover summary and parse behavior +- Visuals: evidence pyramid; red-to-green feedback loop + +## Part III — Meet the outside world + +### Chapter 9 — Ask for an effect + +**Target:** 3,000 words · 11 pages · 4 visuals + +**Reader outcome:** Separate a request to perform outside work from the handler that decides how it happens. + +- Pure decisions at the centre, outside work at the edge +- Declare an effect operation +- `perform` asks; a handler answers +- Inputs, outputs, and missing handlers are checked +- Replace production behavior in a test without service plumbing +- Native-only resumption limit +- Under the wing: algebraic effects without transformer stacks +- Flight Log checkpoint: request logging through an effect +- Visuals: request/handler boundary; direct-style call; test handler; missing-handler gate + +### Chapter 10 — Read, write, and call the web + +**Target:** 3,000 words · 11 pages · 3 visuals + +**Reader outcome:** Perform one file or HTTP workflow while keeping every expected failure visible. + +- Pick one supported native boundary for the running example +- Decode at the edge and model data inside +- Handle `Result` next to the decision that can recover +- Keep secrets and personal data out of logs +- WebAssembly runtime limits remain explicit +- C integrations cross Osprey's safety boundary +- Flight Log checkpoint: save or fetch a deterministic fixture +- Visuals: pure core/impure shell; boundary validation; platform support map + +### Chapter 11 — Let work happen together + +**Target:** 2,700 words · 10 pages · 3 visuals + +**Reader outcome:** Spawn isolated fibers, pass values through channels, and avoid shared mutable state or colored function chains. + +- Concurrency is multiple jobs making progress +- Fibers are lightweight work units +- Send values instead of sharing writable memory +- `spawn`, `send`, `recv`, `await`, and `yield` +- Failure and effects remain visible +- Native support boundary +- Flight Log checkpoint: process independent entries concurrently +- Visuals: isolated paths; channel handoff; structured lifetime + +## Part IV — Make it yours + +### Chapter 12 — Ship a real program + +**Target:** 2,900 words · 10 pages · 3 visuals + +**Reader outcome:** Check, run, compile, and choose an appropriate native or WebAssembly target without making unsupported portability claims. + +- `--check`, `--run`, and `--compile` +- Native binaries through LLVM and clang +- WebAssembly's portable subset +- Debug information and the profiler +- Memory modes as build choices +- C FFI power and safety boundary +- Flight Log checkpoint: produce one native artifact and one supported Wasm artifact +- Visuals: compile pipeline; target decision; memory-mode comparison + +### Chapter 13 — Choose another flavor when it helps + +**Target:** 2,600 words · 9 pages · 3 visuals + +**Reader outcome:** Recognise flavor as a source-level reading preference, translate one file between Default and ML, and verify unchanged behavior. + +- Default remains the complete teaching path +- ML is an optional layout-and-currying surface +- Same shared checking and code generation after lowering +- One file selects one flavor; a project may contain current flavor extensions +- Translate with an agent, then `--check` and test +- Surface differences that are more than punctuation +- The architecture may support more flavors later +- Flight Log checkpoint: translate one pure module without changing its tests +- Visuals: source feathers converging; translation proof loop; curried versus flat call + +### Chapter 14 — Build your own flight plan + +**Target:** 3,400 words · 12 pages · 3 visuals + +**Reader outcome:** Scope a small Osprey application, keep its pure centre visible, choose evidence, and describe the next learning step. + +- Choose a problem with observable outcomes +- Sketch data states before functions +- Put expected failure in the design +- Name outside work as effects or boundary calls +- Add tests before expanding the surface +- Measure performance before selecting a memory mode or optimizing +- State alpha and roadmap constraints in a project README +- Three capstone routes: command-line tool, supported browser app, small native service +- Flight Log checkpoint: turn the running project into a one-page build plan +- Visuals: capstone canvas; evidence path; next-step map + +## Back matter + +### Appendices and next steps + +**Target:** 1,000 words · 4 pages + +- Appendix A — Command quick reference +- Appendix B — Default syntax one-page guide +- Appendix C — Agent prompt and verification recipe +- Appendix D — Current platform and feature qualifications +- Appendix E — Flight Log finished-source map +- Where to go next: documentation, specs, status, Playground, releases, and corrections + +### Glossary + +**Target:** 1,000 words · 4 pages + +- Beginner-facing definitions for values, bindings, functions, expressions, and types +- Osprey terms for records, unions, matching, Results, effects, handlers, and fibers +- Flavor vocabulary that remains open to future surfaces +- Cross-links back to the chapters where each term becomes useful + +## Explicitly out of scope for the first edition + +- Compiler implementation details before they help the reader deploy or debug +- Category theory as prerequisite knowledge +- A complete API or built-in-function reference +- Teaching ML before the Default path is comfortable +- Claims that Osprey will always have exactly two source flavors +- Roadmap-only package workflows, complete imports, strict static memory, or hardware GPU execution +- Pretending all WebAssembly targets have native runtime services +- Presenting C calls as covered by Osprey's memory-safety guarantee +- Invented diagnostics, Playground screens, benchmark results, or performance rankings diff --git a/Book/README.md b/Book/README.md new file mode 100644 index 00000000..c62e4d6c --- /dev/null +++ b/Book/README.md @@ -0,0 +1,71 @@ +# The Osprey Book + +*The Osprey Book* is a practical introduction to programming with Osprey for beginner-to-intermediate developers. It starts with a program you can run in minutes, then builds toward typed data, explicit failure, effects, concurrency, and native or WebAssembly delivery. + +The current edition is a **structural scaffold with Chapter 1 complete**. It establishes the learning journey, teaching contract, source policy, production metadata, visual language, runnable Chapter 1 examples, and a working EPUB/HTML pipeline. Later chapters are mapped as editorial scaffolds rather than presented as finished prose. + +## Reader promise + +By the end of the finished book, a reader should be able to: + +- read and write small Osprey programs in the Default flavor; +- let the compiler infer ordinary types without losing strong checks; +- model a problem with records, unions, and exhaustive pattern matching; +- transform collections with functions and pipelines; +- treat expected failure as data rather than a hidden exit; +- separate pure decisions from outside work with effects; +- test behavior and read compiler feedback without panic; +- use fibers for isolated concurrent work; +- compile a program for a native machine or WebAssembly; and +- recognise ML and future flavors as optional source surfaces over the same language. + +## The teaching path + +The book leads with the Default flavor (`.osp`). Its braces, `fn`, `let`, and parenthesised calls are familiar to readers arriving from JavaScript, TypeScript, C#, Java, Kotlin, Swift, Go, or Rust. + +ML flavor appears later as an **optional alternative**, not a decision the reader must make before learning Osprey. A file can be translated between surfaces—often conveniently with a coding agent—then checked and tested like any other code change. The design remains open to more flavors in the future; the book teaches the shared language before touring alternative spellings. + +## Project map + +```text +Book/ +├── book.json # canonical reading order and production targets +├── metadata.yaml # publication metadata +├── OUTLINE.md # detailed chapter architecture +├── EDITORIAL-BRIEF.md # audience, voice, teaching pattern, and scope +├── SOURCE-POLICY.md # authority, evidence, and accuracy rules +├── VISUAL-DESIGN-SYSTEM.md # First Flight adaptation of Midnight Synthetic +├── GLOSSARY.md # beginner-facing vocabulary authority +├── sources.json # approved source ledger +├── evidence.json # chapter claim-readiness ledger +├── figures.json # planned and completed visual ledger +├── manuscript/ # front matter, chapters, and appendices +├── examples/chapter-01/ # runnable examples from the completed chapter +├── assets/ # cover, diagrams, illustration, and future captures +├── styles/ # EPUB and standalone HTML styling +└── dist/ # generated output; never hand-edited +``` + +## Production commands + +```sh +make check # validate manifests and parse every manuscript file +make check-examples # compile and run the Chapter 1 examples +make render-assets # render deterministic SVG masters to publication PNGs +make epub # build and validate the structural EPUB +make html # build a standalone HTML reading copy +make release # run every check and produce both formats +``` + +## Drafting rules + +1. Treat `book.json` as the source of reading order and production targets. +2. Teach the Default flavor first; show ML only when comparison helps the reader. +3. Explain an everyday programming idea before introducing its specialist name. +4. Keep examples runnable against the pinned Osprey edition and store their output where the chapter can verify it. +5. Let code prove language behavior. Use generated illustration for mood, deterministic diagrams for explanation, and direct captures for product evidence. +6. Never promise a roadmap feature as shipped behavior. +7. Do not hide Osprey's alpha status, platform limits, or C safety boundary. +8. Run `make release` before publishing an edition artifact. + +See [OUTLINE.md](OUTLINE.md) for the complete journey and [GLOSSARY.md](GLOSSARY.md) for the shared teaching vocabulary. diff --git a/Book/SOURCE-POLICY.md b/Book/SOURCE-POLICY.md new file mode 100644 index 00000000..64109f58 --- /dev/null +++ b/Book/SOURCE-POLICY.md @@ -0,0 +1,58 @@ +# Source policy + +## Authority order + +The book uses the narrowest current authority available: + +1. the user's edition-level direction and the book's editorial brief; +2. Osprey language specifications in `../docs/specs/`; +3. executable compiler, runtime, and corpus tests; +4. implementation code when a behavior needs confirmation; +5. maintained installation and status documentation; +6. `../docs/messaging.md` for product philosophy, except where this edition's explicit flavor policy supersedes its fixed-count framing; +7. `../docs/designs/` and live website tokens for visual decisions. + +The repository README is an orientation document, not the final authority when it disagrees with current specifications, tests, or edition direction. + +## Flavor language + +The book teaches Default first. ML is one currently available optional alternative, and more flavors may arrive later. Statements such as “Osprey has exactly two flavors” are forbidden in forward-looking editorial copy. + +When a current command or table needs a count, write “the currently available Default and ML flavors.” Explain that a flavor owns source spelling while shared checking and code generation operate on the lowered program. Do not expose compiler-internal vocabulary in the first chapters. + +## Example evidence + +Every complete example must: + +1. live under `examples/`; +2. compile with the edition's pinned compiler; +3. produce deterministic output where output is claimed; +4. avoid undocumented behavior; and +5. appear in Default flavor before any alternative surface. + +ML twins are verification aids and optional comparisons. They do not replace the Default source. + +## Product and roadmap boundary + +The book states that Osprey is alpha software. It does not describe planned generics completion, package management, complete multi-file imports, strict static memory, device GPU code generation, or unsupported WebAssembly runtime services as shipped. + +When source, specification, implementation, and tests disagree, the book omits the disputed behavior from learner-facing instruction and records the gap in `evidence.json`. + +## Visual evidence + +- Deterministic SVG diagrams explain concepts and may contain exact code or labels. +- Direct screenshots show the compiler, Playground, editor, or other product surfaces. +- Generated editorial illustration establishes mood only and contains no factual text. +- Every ready visual has dimensions, alt text, provenance, and a matching `figures.json` entry. + +## Edition maintenance + +Before publishing an edition: + +1. set the compiler version and build date in `book.json` and `metadata.yaml`; +2. run every example with that compiler; +3. compare chapter claims with the cited specifications and tests; +4. render and inspect every figure at desktop and 320 px width; +5. run `make release`; and +6. record unresolved limits beside the relevant feature. + diff --git a/Book/VISUAL-DESIGN-SYSTEM.md b/Book/VISUAL-DESIGN-SYSTEM.md new file mode 100644 index 00000000..993ac67f --- /dev/null +++ b/Book/VISUAL-DESIGN-SYSTEM.md @@ -0,0 +1,156 @@ +# Visual design system — First Flight + +## Creative north star + +The book adapts Osprey's **Midnight Synthetic** design language into **First Flight**: a calm technical field guide in which small pieces of source become visible, trustworthy programs. + +The visual narrative has three verbs: + +- **Compose** — small values and functions join without clutter. +- **Check** — a cyan flight path passes through explicit compiler gates. +- **Launch** — one source file becomes a native or WebAssembly program. + +The result should feel precise, nocturnal, optimistic, and welcoming. It must not resemble a cyberpunk game menu, a generic AI dashboard, or a children's activity book. + +## Evidence hierarchy + +1. **Runnable source and direct captures** prove what Osprey does. +2. **Deterministic diagrams** explain syntax, data flow, and program structure. +3. **Generated editorial illustration** may open a part or express the flight metaphor without carrying facts. + +Generated imagery never owns code, commands, diagnostics, labels, or learning-critical arrows. + +## Palette + +The book is dark-first to match the website and uses tonal layers instead of heavy borders. + +| Role | Hex | Use | +|---|---|---| +| Midnight canvas | `#070d1f` | Cover, page edges, code recesses | +| Reading surface | `#0c1325` | Primary EPUB and HTML background | +| Low surface | `#151b2d` | Notes and alternate sections | +| Container | `#191f32` | Quiet grouped evidence | +| Raised surface | `#23293d` | Tables, diagram nodes, code headers | +| Primary text | `#dce1fb` | Body and headings | +| Muted text | `#bdc8cd` | Captions, metadata, secondary explanation | +| Flight cyan | `#77d7f4` | Connections, focus, interactive meaning | +| Bright cyan | `#bdeeff` | Hot highlights and key terms | +| Periwinkle | `#bbc5ec` | Rare alternative-surface cue | +| Warm checkpoint | `#ffbe65` | Rare caution or deliberate reader action | +| Error | `#ffb4ab` | Compiler rejection only | + +Cyan is the only energetic field color. Amber marks a reader checkpoint, never decoration. Error red appears only where a rejected program is the lesson. + +## Long-form typography + +The website specifies Geist and JetBrains Mono but does not define a book reading measure or print fallback. This book fills that gap: + +- Display and headings: Geist, Inter, or an EPUB-safe system sans +- Body: Geist, Inter, or a system sans at 1.68–1.78 line height +- Code and utility labels: JetBrains Mono, SFMono-Regular, Consolas, or monospace +- Maximum prose measure: 68 characters, approximately 720 px on HTML +- Minimum body size: 1 rem / reader default; never lock EPUB text below that +- Eyebrows: uppercase mono, 0.08 em tracking +- Code: 0.86–0.9 em with 1.55 line height and visible wrapping policy + +Headings use tight tracking. Body text stays left aligned. Fully justified text is forbidden because uneven word spacing harms younger and dyslexic readers. + +## Layout and rhythm + +- Use an 8 px base spacing unit. +- Keep one primary reading column. +- Give chapters a 64–96 px opening field on large screens. +- Put optional details after the main explanation, never beside the first code sample. +- Prefer background shifts and whitespace over outlined cards. +- Corners are 6–10 px to match the website tokens; code blocks use 8 px. +- Use no pills except genuine short metadata tags. + +## Learning components + +### Try it + +A cyan-led exercise with one requested edit, one prediction, and one command to run. It must be possible to finish in under ten minutes. + +### Compiler says + +An error surface that highlights the source location, the expected idea, and the next useful action. Do not reproduce a diagnostic unless it came from the pinned compiler. + +### Under the wing + +An optional note for readers who want the precise functional-programming term. It never contains knowledge required by the next section. + +### Same flight, different feathers + +An optional flavor comparison. Default appears first and receives the full explanation. Alternative syntax is smaller, clearly dismissible, and paired with a verification command. + +### Agent handoff + +A paste-ready prompt on a raised surface. It includes the intended change, invariants, and the exact check or test the agent must run. + +## Diagram language + +| Idea | Form | +|---|---| +| Source value | Small cyan node with exact label | +| Pure function | Open rectangular transform with input/output ports | +| Pipeline | One continuous rising cyan path | +| Inferred type | Muted annotation attached after the value, not before it | +| Pattern match | Explicit fan-out with every branch visible | +| Result | Two named routes: success and error | +| Effect | Dashed request rising to a solid handler boundary | +| Fiber | Separate parallel flight path with no shared node | +| Compiler check | Thin luminous gate across the path | +| Flavor | A source-side feather shape that converges before checking | +| Output target | Native or WebAssembly landing field | + +Arrows always name what moves. A box labelled only “magic,” “compiler,” or “quality” teaches nothing. + +## Canvas families + +| Asset | Master | Publication derivative | +|---|---|---| +| Cover | 1600 × 2560 SVG | 1600 × 2560 PNG | +| Concept diagram | 1600 × 1000 SVG | 1600 × 1000 PNG | +| Editorial opener | 16:9 raster | 1600 × 900 PNG/WebP | +| Product capture | Native high-DPI capture | 1600 px-wide crop where practical | + +Keep at least 72 px safe margin around deterministic diagram content and 8% around raster focal elements. Publication assets are opaque. + +## Cover direction + +The cover is a midnight launch field. A single cyan program line rises through three checking planes and becomes an abstract osprey flight path. The canonical Osprey mark is composited from `../website/src/assets/images/logo.png`; it is never redrawn by a generative model. + +Required text: + +```text +THE OSPREY BOOK +A practical first flight through modern programming. +``` + +The title must remain readable at 160 px. No fake editor, terminal, source listing, laptop, human figure, or generated lettering. + +## Generated-art prompt contract + +Generated editorial art uses the repository's Midnight Synthetic image guidance with these book additions: + +- one clear flight metaphor per image; +- calm negative space rather than dense spectacle; +- no people, laptops, fake code, UI, or text; +- abstract wireframe osprey anatomy must still read as a broad-winged raptor; +- cyan `#77d7f4` is the only vibrant color; and +- a chapter image must remain legible at a 320 px e-reader width. + +## Accessibility and production gates + +Every ready visual must have: + +- descriptive alt text explaining the lesson; +- a caption explaining why the figure exists; +- readable content at 320 px width; +- sufficient contrast and a grayscale-safe distinction; +- no information carried by color alone; +- a source master and exact dimensions; +- no personal paths, secrets, or private repository names; +- no fictional product output; and +- a matching entry in `figures.json`. + diff --git a/Book/assets/brand/README.md b/Book/assets/brand/README.md new file mode 100644 index 00000000..853ce1c8 --- /dev/null +++ b/Book/assets/brand/README.md @@ -0,0 +1,6 @@ +# Brand assets + +The canonical Osprey mark remains at `../../../website/src/assets/images/logo.png`. Book production references that file rather than creating a divergent logo copy. + +The cover's generated PNG composites the canonical mark onto the deterministic SVG master. Editorial illustrations may use an abstract osprey flight motif but never replace or redraw the product mark. + diff --git a/Book/assets/cover/cover.png b/Book/assets/cover/cover.png new file mode 100644 index 00000000..6bd7e263 Binary files /dev/null and b/Book/assets/cover/cover.png differ diff --git a/Book/assets/cover/cover.svg b/Book/assets/cover/cover.svg new file mode 100644 index 00000000..3f86fb11 --- /dev/null +++ b/Book/assets/cover/cover.svg @@ -0,0 +1,56 @@ + + The Osprey Book cover + A cyan source path rises through three checking planes and becomes an Osprey flight line over a midnight field. + + + + + + + + + + + + + + + + + + + + FIRST FLIGHT EDITION + THE + OSPREY + BOOK + + + A practical first flight through + modern programming. + + + + + + + + + + + + + + + + + + + + + + + + + CHRISTIAN FINDLAY + diff --git a/Book/assets/diagrams/00-reading-journey.png b/Book/assets/diagrams/00-reading-journey.png new file mode 100644 index 00000000..07ae923b Binary files /dev/null and b/Book/assets/diagrams/00-reading-journey.png differ diff --git a/Book/assets/diagrams/00-reading-journey.svg b/Book/assets/diagrams/00-reading-journey.svg new file mode 100644 index 00000000..ffb3dd27 --- /dev/null +++ b/Book/assets/diagrams/00-reading-journey.svg @@ -0,0 +1,70 @@ + + The Osprey Book reading journey + The learning journey moves from running one file, through honest data and outside-world interaction, to shipping and choosing an optional source flavor. + + + + + + + + + + + + YOUR FLIGHT PLAN + From one line to a program you can ship + One project grows in four passes. Alternative syntax waits until the shared language is familiar. + + + + + + + PART I · RUN + Make it do + something + One file + Named values + Inferred types + Visible decisions + + + + + PART II · MODEL + Build honest + data + Collections + Valid states + Explicit failure + Tests + + + + + PART III · ACT + Meet the + outside world + Effects + Files and HTTP + Fibers + Boundaries + + + + + PART IV · SHIP + Make it + yours + Native or Wasm + Optional flavors + Evidence + Capstone + + + + + DEFAULT FLAVOR CARRIES THE COMPLETE PATH + ML and future flavors are optional source surfaces, introduced after the reader owns the core ideas. + diff --git a/Book/assets/diagrams/01-program-anatomy.png b/Book/assets/diagrams/01-program-anatomy.png new file mode 100644 index 00000000..18f51b47 Binary files /dev/null and b/Book/assets/diagrams/01-program-anatomy.png differ diff --git a/Book/assets/diagrams/01-program-anatomy.svg b/Book/assets/diagrams/01-program-anatomy.svg new file mode 100644 index 00000000..212f3b98 --- /dev/null +++ b/Book/assets/diagrams/01-program-anatomy.svg @@ -0,0 +1,50 @@ + + Anatomy of the first Osprey program + A one-line Osprey program is annotated to identify fn, main, the function body, print, and the string value. + + PROGRAM ANATOMY + Read the jobs before the punctuation + Every token contributes one small fact to a complete program. + + + + + + + + fn + main + () + = + print + ( + "Hello from Osprey" + ) + + + + + + + + + + + + + + + + + + + Declare a function + Program starts here + Body produces this expression + Call print + String argument + + + + Read aloud: define main with no inputs; its body prints one string value. + diff --git a/Book/assets/diagrams/01-source-to-output.png b/Book/assets/diagrams/01-source-to-output.png new file mode 100644 index 00000000..96e62684 Binary files /dev/null and b/Book/assets/diagrams/01-source-to-output.png differ diff --git a/Book/assets/diagrams/01-source-to-output.svg b/Book/assets/diagrams/01-source-to-output.svg new file mode 100644 index 00000000..60e7675f --- /dev/null +++ b/Book/assets/diagrams/01-source-to-output.svg @@ -0,0 +1,60 @@ + + From Osprey source to visible output + Source code passes through Osprey parsing and checking, then LLVM-backed native compilation, before the program prints output. + + + + + + + SOURCE → OUTPUT + --run performs a checked build + The convenience command does not skip the compiler gates. + + + + + + + 01 · SOURCE + hello.osp + Values + Functions + Expressions + + + + 02 · READ + Parse + Recognise the + source structure + + + + 03 · CHECK + Prove fit + Names exist + Types agree + Effects covered + + --check stops here + + + + 04 · BUILD + LLVM + clang + Generate and link + a native program + + + + 05 · RUN + Output + Hello from + Osprey + + + + ONE COMMAND · SEVERAL HONEST STAGES + A build failure belongs to the stage that can explain it; a running program has passed the earlier gates. + diff --git a/Book/assets/illustrations/README.md b/Book/assets/illustrations/README.md new file mode 100644 index 00000000..6332ab13 --- /dev/null +++ b/Book/assets/illustrations/README.md @@ -0,0 +1,15 @@ +# Editorial illustrations + +## First Flight + +`first-flight-master.png` was generated with OpenAI's built-in image-generation tool. `first-flight.png` is the 1600 × 900 publication crop. + +### Base prompt + +> Use case: stylized-concept. Asset type: wide editorial chapter opener for a beginner programming book. Visualize a programmer's first Osprey program taking flight, using one clean abstract transformation: a small luminous cyan source-thread rises from a precise geometric launch plane and unfolds into a stylized wireframe osprey wing in upward motion. Deep midnight-blue space from #070D1F to #0C1325 with a faint disciplined dot grid. One geometric cyan flight form; the source-thread and wing are part of the same continuous structure. Clean abstract geometric digital render, luminous architecture, restrained glassmorphism, premium technical editorial illustration. 16:9 wide; focal structure centered and fully inside the central square; generous quiet negative space; no important detail in the outer 8 percent. Directional electric-cyan #77D7F4 rim light, soft bloom, bright #BDEEFF cores, calm, precise, optimistic, nocturnal. Midnight blue and cyan only, with muted ice-white detail. Absolutely no text, words, letters, numbers, readable code, UI, logo, watermark, people, faces, hands, laptops, photorealism, or extra animals; the osprey is an abstract wireframe/light structure, not a realistic bird; glow rather than drop shadow. Avoid purple, magenta, teal green, neon green, orange fields, warm sunset, pure black, gray background, pastel, rainbow, clutter, stock-photo look, cute mascot, 3D cartoon, frame, or border. + +### Targeted anatomy correction + +> Change only the abstract bird silhouette so it unmistakably reads as an osprey in flight: shorter strongly hooked raptor beak, broad powerful wings with splayed primary feathers, compact eagle-like head and chest, predatory forward flight posture. Keep it a stylized luminous geometric wireframe/light structure. Preserve the exact deep midnight-blue background, cyan-only palette, dot grid, launch plane, rising source-thread, central placement, generous negative space, lighting, glow, framing, and 16:9 composition. No text, letters, numbers, readable code, UI, logo, watermark, people, laptops, photorealism, or extra animals. Change only the bird anatomy and silhouette; do not add objects. Avoid hummingbird, long straight needle beak, gull, swallow, songbird, cute mascot, purple, green, orange, clutter, frame, or border. + +The correction was required because the first silhouette read as a hummingbird rather than a broad-winged raptor. diff --git a/Book/assets/illustrations/first-flight-master.png b/Book/assets/illustrations/first-flight-master.png new file mode 100644 index 00000000..47ba6025 Binary files /dev/null and b/Book/assets/illustrations/first-flight-master.png differ diff --git a/Book/assets/illustrations/first-flight.png b/Book/assets/illustrations/first-flight.png new file mode 100644 index 00000000..0ce07fcb Binary files /dev/null and b/Book/assets/illustrations/first-flight.png differ diff --git a/Book/assets/screenshots/README.md b/Book/assets/screenshots/README.md new file mode 100644 index 00000000..43a214a2 --- /dev/null +++ b/Book/assets/screenshots/README.md @@ -0,0 +1,6 @@ +# Product screenshots + +No product screenshots are included in the structural scaffold. Add captures only after the edition compiler, website release, browser, operating system, viewport, and theme are pinned in `figures.json`. + +Keep untouched masters in an `assets/screenshots/masters/` directory. Crop and resize derivatives without repainting source, output, labels, or diagnostics. + diff --git a/Book/book.json b/Book/book.json new file mode 100644 index 00000000..f94e6691 --- /dev/null +++ b/Book/book.json @@ -0,0 +1,203 @@ +{ + "schemaVersion": 1, + "title": "The Osprey Book", + "status": "structural-scaffold-chapter-1-complete", + "editionModel": "living-release-aligned", + "ospreyRelease": "0.0.0-dev", + "releasePinStatus": "pin-before-publication", + "teachingFlavor": "default", + "alternativeFlavors": ["ml"], + "flavorCountPolicy": "open-ended", + "terminologyAuthority": "GLOSSARY.md", + "targets": { + "words": 42600, + "printEquivalentPages": 154, + "figures": 42 + }, + "sections": [ + { + "kind": "front-matter", + "part": "Front matter", + "title": "How to use this book", + "file": "manuscript/00-how-to-use-this-book.md", + "status": "representative-draft", + "targetWords": 1200, + "targetPages": 5, + "targetFigures": 1 + }, + { + "kind": "chapter", + "part": "Part I — Make the computer do something", + "number": 1, + "title": "One file, one result", + "file": "manuscript/01-one-file-one-result.md", + "status": "complete", + "targetWords": 3000, + "targetPages": 10, + "targetFigures": 3 + }, + { + "kind": "chapter", + "part": "Part I — Make the computer do something", + "number": 2, + "title": "Give values useful names", + "file": "manuscript/02-give-values-useful-names.md", + "status": "editorial-scaffold", + "targetWords": 2500, + "targetPages": 9, + "targetFigures": 2 + }, + { + "kind": "chapter", + "part": "Part I — Make the computer do something", + "number": 3, + "title": "Let the compiler work out the types", + "file": "manuscript/03-let-the-compiler-work-out-the-types.md", + "status": "editorial-scaffold", + "targetWords": 2600, + "targetPages": 9, + "targetFigures": 3 + }, + { + "kind": "chapter", + "part": "Part I — Make the computer do something", + "number": 4, + "title": "Make every decision visible", + "file": "manuscript/04-make-every-decision-visible.md", + "status": "editorial-scaffold", + "targetWords": 2500, + "targetPages": 9, + "targetFigures": 3 + }, + { + "kind": "chapter", + "part": "Part II — Build honest data", + "number": 5, + "title": "Move a collection through a pipeline", + "file": "manuscript/05-move-a-collection-through-a-pipeline.md", + "status": "editorial-scaffold", + "targetWords": 2700, + "targetPages": 10, + "targetFigures": 3 + }, + { + "kind": "chapter", + "part": "Part II — Build honest data", + "number": 6, + "title": "Model only valid states", + "file": "manuscript/06-model-only-valid-states.md", + "status": "editorial-scaffold", + "targetWords": 2800, + "targetPages": 10, + "targetFigures": 3 + }, + { + "kind": "chapter", + "part": "Part II — Build honest data", + "number": 7, + "title": "Keep failure in the open", + "file": "manuscript/07-keep-failure-in-the-open.md", + "status": "editorial-scaffold", + "targetWords": 3000, + "targetPages": 11, + "targetFigures": 3 + }, + { + "kind": "chapter", + "part": "Part II — Build honest data", + "number": 8, + "title": "Prove what the program does", + "file": "manuscript/08-prove-what-the-program-does.md", + "status": "editorial-scaffold", + "targetWords": 2700, + "targetPages": 10, + "targetFigures": 2 + }, + { + "kind": "chapter", + "part": "Part III — Meet the outside world", + "number": 9, + "title": "Ask for an effect", + "file": "manuscript/09-ask-for-an-effect.md", + "status": "editorial-scaffold", + "targetWords": 3000, + "targetPages": 11, + "targetFigures": 4 + }, + { + "kind": "chapter", + "part": "Part III — Meet the outside world", + "number": 10, + "title": "Read, write, and call the web", + "file": "manuscript/10-read-write-and-call-the-web.md", + "status": "editorial-scaffold", + "targetWords": 3000, + "targetPages": 11, + "targetFigures": 3 + }, + { + "kind": "chapter", + "part": "Part III — Meet the outside world", + "number": 11, + "title": "Let work happen together", + "file": "manuscript/11-let-work-happen-together.md", + "status": "editorial-scaffold", + "targetWords": 2700, + "targetPages": 10, + "targetFigures": 3 + }, + { + "kind": "chapter", + "part": "Part IV — Make it yours", + "number": 12, + "title": "Ship a real program", + "file": "manuscript/12-ship-a-real-program.md", + "status": "editorial-scaffold", + "targetWords": 2900, + "targetPages": 10, + "targetFigures": 3 + }, + { + "kind": "chapter", + "part": "Part IV — Make it yours", + "number": 13, + "title": "Choose another flavor when it helps", + "file": "manuscript/13-choose-another-flavor-when-it-helps.md", + "status": "editorial-scaffold", + "targetWords": 2600, + "targetPages": 9, + "targetFigures": 3 + }, + { + "kind": "chapter", + "part": "Part IV — Make it yours", + "number": 14, + "title": "Build your own flight plan", + "file": "manuscript/14-build-your-own-flight-plan.md", + "status": "editorial-scaffold", + "targetWords": 3400, + "targetPages": 12, + "targetFigures": 3 + }, + { + "kind": "back-matter", + "part": "Back matter", + "title": "Appendices and next steps", + "file": "manuscript/90-appendices-and-next-steps.md", + "status": "editorial-scaffold", + "targetWords": 1000, + "targetPages": 4, + "targetFigures": 0 + }, + { + "kind": "glossary", + "part": "Back matter", + "title": "Glossary", + "file": "GLOSSARY.md", + "status": "representative-draft", + "targetWords": 1000, + "targetPages": 4, + "targetFigures": 0 + } + ] +} diff --git a/Book/evidence.json b/Book/evidence.json new file mode 100644 index 00000000..9b4d0178 --- /dev/null +++ b/Book/evidence.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "editionStatus": "structural-scaffold-chapter-1-complete", + "releasePinned": false, + "compilerVersion": "0.0.0-dev", + "chapters": [ + { + "number": 1, + "editorial": "complete", + "spec": "mapped", + "implementation": "compiler-checked", + "examples": "run-with-pinned-dev-binary", + "capture": "concept-diagrams-and-editorial-opener-ready" + }, + { "number": 2, "spec": "mapped", "examples": "planned", "capture": "planned" }, + { "number": 3, "spec": "mapped", "examples": "planned", "capture": "planned" }, + { "number": 4, "spec": "mapped", "examples": "planned", "capture": "planned" }, + { "number": 5, "spec": "mapped", "examples": "planned", "capture": "planned" }, + { "number": 6, "spec": "mapped", "examples": "planned", "capture": "planned" }, + { "number": 7, "spec": "mapped", "examples": "planned", "capture": "planned" }, + { "number": 8, "spec": "mapped", "examples": "planned", "capture": "planned" }, + { "number": 9, "spec": "mapped", "examples": "planned", "capture": "planned" }, + { "number": 10, "spec": "mapped", "examples": "planned", "capture": "planned" }, + { "number": 11, "spec": "mapped", "examples": "planned", "capture": "planned" }, + { "number": 12, "spec": "mapped", "examples": "planned", "capture": "planned" }, + { + "number": 13, + "spec": "mapped", + "editionDirection": "default-first-ml-optional-future-flavors-open", + "examples": "planned", + "capture": "planned" + }, + { "number": 14, "spec": "mapped", "examples": "planned", "capture": "planned" } + ] +} diff --git a/Book/examples/chapter-01/README.md b/Book/examples/chapter-01/README.md new file mode 100644 index 00000000..7f95a8ff --- /dev/null +++ b/Book/examples/chapter-01/README.md @@ -0,0 +1,11 @@ +# Chapter 1 examples + +The complete Default-flavor sources are the teaching authority. `first-flight.ospml` is an optional translated twin used only to prove the Chapter 1 flavor aside. + +From `Book/`: + +```sh +make check-examples +``` + +The target checks every source, runs each executable example, and compares stdout byte-for-byte with the matching `.expectedoutput` file. diff --git a/Book/examples/chapter-01/first-flight.expectedoutput b/Book/examples/chapter-01/first-flight.expectedoutput new file mode 100644 index 00000000..9522cace --- /dev/null +++ b/Book/examples/chapter-01/first-flight.expectedoutput @@ -0,0 +1 @@ +Mika launched a first Osprey program. diff --git a/Book/examples/chapter-01/first-flight.osp b/Book/examples/chapter-01/first-flight.osp new file mode 100644 index 00000000..90754ac5 --- /dev/null +++ b/Book/examples/chapter-01/first-flight.osp @@ -0,0 +1,7 @@ +fn launchLine(name, project) = "${name} launched ${project}." + +fn main() = { + let name = "Mika" + let project = "a first Osprey program" + launchLine(name, project) |> print +} diff --git a/Book/examples/chapter-01/first-flight.ospml b/Book/examples/chapter-01/first-flight.ospml new file mode 100644 index 00000000..e721cf44 --- /dev/null +++ b/Book/examples/chapter-01/first-flight.ospml @@ -0,0 +1,6 @@ +launchLine (name, project) = "${name} launched ${project}." + +main () = + name = "Mika" + project = "a first Osprey program" + launchLine (name, project) |> print diff --git a/Book/examples/chapter-01/hello.expectedoutput b/Book/examples/chapter-01/hello.expectedoutput new file mode 100644 index 00000000..025bc13c --- /dev/null +++ b/Book/examples/chapter-01/hello.expectedoutput @@ -0,0 +1 @@ +Hello from Osprey diff --git a/Book/examples/chapter-01/hello.osp b/Book/examples/chapter-01/hello.osp new file mode 100644 index 00000000..c589b7f4 --- /dev/null +++ b/Book/examples/chapter-01/hello.osp @@ -0,0 +1 @@ +fn main() = print("Hello from Osprey") diff --git a/Book/figures.json b/Book/figures.json new file mode 100644 index 00000000..fb821636 --- /dev/null +++ b/Book/figures.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 1, + "figures": [ + { + "id": "cover", + "kind": "cover", + "status": "ready", + "master": "assets/cover/cover.svg", + "publication": "assets/cover/cover.png", + "width": 1600, + "height": 2560, + "alt": "A cyan source path rises through three checking planes and becomes an Osprey flight line over a midnight field." + }, + { + "id": "reading-journey", + "kind": "concept-diagram", + "status": "ready", + "master": "assets/diagrams/00-reading-journey.svg", + "publication": "assets/diagrams/00-reading-journey.png", + "width": 1600, + "height": 1000, + "alt": "The learning journey moves from running one file, through honest data and outside-world interaction, to shipping and choosing an optional source flavor." + }, + { + "id": "first-flight", + "kind": "generated-editorial-illustration", + "status": "ready", + "master": "assets/illustrations/first-flight-master.png", + "publication": "assets/illustrations/first-flight.png", + "width": 1600, + "height": 900, + "alt": "A broad-winged cyan wireframe osprey rises from a single luminous source thread above a precise geometric launch plane.", + "provenance": "OpenAI built-in image generation; prompts recorded in assets/illustrations/README.md" + }, + { + "id": "program-anatomy", + "kind": "concept-diagram", + "status": "ready", + "master": "assets/diagrams/01-program-anatomy.svg", + "publication": "assets/diagrams/01-program-anatomy.png", + "width": 1600, + "height": 1000, + "alt": "A one-line Osprey program is annotated to identify fn, main, the function body, print, and the string value." + }, + { + "id": "source-to-output", + "kind": "concept-diagram", + "status": "ready", + "master": "assets/diagrams/01-source-to-output.svg", + "publication": "assets/diagrams/01-source-to-output.png", + "width": 1600, + "height": 1000, + "alt": "Source code passes through Osprey's parser, checker, and LLVM-backed build before a native program prints output." + }, + { + "id": "flavors-converge", + "kind": "concept-diagram", + "status": "planned", + "chapter": 13, + "alt": "Default, ML, and a future flavor converge on the same checked language core before code generation." + } + ] +} diff --git a/Book/manuscript/00-how-to-use-this-book.md b/Book/manuscript/00-how-to-use-this-book.md new file mode 100644 index 00000000..f45dab22 --- /dev/null +++ b/Book/manuscript/00-how-to-use-this-book.md @@ -0,0 +1,94 @@ +# How to use this book + +You do not need to know what a monad is. You do not need a favourite operating system, a perfectly configured editor, or a hot take about programming languages. You need enough curiosity to change a line of code and ask what happened. + +This book begins there. + +Osprey is a practical functional programming language. That description will mean more after you have used it. For now, it means the language helps you build programs from values and functions, keeps ordinary failure visible, and checks a surprising amount before the program runs. + +The goal is not to memorise syntax. The goal is to understand why your program behaves the way it does and to make the next change with confidence. + +![The book moves from one running file through honest data and outside-world interaction to a program the reader can ship and reshape.](assets/diagrams/00-reading-journey.png) + +*Figure 0.1 — The four parts grow one Flight Log rather than restarting with disconnected examples.* + +## Two ways to begin + +The fastest path uses the [Osprey Playground](https://www.ospreylang.dev/playground/). It runs in a browser and requires no local toolchain. Use it when you want the first result now. + +The local path uses the `osprey` compiler and LLVM's `clang`. Use it when you want to keep files on your computer and build native programs. The maintained [installation guide](https://www.ospreylang.dev/docs/installation/) has the current steps for macOS, Linux, and Windows. + +Chapter 1 works on either path. Command blocks show the local form; Playground readers can paste the same Osprey source into the editor and use its run control. + +## One teaching surface first + +Osprey can currently be written in Default flavor and ML flavor. More source flavors may arrive in the future. + +You do not need to choose among them now. + +This book leads with Default flavor. A Default file ends in `.osp` and uses familiar pieces such as `fn`, `let`, braces, and parenthesised calls. That gives readers from mainstream languages less surface syntax to learn at once. + +ML flavor is an optional alternative. It uses indentation, whitespace application, and currying by default. The book introduces it after the shared language ideas are comfortable. Skipping every ML aside will still give you a complete journey through the book. + +A coding agent can translate a file between source flavors quickly. Treat that translation like any other code change: check it, run the tests, and compare the behavior. Convenience is not evidence; the compiler and tests provide the evidence. + +## The Flight Log + +Most chapters add one capability to a small project called Flight Log. It records what you want to learn and how far you have travelled. + +The project begins as a single printed line. Later it gains: + +- named values and small functions; +- explicit states such as planned, learning, and complete; +- lists and transformation pipelines; +- visible success and failure; +- tests; +- effects for outside work; +- files or web calls; +- concurrent tasks; and +- a real build target. + +The first version is deliberately tiny. Good programs do not earn their value from file count. + +## The page signals + +Each chapter uses a few repeated signals. + +**Try it** asks you to make one small edit, predict the result, and run it. + +**Compiler says** creates a useful error on purpose. Read the source location and the expected shape before changing anything. + +**Under the wing** gives the precise functional-programming term for something you have already used. These notes are optional; they are also a quiet promise to experienced FP readers that the book is teaching the real language ideas. + +**Same flight, different feathers** shows an optional source flavor comparison. Default always comes first and receives the full explanation. + +**Agent handoff** is a paste-ready task for a coding agent. It includes what must remain unchanged and how the agent should verify the result. + +## How to use an agent without losing the lesson + +An agent is excellent at typing a mechanical change, explaining a compiler message in different words, and producing a second example. It can also give you a confident answer that has not been checked. + +Keep three jobs for yourself: + +1. Say what outcome you want. +2. Predict one important part of the behavior. +3. Read the command or test result that proves what happened. + +Ask the agent to show a small diff and run a specific check. If it changes the design while translating syntax, ask it to revert to the smallest behavior-preserving change. + +## Alpha means honest edges + +Osprey is alpha software. Syntax, tooling, and implementation details can change. The book is tied to an edition and a compiler version so its examples can be checked rather than merely remembered. + +Some language areas are deliberately qualified. Native programs and WebAssembly do not have identical runtime capabilities. Effect resumption is currently native-only. Complete package and module workflows remain in development. C libraries are useful, but C code sits outside Osprey's memory-safety guarantee. + +These are not footnotes designed to spoil the fun. They are part of learning to trust technical claims: say what works, say where it works, and test the program you plan to ship. + +## A good pace + +Type the Chapter 1 program yourself. After that, copying a longer example is fine if you still make the requested change and predict the result. + +Stop at each Flight Log checkpoint. Commit it to memory, a notebook, or version control if that helps you see progress. When a chapter introduces a specialist term, connect it to the code you already ran. + +You are ready when you can create a text file and change a quoted string. The next chapter turns that small ability into a running program. + diff --git a/Book/manuscript/01-one-file-one-result.md b/Book/manuscript/01-one-file-one-result.md new file mode 100644 index 00000000..09225534 --- /dev/null +++ b/Book/manuscript/01-one-file-one-result.md @@ -0,0 +1,344 @@ +# Chapter 1 — One file, one result + +![A broad-winged wireframe osprey rises from one luminous source thread over a geometric launch plane.](assets/illustrations/first-flight.png) + +*Figure 1.1 — A first program does not need to be large. One source path, checked and run, is enough to take flight.* + +A program is a set of instructions and values that a computer can work with. That definition is accurate, but it does not feel real until the computer responds to something you wrote. + +So the first goal is deliberately small: make Osprey print one line. + +```osprey +fn main() = print("Hello from Osprey") +``` + +This is a complete program. It has no project generator, configuration file, class, import list, or type annotation. There will be time for larger programs later. Right now, you need one file and one visible result. + +## Reader outcome + +By the end of this chapter, you should be able to: + +- run a Default-flavor Osprey file; +- read a one-line function from the outside in; +- create a small function with parameters; +- bind immutable values with `let`; +- explain what the compiler inferred; +- pass a value through a pipeline with `|>`; and +- use a coding agent to translate an optional syntax surface without changing behavior. + +## Choose your runway + +Use the browser path if you want zero setup. Open the [Osprey Playground](https://www.ospreylang.dev/playground/), replace its source with the one-line program, and run it. + +Use the local path if the compiler is already installed. Create a file called `hello.osp`: + +```osprey +fn main() = print("Hello from Osprey") +``` + +Then run: + +```sh +osprey hello.osp --run +``` + +The program prints: + +```text +Hello from Osprey +``` + +If `osprey` or `clang` cannot be found, use the Playground now and return to the maintained [installation guide](https://www.ospreylang.dev/docs/installation/) when you want local builds. Toolchain setup is useful, but it is not the programming lesson. + +With no explicit override or source marker, the `.osp` ending selects Default flavor. You can also think of it as a small label that helps the editor and compiler know how the file is written. + +## Read the line from the outside in + +The first program has five pieces. + +![The one-line program is annotated with the job performed by fn, main, the body marker, print, and the string value.](assets/diagrams/01-program-anatomy.png) + +*Figure 1.2 — Read the structure before worrying about every punctuation mark.* + +Start with the shape: + +```osprey +fn main() = ... +``` + +`fn` says you are declaring a **function**. A function is a reusable transformation or action. `main` is the name the runnable program begins with. The empty parentheses mean this function takes no input values. The `=` introduces the function's body: the expression that runs when the function is called. + +Now read the body: + +```osprey +print("Hello from Osprey") +``` + +This is a **function call**. `print` is the function being called. The string inside the parentheses is the **argument** supplied to it. A string is text in double quotes. + +You can read the whole line aloud: + +> Define a function named main. It takes no arguments. Its body prints the string “Hello from Osprey.” + +That sentence matters more than memorising which symbol came first. Syntax becomes easier when each piece has a job. + +### Try it: change the value + +Replace the string with a message of your own: + +```osprey +fn main() = print("Mika was here") +``` + +Before running it, predict the exact output. Then run the program. + +You changed a **value**, not the structure of the program. `main` still calls `print`; it simply supplies a different string. + +## Source becomes a running program + +On a local machine, `--run` performs several jobs for you. Osprey reads the source, checks that the pieces fit, produces LLVM code, asks `clang` to build a native executable, and starts it. + +![Osprey source passes through parsing and checks into an LLVM-backed native build, then produces visible output.](assets/diagrams/01-source-to-output.png) + +*Figure 1.3 — `--run` is convenient, but the checking stage remains a real gate rather than a guess.* + +You can stop after checking: + +```sh +osprey hello.osp --check +``` + +Or compile an executable without starting it: + +```sh +osprey hello.osp --compile -o hello +``` + +Chapter 12 returns to build targets and deployment. For now, remember the useful split: + +- `--check` asks whether the source is a valid, well-typed program. +- `--run` checks, builds, and starts it. +- `--compile` checks and builds an artifact you can start later. + +## Give one idea a name + +The first line does two jobs at once: it decides the message and prints it. Separate those jobs by adding a function: + +```osprey +fn launchLine(name, project) = "${name} launched ${project}." + +fn main() = launchLine("Mika", "a first Osprey program") |> print +``` + +`launchLine` has two **parameters**, `name` and `project`. A parameter is a local name that receives an argument when the function is called. + +The call supplies two strings: + +```osprey +launchLine("Mika", "a first Osprey program") +``` + +Inside the function, `${name}` and `${project}` place those values into a larger string. This is **string interpolation**: building text while keeping the inserted expressions visible. + +The function returns the string it creates. There is no `return` keyword because the body is already an expression. The value of that expression is the function's result. + +Then the pipe operator sends that result into `print`: + +```osprey +launchLine("Mika", "a first Osprey program") |> print +``` + +`|>` passes the value on its left as the first argument to the function on its right. You could write the same work as: + +```osprey +print(launchLine("Mika", "a first Osprey program")) +``` + +Both forms are valid. The pipeline reads in the order the data moves: make the launch line, then print it. + +### Under the wing: functions are values doing honest work + +If you already know functional programming, this is not decorative “functional style.” Osprey functions produce values, expressions form bodies, immutable data is the default, and pipelines compose transformations. Later chapters add algebraic data types, exhaustive matching, persistent collections, higher-order functions, and first-class effects. + +If those terms are new, ignore them for now. You have already used the core move: one function produced a value and another consumed it. + +## Bind values before using them + +The final Chapter 1 program gives the two arguments useful local names: + +```osprey +fn launchLine(name, project) = "${name} launched ${project}." + +fn main() = { + let name = "Mika" + let project = "a first Osprey program" + launchLine(name, project) |> print +} +``` + +The braces give `main` a block with more than one step. The two `let` lines create **bindings**: + +```osprey +let name = "Mika" +let project = "a first Osprey program" +``` + +A binding connects a name to a value. These bindings are immutable: `name` continues to mean `"Mika"` throughout this block. That removes a common question from your head. You do not need to wonder whether some earlier line changed what `name` means. + +The block's last expression is the value of the block: + +```osprey +launchLine(name, project) |> print +``` + +This calls the function with the named values and sends its result to `print`. + +The complete source lives in `examples/chapter-01/first-flight.osp` in the book project. It prints: + +```text +Mika launched a first Osprey program. +``` + +### The missing annotations are intentional + +You did not write `name: string` on the bindings or parameters. Osprey inferred the types from the values and operations: + +- `"Mika"` is a string, so the `name` binding is a string. +- `name` is interpolated into a string, which agrees with that use. +- `launchLine` builds a string, so its result is a string. +- `print` accepts the produced value. + +This is **type inference**. The compiler still checks the types; it simply works out the obvious parts instead of asking you to repeat them. + +Annotations are valuable when they add a real boundary or resolve ambiguity. Rewriting facts the compiler already knows adds noise. Chapter 3 develops that judgement. + +## Let the compiler catch one mistake + +Change the final call without adding a matching binding: + +```osprey +launchLine(name, mission) |> print +``` + +Now run: + +```sh +osprey first-flight.osp --check +``` + +The check must fail because `mission` has no binding. Diagnostic wording may change during alpha development, so this book does not freeze an invented error message. Read three pieces from the real output: + +1. the source location; +2. the name or type the compiler could not resolve; and +3. the smallest change that makes the code tell the truth again. + +Here the repair is either to use `project`, the name that already exists, or deliberately rename the binding and every use to `mission`. Guessing a new value would hide the mistake. + +This failure is useful. The program did not start with a made-up value, silently print the wrong thing, or wait for a user to discover the problem. The compiler stopped at the boundary between what the source says and what it can prove. + +### Try it: predict before you repair + +Before editing, answer: + +- Which names are in scope inside `main`? +- Which names are parameters inside `launchLine`? +- Would changing only the parameter name affect the call site? + +Then make the smallest repair and rerun `--check` before `--run`. + +## Same flight, different feathers — optional + +You can skip this section without missing any Chapter 1 skill. + +The currently available ML flavor can express the same program with indentation and different call spelling: + +```osprey-ml +launchLine (name, project) = "${name} launched ${project}." + +main () = + name = "Mika" + project = "a first Osprey program" + launchLine (name, project) |> print +``` + +The source file ends in `.ospml`. The compiler selects the ML surface from that extension, then checks and builds the resulting Osprey program through the shared language pipeline. + +Notice what did not change: the values, the function's job, the immutable bindings, the interpolation, the pipeline, and the output. + +Some flavor differences are deeper than removing `fn` or braces. ML functions are curried by default, and a flat multi-argument call uses its own spelling. That is why Chapter 13 gives flavor switching a proper treatment instead of presenting it as automatic punctuation replacement. + +The important emotional fact is simpler: you are not choosing a lifelong camp. Learn Osprey through Default. Try another surface later if it helps you read or write. More flavors can fit the architecture in the future. + +## Flight Log checkpoint + +Create `flight-log.osp` from the completed example: + +```osprey +fn launchLine(name, project) = "${name} launched ${project}." + +fn main() = { + let name = "Mika" + let project = "a first Osprey program" + launchLine(name, project) |> print +} +``` + +Make three changes: + +1. Replace `Mika` with your name or handle. +2. Replace the project description with something you want to build. +3. Rename `launchLine` to a name that still explains its job. + +Run the check, then the program: + +```sh +osprey flight-log.osp --check +osprey flight-log.osp --run +``` + +Your landing condition is one personalised line of output and a successful check. + +## Agent handoff + +Paste this into a coding agent when you want help without handing over the whole lesson: + +```text +Update flight-log.osp in Default flavor. + +Keep the program in one file and preserve its single-line output shape: +" launched ." + +Use one pure function to build the string, immutable let bindings in main, +and a pipeline into print. Do not add type annotations the compiler can infer. +Show the smallest diff, then run: + +osprey flight-log.osp --check +osprey flight-log.osp --run + +Report the observed output. Do not translate to another flavor unless asked. +``` + +For an optional flavor experiment, ask the agent to create an `.ospml` twin without modifying the `.osp` source. Require both files to pass `--check` and produce byte-identical output. The twin is evidence of translation, not a replacement for the teaching source. + +## What changed + +- You ran a complete Osprey program from one file. +- `main` provided the starting function and `print` made the result visible. +- A function parameter received an argument and produced a new value. +- `let` gave stable names to immutable values. +- The compiler inferred ordinary string types without losing type checking. +- `|>` made the data flow read from left to right. +- A failed check became useful information before the program ran. +- Default flavor carried the lesson; ML remained an optional alternate surface. + +Chapter 2 keeps the program small and asks a deeper question: what makes a name useful, and what can you calculate without making a value change under your feet? + +## Authoritative sources + +- Osprey [Introduction](https://github.com/Nimblesite/osprey/blob/main/docs/specs/0001-Introduction.md) for the language shape and explicit-failure contract. +- Osprey [Syntax](https://github.com/Nimblesite/osprey/blob/main/docs/specs/0003-Syntax.md) for Default bindings, functions, and expressions. +- Osprey [Type System](https://github.com/Nimblesite/osprey/blob/main/docs/specs/0004-TypeSystem.md) for Hindley–Milner inference. +- Osprey [Function Calls](https://github.com/Nimblesite/osprey/blob/main/docs/specs/0005-FunctionCalls.md) for Default call behavior. +- Osprey [Iterators and Iteration](https://github.com/Nimblesite/osprey/blob/main/docs/specs/0010-LoopConstructsAndFunctionalIterators.md) for the pipe operator. +- Osprey [Language Flavors](https://github.com/Nimblesite/osprey/blob/main/docs/specs/0023-LanguageFlavors.md) and [ML Flavor Syntax](https://github.com/Nimblesite/osprey/blob/main/docs/specs/0024-MLFlavorSyntax.md) for the optional comparison. +- The maintained [installation guide](https://www.ospreylang.dev/docs/installation/) for local and no-install paths. diff --git a/Book/manuscript/02-give-values-useful-names.md b/Book/manuscript/02-give-values-useful-names.md new file mode 100644 index 00000000..0cf3a942 --- /dev/null +++ b/Book/manuscript/02-give-values-useful-names.md @@ -0,0 +1,37 @@ +# Chapter 2 — Give values useful names + +## Reader outcome + +Use strings, booleans, numbers, parameters, interpolation, and immutable bindings to explain a small calculation without mutable bookkeeping. + +## Flight Log state + +Chapter 1 prints one launch line. This chapter derives that line from a learner profile and a project summary, with names that describe the problem rather than the storage type. + +## Core sections + +1. A value is something the program can use +2. A binding gives a stable value a useful name +3. Parameters let one function work with different inputs +4. Interpolation turns values into a readable boundary +5. Checked integer arithmetic introduces a visible `Result` +6. Expressions keep the transformation small +7. Under the wing: immutability and referential transparency + +## Compiler-feedback exercise + +Attempt to reassign an ordinary immutable binding. Use the actual checker output to distinguish “create a new value” from handler-owned mutation, without introducing effects early. + +## Flight Log checkpoint + +Add a pure `summary` function that receives project data and produces one string. Verify two inputs without adding global state. + +## Planned visuals + +- Named-value graph +- Expression in → value out + +## Source map + +`0002-LexicalStructure`, `0003-Syntax`, `0004-TypeSystem`, `0013-ErrorHandling` + diff --git a/Book/manuscript/03-let-the-compiler-work-out-the-types.md b/Book/manuscript/03-let-the-compiler-work-out-the-types.md new file mode 100644 index 00000000..b179bc32 --- /dev/null +++ b/Book/manuscript/03-let-the-compiler-work-out-the-types.md @@ -0,0 +1,38 @@ +# Chapter 3 — Let the compiler work out the types + +## Reader outcome + +Read common Osprey types, rely on inference for ordinary code, and treat a type mismatch as a precise disagreement rather than a mysterious crash. + +## Flight Log state + +The summary function works for one shape of data. The reader introduces a small record boundary and lets field use determine the surrounding function types. + +## Core sections + +1. A type describes possible values +2. Literals and operations leave an inference trail +3. Function inputs and outputs constrain one another +4. Add an annotation only when it adds information +5. Reuse a function at more than one inferred type +6. Polymorphism is not `any` +7. Read a mismatch from the source location outward + +## Compiler-feedback exercise + +Supply a boolean where the program builds text. Capture the pinned compiler's location and expected/observed types; do not paraphrase an invented diagnostic. + +## Flight Log checkpoint + +Introduce a `Learner` record and a function whose parameter and return types remain inferred. + +## Planned visuals + +- Inference trail from literal to call +- Useful versus redundant annotation +- Type-mismatch locator + +## Source map + +`0003-Syntax`, `0004-TypeSystem`, `0005-FunctionCalls` + diff --git a/Book/manuscript/04-make-every-decision-visible.md b/Book/manuscript/04-make-every-decision-visible.md new file mode 100644 index 00000000..94c2e5c3 --- /dev/null +++ b/Book/manuscript/04-make-every-decision-visible.md @@ -0,0 +1,38 @@ +# Chapter 4 — Make every decision visible + +## Reader outcome + +Use `match` to make decisions, unpack data, and cover every possible case of a known type. + +## Flight Log state + +The project gains explicit `Planned` and `Complete` cases. Rendering the status requires handling both. + +## Core sections + +1. A decision is an expression with a result +2. Match booleans and exact scalar values +3. Use `_` when every remaining value shares one answer +4. List the cases with a union +5. Unpack one payload with a pattern +6. Let exhaustiveness stop a missing case +7. Reject unreachable branches instead of hiding them + +## Compiler-feedback exercise + +Add a union case without updating the renderer. Use the real exhaustiveness error, then add the smallest meaningful branch. + +## Flight Log checkpoint + +Render every project state to a string and test each branch. + +## Planned visuals + +- Exhaustive decision fan-out +- Pattern and payload anatomy +- Missing-case gate + +## Source map + +`0003-Syntax`, `0004-TypeSystem`, `0007-PatternMatching` + diff --git a/Book/manuscript/05-move-a-collection-through-a-pipeline.md b/Book/manuscript/05-move-a-collection-through-a-pipeline.md new file mode 100644 index 00000000..38a76966 --- /dev/null +++ b/Book/manuscript/05-move-a-collection-through-a-pipeline.md @@ -0,0 +1,38 @@ +# Chapter 5 — Move a collection through a pipeline + +## Reader outcome + +Build lists and maps, preserve earlier versions, and describe collection work with `map`, `filter`, `fold`, and `|>`. + +## Flight Log state + +One entry becomes a list of entries. The reader filters active work and folds it into a compact summary. + +## Core sections + +1. A list groups values of one type +2. Persistent updates keep the old value valid +3. A map connects string keys to values +4. A pipeline reads in transformation order +5. `map` transforms and `filter` keeps +6. `fold` combines and `forEach` performs +7. Named callbacks before lambdas + +## Compiler-feedback exercise + +Use a callback with the wrong return shape inside a pipeline. Follow the type relationship from the callback to the consumer. + +## Flight Log checkpoint + +Filter active entries, map them to display lines, and produce a deterministic summary without a mutable loop. + +## Planned visuals + +- Pipeline flow +- Persistent structural sharing +- Map/filter/fold job comparison + +## Source map + +`0004-TypeSystem`, `0010-LoopConstructsAndFunctionalIterators`, `0012-Built-InFunctions` + diff --git a/Book/manuscript/06-model-only-valid-states.md b/Book/manuscript/06-model-only-valid-states.md new file mode 100644 index 00000000..1a8e6420 --- /dev/null +++ b/Book/manuscript/06-model-only-valid-states.md @@ -0,0 +1,38 @@ +# Chapter 6 — Model only valid states + +## Reader outcome + +Use records and unions to represent the real states of a problem and remove meaningless field combinations. + +## Flight Log state + +A loose set of flags becomes `Planned`, `Learning`, and `Complete`, each carrying only the data its state needs. + +## Core sections + +1. Records group facts that exist together +2. Unions list alternatives +3. Put data on the case that owns it +4. Named and positional payloads +5. Match before reading union-specific data +6. Record update returns a new value +7. Under the wing: products, sums, and algebraic data types + +## Compiler-feedback exercise + +Construct a record with a missing or unknown field, then use the actual checker response to repair the model rather than insert a meaningless default. + +## Flight Log checkpoint + +Replace status booleans with a closed union and update every renderer and test. + +## Planned visuals + +- Record versus union +- Impossible-state removal +- Immutable update sharing + +## Source map + +`0003-Syntax`, `0004-TypeSystem`, `0007-PatternMatching` + diff --git a/Book/manuscript/07-keep-failure-in-the-open.md b/Book/manuscript/07-keep-failure-in-the-open.md new file mode 100644 index 00000000..c4ba462e --- /dev/null +++ b/Book/manuscript/07-keep-failure-in-the-open.md @@ -0,0 +1,38 @@ +# Chapter 7 — Keep failure in the open + +## Reader outcome + +Use `Result` for expected failure, preserve error information, and recover only where the program has a real policy. + +## Flight Log state + +The reader parses a text estimate. Invalid input becomes data the caller must handle rather than a hidden exit. + +## Core sections + +1. Expected failure belongs in the result +2. `Success` carries a value; `Error` carries information +3. Match both routes explicitly +4. Checked integer arithmetic remains honest +5. Preserve the first failure while composing work +6. Use `?:` only for an intentional fallback +7. Why ordinary failure needs no exception or panic + +## Compiler-feedback exercise + +Pass a `Result` where a plain value is required. Follow the compiler back to the missing policy and fix it with an exhaustive match. + +## Flight Log checkpoint + +Parse an estimate, show a successful duration, and retain the exact parser message on failure. + +## Planned visuals + +- Two-route Result +- Propagation versus handling +- Fallback decision + +## Source map + +`0001-Introduction`, `0007-PatternMatching`, `0013-ErrorHandling` + diff --git a/Book/manuscript/08-prove-what-the-program-does.md b/Book/manuscript/08-prove-what-the-program-does.md new file mode 100644 index 00000000..aeffba69 --- /dev/null +++ b/Book/manuscript/08-prove-what-the-program-does.md @@ -0,0 +1,37 @@ +# Chapter 8 — Prove what the program does + +## Reader outcome + +Write tests that state meaningful behavior, cover every modeled case, and separate runtime assertions from programs the compiler must reject. + +## Flight Log state + +The project has useful pure functions and honest failure. The reader now turns those expectations into executable evidence. + +## Core sections + +1. A test is a behavior claim +2. Arrange one value, call one boundary, check one result +3. Cover every union and Result branch +4. Use grouped checks without meaningless assertions +5. Use expected output for a complete interaction +6. Keep compile-fail examples for forbidden programs +7. Run the repository's actual test command + +## Compiler-feedback exercise + +Write one deliberately false assertion and distinguish a test failure from a compilation failure. Repair the behavior or the expectation without deleting evidence. + +## Flight Log checkpoint + +Cover summary rendering, status transitions, and successful and failed estimate parsing. + +## Planned visuals + +- Evidence pyramid +- Edit/check/test feedback loop + +## Source map + +`0027-TestingFramework`, corpus conventions in `../tests/`, and repository test instructions + diff --git a/Book/manuscript/09-ask-for-an-effect.md b/Book/manuscript/09-ask-for-an-effect.md new file mode 100644 index 00000000..0b1e4bc8 --- /dev/null +++ b/Book/manuscript/09-ask-for-an-effect.md @@ -0,0 +1,40 @@ +# Chapter 9 — Ask for an effect + +## Reader outcome + +Declare a typed effect, perform an operation, and install a handler without passing service objects through pure functions. + +## Flight Log state + +The project wants to log progress. Its core asks to log; production and test handlers answer differently. + +## Core sections + +1. Keep decisions pure and outside work visible +2. Declare the operation's inputs and output +3. `perform` makes a typed request +4. A lexical handler supplies behavior +5. Missing handlers are rejected before program entry +6. Replace behavior in tests without changing the caller +7. Native-only resumption and current limits +8. Under the wing: first-class algebraic effects + +## Compiler-feedback exercise + +Remove the required handler and capture the pinned missing-handler diagnostic. Restore the narrowest handler rather than hide the effect. + +## Flight Log checkpoint + +Add a logging effect with a production print handler and a deterministic test handler. + +## Planned visuals + +- Pure request and handler boundary +- Direct-style call path +- Production/test handler swap +- Missing-handler gate + +## Source map + +`0001-Introduction`, `0017-AlgebraicEffects`, effect corpus and failure fixtures + diff --git a/Book/manuscript/10-read-write-and-call-the-web.md b/Book/manuscript/10-read-write-and-call-the-web.md new file mode 100644 index 00000000..11845a89 --- /dev/null +++ b/Book/manuscript/10-read-write-and-call-the-web.md @@ -0,0 +1,38 @@ +# Chapter 10 — Read, write, and call the web + +## Reader outcome + +Complete one native boundary workflow while decoding outside data early and keeping every expected failure visible. + +## Flight Log state + +The in-memory project saves to or loads from a deterministic fixture. A web variant is shown only where the current native runtime supports it. + +## Core sections + +1. The outside world is allowed to be unreliable +2. Keep a pure model behind a narrow boundary +3. Read or request data through a `Result` +4. Decode before the data reaches the core +5. Log presence and shape, never secrets +6. State native and WebAssembly support separately +7. Treat C calls as an explicit safety boundary + +## Compiler-feedback exercise + +Try to use a fallible boundary result as decoded data. Add the missing match and retain the original error value. + +## Flight Log checkpoint + +Persist or fetch one fixed fixture and prove the pure summary is unchanged after decoding. + +## Planned visuals + +- Pure core and impure shell +- Boundary validation path +- Platform support map + +## Source map + +`0013-ErrorHandling`, `0014-HTTP`, `0019-ForeignFunctionInterface`, `0022-WebAssemblyTarget` + diff --git a/Book/manuscript/11-let-work-happen-together.md b/Book/manuscript/11-let-work-happen-together.md new file mode 100644 index 00000000..023f6056 --- /dev/null +++ b/Book/manuscript/11-let-work-happen-together.md @@ -0,0 +1,38 @@ +# Chapter 11 — Let work happen together + +## Reader outcome + +Spawn isolated fibers, communicate through typed channels, and wait for work without shared mutable state or a separate async function kind. + +## Flight Log state + +Independent entries are processed concurrently and return their values through explicit communication. + +## Core sections + +1. Concurrency is work making progress together +2. A fiber is lighter than an operating-system thread +3. Spawn work without coloring every caller +4. Send values instead of sharing writable memory +5. Receive, await, and yield deliberately +6. Keep failures and effects visible across boundaries +7. Structured lifetime and native availability + +## Compiler-feedback exercise + +Cross a channel with the wrong value type or leave a required effect uncovered. Repair the boundary rather than erase type information. + +## Flight Log checkpoint + +Process independent entries in fibers and collect a deterministic result in a defined order. + +## Planned visuals + +- Isolated flight paths +- Typed channel handoff +- Parent and child lifetime + +## Source map + +`0011-LightweightFibersAndConcurrency`, `0036-StructuredConcurrency`, fiber corpus + diff --git a/Book/manuscript/12-ship-a-real-program.md b/Book/manuscript/12-ship-a-real-program.md new file mode 100644 index 00000000..67481620 --- /dev/null +++ b/Book/manuscript/12-ship-a-real-program.md @@ -0,0 +1,38 @@ +# Chapter 12 — Ship a real program + +## Reader outcome + +Choose `--check`, `--run`, or `--compile`, select a supported target, and describe what the resulting artifact depends on. + +## Flight Log state + +The tested project becomes a native executable and a supported WebAssembly artifact with platform limits documented beside each build. + +## Core sections + +1. Check, run, and compile are different jobs +2. Native code travels through LLVM and clang +3. WebAssembly supports a portable subset +4. Debug information and profiling answer different questions +5. Memory management is a build choice +6. Measure before changing the memory mode +7. C libraries are powerful and outside the safety guarantee + +## Compiler-feedback exercise + +Attempt one unsupported target/runtime combination and preserve the actual failure as a platform qualification, not a workaround recipe. + +## Flight Log checkpoint + +Produce a native artifact, record its command and environment, then build only the portion supported on WebAssembly. + +## Planned visuals + +- Source-to-target compile pipeline +- Native versus Wasm decision map +- Memory-mode comparison + +## Source map + +`0018-MemoryManagement`, `0019-ForeignFunctionInterface`, `0022-WebAssemblyTarget`, `0028-Profiler` + diff --git a/Book/manuscript/13-choose-another-flavor-when-it-helps.md b/Book/manuscript/13-choose-another-flavor-when-it-helps.md new file mode 100644 index 00000000..8c87490d --- /dev/null +++ b/Book/manuscript/13-choose-another-flavor-when-it-helps.md @@ -0,0 +1,42 @@ +# Chapter 13 — Choose another flavor when it helps + +## Reader outcome + +Translate one understood program between Default and ML, identify the differences that affect function shape, and prove unchanged behavior. + +## Flight Log state + +The complete Default source remains canonical for the book. One pure file gains an optional `.ospml` twin and shares the same output and tests. + +## Core sections + +1. Flavor changes the source surface, not the language you have learned +2. Default remains the safe starting point +3. ML replaces braces with layout and defaults to currying +4. Flat and curried calls are not punctuation twins +5. One file selects one surface +6. Use an agent for translation, then check and test +7. The architecture remains open to future flavors + +## Compiler-feedback exercise + +Translate a flat two-argument function as though it were curried, observe the real mismatch, and repair the source while preserving the function's intended shape. + +## Flight Log checkpoint + +Create an ML twin of one pure module, run both checks, and compare stdout or test evidence byte-for-byte. + +## Planned visuals + +- Several source feathers converging on one checked core +- Agent translation and verification loop +- Flat versus curried application + +## Source map + +`0023-LanguageFlavors`, `0024-MLFlavorSyntax`, cross-flavor AST and IR equivalence tests + +## Edition note + +Never say Osprey is permanently limited to two flavors. Name Default and ML as the currently available surfaces and keep future additions structurally possible. + diff --git a/Book/manuscript/14-build-your-own-flight-plan.md b/Book/manuscript/14-build-your-own-flight-plan.md new file mode 100644 index 00000000..c18fa254 --- /dev/null +++ b/Book/manuscript/14-build-your-own-flight-plan.md @@ -0,0 +1,39 @@ +# Chapter 14 — Build your own flight plan + +## Reader outcome + +Scope a small application, design its values and failure paths, choose evidence, and plan deployment without depending on unshipped features. + +## Flight Log state + +The running project becomes a template. The reader keeps it or replaces its domain while preserving the same design questions. + +## Core sections + +1. Choose an observable outcome, not a framework +2. Sketch valid data states before functions +3. Put expected failure into the design +4. Keep outside work at named boundaries +5. Add tests before widening the feature surface +6. Measure before optimizing or changing memory mode +7. State alpha and platform limits in the project README +8. Choose a command-line, supported browser, or small native-service route + +## Compiler-feedback exercise + +Turn one vague capstone requirement into a concrete type or test, then use the compiler or test runner to expose what the plan had left unspecified. + +## Flight Log checkpoint + +Produce a one-page build plan with domain states, pure functions, failure routes, outside effects, tests, target, and explicit non-goals. + +## Planned visuals + +- Capstone planning canvas +- Claim-to-evidence path +- Next-step learning map + +## Source map + +Current feature specifications, `website/src/status.md`, project examples, and the reader's chosen target contract + diff --git a/Book/manuscript/90-appendices-and-next-steps.md b/Book/manuscript/90-appendices-and-next-steps.md new file mode 100644 index 00000000..a2080c5d --- /dev/null +++ b/Book/manuscript/90-appendices-and-next-steps.md @@ -0,0 +1,25 @@ +# Appendices and next steps + +## Appendix A — Command quick reference + +Planned compact reference for checking, running, compiling, testing, formatting, target selection, debugging, and profiling. Every command will be verified against the pinned edition. + +## Appendix B — Default syntax on one page + +Planned scan sheet for bindings, functions, calls, blocks, records, unions, matching, lists, maps, pipelines, Results, effects, and fibers. It is a memory aid, not a second specification. + +## Appendix C — Agent prompt and verification recipe + +Planned templates for a small feature, a behavior-preserving refactor, and an optional flavor translation. Every template names invariants and ends with compiler or test evidence. + +## Appendix D — Current qualifications + +Planned release-aligned table for native and WebAssembly runtime support, effect resumption, modules, packages, memory modes, GPU execution, and the C safety boundary. + +## Appendix E — Flight Log source map + +Planned index connecting each finished file and test to the chapter that introduced it. + +## Continue learning + +Use the live [Osprey documentation](https://www.ospreylang.dev/docs/), [specifications](https://github.com/Nimblesite/osprey/tree/main/docs/specs), [status page](https://www.ospreylang.dev/status/), and [Playground](https://www.ospreylang.dev/playground/) for behavior beyond this edition. Confirm alpha-era changes against the current compiler and tests before updating book claims. diff --git a/Book/metadata.yaml b/Book/metadata.yaml new file mode 100644 index 00000000..ef6ab349 --- /dev/null +++ b/Book/metadata.yaml @@ -0,0 +1,17 @@ +--- +title: "The Osprey Book" +subtitle: "A practical first flight through modern programming." +author: + - "Christian Findlay" +publisher: "NIMBLESITE PTY LTD" +date: "2026-08-13" +language: "en-AU" +rights: "Copyright © 2026 Christian Findlay" +description: >- + A beginner-friendly guide to Osprey that teaches programming through the + Default flavor, then builds toward inferred types, pattern matching, + explicit failure, effects, fibers, deployment, and optional source flavors. +edition: "Structural scaffold — Chapter 1 complete" +osprey-version: "0.0.0-dev" +website: "https://www.ospreylang.dev/" +... diff --git a/Book/sources.json b/Book/sources.json new file mode 100644 index 00000000..67a1f58f --- /dev/null +++ b/Book/sources.json @@ -0,0 +1,113 @@ +{ + "schemaVersion": 1, + "sources": [ + { + "id": "spec-introduction", + "kind": "repository-specification", + "path": "../docs/specs/0001-Introduction.md", + "use": "language shape, explicit failure, runtime and platform qualifications" + }, + { + "id": "spec-syntax", + "kind": "repository-specification", + "path": "../docs/specs/0003-Syntax.md", + "use": "Default bindings, functions, records, unions, and expression syntax" + }, + { + "id": "spec-types", + "kind": "repository-specification", + "path": "../docs/specs/0004-TypeSystem.md", + "use": "inference, records, unions, collections, and Result preservation" + }, + { + "id": "spec-functions", + "kind": "repository-specification", + "path": "../docs/specs/0005-FunctionCalls.md", + "use": "Default call syntax and argument behavior" + }, + { + "id": "spec-patterns", + "kind": "repository-specification", + "path": "../docs/specs/0007-PatternMatching.md", + "use": "matching, destructuring, exhaustiveness, and fallback policy" + }, + { + "id": "spec-iterators", + "kind": "repository-specification", + "path": "../docs/specs/0010-LoopConstructsAndFunctionalIterators.md", + "use": "range pipelines, map, filter, fold, forEach, and stream fusion" + }, + { + "id": "spec-errors", + "kind": "repository-specification", + "path": "../docs/specs/0013-ErrorHandling.md", + "use": "Result handling, checked arithmetic, propagation, and explicit fallback" + }, + { + "id": "spec-effects", + "kind": "repository-specification", + "path": "../docs/specs/0017-AlgebraicEffects.md", + "use": "effect declarations, operations, handlers, and availability limits" + }, + { + "id": "spec-fibers", + "kind": "repository-specification", + "path": "../docs/specs/0011-LightweightFibersAndConcurrency.md", + "use": "fiber isolation, channels, scheduling, and native availability" + }, + { + "id": "spec-wasm", + "kind": "repository-specification", + "path": "../docs/specs/0022-WebAssemblyTarget.md", + "use": "WebAssembly build behavior and portable runtime subset" + }, + { + "id": "spec-flavors", + "kind": "repository-specification", + "path": "../docs/specs/0023-LanguageFlavors.md", + "use": "current Default and ML surfaces, selection, lowering boundary, and interop" + }, + { + "id": "spec-ml", + "kind": "repository-specification", + "path": "../docs/specs/0024-MLFlavorSyntax.md", + "use": "optional ML layout, currying, and source syntax" + }, + { + "id": "spec-testing", + "kind": "repository-specification", + "path": "../docs/specs/0027-TestingFramework.md", + "use": "Osprey test declarations, assertions, and test execution" + }, + { + "id": "installation-guide", + "kind": "maintained-documentation", + "path": "../website/src/docs/installation.md", + "use": "no-install Playground path, local prerequisites, install commands, and verification" + }, + { + "id": "project-messaging", + "kind": "editorial-authority", + "path": "../docs/messaging.md", + "use": "practicality, safety, performance, elegance, and claim qualifications" + }, + { + "id": "midnight-synthetic", + "kind": "visual-authority", + "path": "../docs/designs/design.md", + "use": "authoritative palette, typography, spacing, shape, and component direction" + }, + { + "id": "website-tokens", + "kind": "visual-implementation", + "path": "../website/src/css/variables.css", + "use": "implemented dark-theme tokens and long-form layout values" + }, + { + "id": "chapter-one-examples", + "kind": "executable-evidence", + "path": "examples/chapter-01/", + "use": "checked and run Default and optional ML examples used by Chapter 1" + } + ] +} diff --git a/Book/styles/epub.css b/Book/styles/epub.css new file mode 100644 index 00000000..d5902580 --- /dev/null +++ b/Book/styles/epub.css @@ -0,0 +1,228 @@ +:root { + color-scheme: dark; + --canvas: #070d1f; + --surface: #0c1325; + --surface-low: #151b2d; + --surface-high: #23293d; + --text: #dce1fb; + --muted: #bdc8cd; + --cyan: #77d7f4; + --cyan-bright: #bdeeff; + --periwinkle: #bbc5ec; + --amber: #ffbe65; + --error: #ffb4ab; + --outline: #3e484c; +} + +html { + background: var(--canvas); +} + +body { + max-width: 45rem; + margin: 0 auto; + padding: 2rem clamp(1rem, 4vw, 3rem) 5rem; + color: var(--text); + background: var(--surface); + font-family: Geist, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 1rem; + line-height: 1.72; + text-align: left; +} + +h1, +h2, +h3, +h4 { + color: var(--text); + line-height: 1.18; + letter-spacing: -0.02em; + page-break-after: avoid; +} + +h1 { + margin: 4rem 0 1.5rem; + font-size: 2.45rem; +} + +h2 { + margin: 3rem 0 1rem; + font-size: 1.75rem; +} + +h3 { + margin: 2rem 0 0.75rem; + font-size: 1.25rem; +} + +p, +li { + color: var(--muted); +} + +strong, +dt { + color: var(--text); +} + +a { + color: var(--cyan); + text-underline-offset: 0.15em; +} + +a:hover, +a:focus { + color: var(--cyan-bright); +} + +code { + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 0.88em; +} + +:not(pre) > code { + padding: 0.12em 0.34em; + color: var(--cyan-bright); + background: var(--surface-low); + border-radius: 0.35rem; +} + +pre { + margin: 1.5rem 0; + padding: 1.15rem 1.25rem; + overflow-x: auto; + color: var(--text); + background: var(--canvas); + border: 1px solid var(--outline); + border-radius: 0.5rem; + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 0.86rem; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; + page-break-inside: avoid; +} + +blockquote { + margin: 1.5rem 0; + padding: 1rem 1.25rem; + background: var(--surface-low); + border-left: 3px solid var(--cyan); + border-radius: 0 0.5rem 0.5rem 0; +} + +blockquote p:last-child { + margin-bottom: 0; +} + +img { + display: block; + max-width: 100%; + height: auto; + margin: 2rem auto 0.75rem; + border: 1px solid var(--outline); + border-radius: 0.6rem; +} + +img + em { + display: block; + margin: 0 auto 2rem; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.5; +} + +table { + width: 100%; + margin: 1.5rem 0; + border-collapse: collapse; + font-variant-numeric: tabular-nums; +} + +th, +td { + padding: 0.65rem 0.75rem; + border-bottom: 1px solid var(--outline); + text-align: left; + vertical-align: top; +} + +th { + color: var(--text); + background: var(--surface-high); + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 0.78rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +hr { + margin: 3rem 0; + border: 0; + border-top: 1px solid var(--outline); +} + +nav#TOC { + margin: 2rem 0 4rem; + padding: 1.25rem 1.5rem; + background: var(--surface-low); + border-radius: 0.6rem; +} + +nav#TOC::before { + content: "FLIGHT PLAN"; + color: var(--cyan); + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +nav#TOC ul { + padding-left: 1.2rem; +} + +@media (max-width: 38rem) { + body { + padding-inline: 1rem; + } + + h1 { + margin-top: 2.5rem; + font-size: 2rem; + } + + h2 { + font-size: 1.5rem; + } +} + +@media print { + :root { + color-scheme: light; + } + + html, + body { + color: #14161a; + background: #ffffff; + } + + p, + li, + h1, + h2, + h3, + h4, + strong { + color: #14161a; + } + + pre, + blockquote, + nav#TOC { + color: #14161a; + background: #f1f4f7; + } +} + diff --git a/Makefile b/Makefile index f7673966..df03dd2d 100644 --- a/Makefile +++ b/Makefile @@ -79,24 +79,24 @@ OSSL ?= -DOPENSSL_SUPPRESS_DEPRECATED -DOPENSSL_API_COMPAT=30000 -Wno-deprecated # the flag list per suite. T ?= -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong $(WARN) -ftrapv -std=c11 -D_GNU_SOURCE # Object lists for the archives (paths relative to compiler/, where `ar` runs). -FIB_OBJ ?= bin/memory_runtime.o bin/gpu_runtime.o bin/fiber_runtime.o bin/system_runtime.o bin/effects_runtime.o bin/string_runtime.o bin/string_runtime_list.o bin/list_runtime.o bin/map_runtime.o bin/map_runtime_hamt.o bin/json_runtime.o bin/ffi_runtime.o bin/term_runtime.o bin/random_runtime.o bin/test_runtime.o bin/coverage_runtime.o bin/profiler_runtime.o bin/profiler_sampler.o +FIB_OBJ ?= bin/memory_runtime.o bin/gpu_runtime.o bin/fiber_runtime.o bin/system_runtime.o bin/file_runtime.o bin/effects_runtime.o bin/effects_coro.o bin/string_runtime.o bin/string_runtime_list.o bin/list_runtime.o bin/map_runtime.o bin/map_runtime_hamt.o bin/json_runtime.o bin/ffi_runtime.o bin/term_runtime.o bin/random_runtime.o bin/test_runtime.o bin/coverage_runtime.o bin/profiler_runtime.o bin/profiler_sampler.o HTTP_OBJ ?= bin/http_shared.o bin/http_client_runtime.o bin/http_server_request.o bin/http_server_response.o bin/http_server_runtime.o bin/websocket_client_runtime.o bin/websocket_server_runtime.o $(FIB_OBJ) # GC backend archives (osprey --memory=gc): the tracing collector replaces # memory_runtime.o, and the value-container units are rebuilt with the malloc # redirect (osp_gc_shim.h) so their nodes live in the managed heap. Everything # else is the same object. Implements [GC-TRACE-CONSERVATIVE], spec 0018. -FIB_OBJ_GC ?= bin/memory_gc.o bin/gpu_runtime.o bin/fiber_runtime.o bin/system_runtime.o bin/effects_runtime.o bin/string_runtime.o bin/string_runtime_list.o bin/gc/list_runtime.o bin/gc/map_runtime.o bin/gc/map_runtime_hamt.o bin/json_runtime.o bin/ffi_runtime.o bin/term_runtime.o bin/random_runtime.o bin/test_runtime.o bin/coverage_runtime.o bin/profiler_runtime.o bin/profiler_sampler.o +FIB_OBJ_GC ?= bin/memory_gc.o bin/gpu_runtime.o bin/fiber_runtime.o bin/system_runtime.o bin/file_runtime.o bin/effects_runtime.o bin/effects_coro.o bin/string_runtime.o bin/string_runtime_list.o bin/gc/list_runtime.o bin/gc/map_runtime.o bin/gc/map_runtime_hamt.o bin/json_runtime.o bin/ffi_runtime.o bin/term_runtime.o bin/random_runtime.o bin/test_runtime.o bin/coverage_runtime.o bin/profiler_runtime.o bin/profiler_sampler.o HTTP_OBJ_GC ?= bin/http_shared.o bin/http_client_runtime.o bin/http_server_request.o bin/http_server_response.o bin/http_server_runtime.o bin/websocket_client_runtime.o bin/websocket_server_runtime.o $(FIB_OBJ_GC) # ARC backend archives (osprey --memory=arc): Perceus reference counting # replaces memory_runtime.o, and the value-producing units (containers + # strings + JSON) are rebuilt with the allocation redirect (osp_arc_shim.h) so # their nodes/buffers carry the 16-byte header and registry entry. Implements # [GC-ARC-PERCEUS], spec 0018. -FIB_OBJ_ARC ?= bin/memory_arc.o bin/gpu_runtime.o bin/fiber_runtime.o bin/system_runtime.o bin/effects_runtime.o bin/arc/string_runtime.o bin/arc/string_runtime_list.o bin/arc/list_runtime.o bin/arc/map_runtime.o bin/arc/map_runtime_hamt.o bin/arc/json_runtime.o bin/ffi_runtime.o bin/term_runtime.o bin/random_runtime.o bin/test_runtime.o bin/coverage_runtime.o bin/profiler_runtime.o bin/profiler_sampler.o +FIB_OBJ_ARC ?= bin/memory_arc.o bin/gpu_runtime.o bin/fiber_runtime.o bin/system_runtime.o bin/arc/file_runtime.o bin/effects_runtime.o bin/effects_coro.o bin/arc/string_runtime.o bin/arc/string_runtime_list.o bin/arc/list_runtime.o bin/arc/map_runtime.o bin/arc/map_runtime_hamt.o bin/arc/json_runtime.o bin/ffi_runtime.o bin/term_runtime.o bin/random_runtime.o bin/test_runtime.o bin/coverage_runtime.o bin/profiler_runtime.o bin/profiler_sampler.o HTTP_OBJ_ARC ?= bin/http_shared.o bin/http_client_runtime.o bin/http_server_request.o bin/http_server_response.o bin/http_server_runtime.o bin/websocket_client_runtime.o bin/websocket_server_runtime.o $(FIB_OBJ_ARC) NATIVE_RUNTIME_CONFIG ?= compiler/bin/.native-runtime-config NATIVE_RUNTIME_STAMP ?= compiler/bin/.native-runtime.stamp -NATIVE_RUNTIME_INPUTS ?= $(filter-out compiler/runtime/%_tests.c compiler/runtime/test_http_length_validation.c compiler/runtime/test_openssl.c compiler/runtime/test_system_runtime.c compiler/runtime/web_runtime.c,$(wildcard compiler/runtime/*.c)) $(wildcard compiler/runtime/*.h) +NATIVE_RUNTIME_INPUTS ?= $(filter-out compiler/runtime/%_tests.c compiler/runtime/test_http_length_validation.c compiler/runtime/test_openssl.c compiler/runtime/test_system_runtime.c compiler/runtime/test_file_runtime.c compiler/runtime/web_runtime.c,$(wildcard compiler/runtime/*.c)) $(wildcard compiler/runtime/*.h) NATIVE_RUNTIME_ARCHIVES ?= compiler/bin/libfiber_runtime.a compiler/bin/libhttp_runtime.a compiler/bin/libfiber_runtime_gc.a compiler/bin/libhttp_runtime_gc.a compiler/bin/libfiber_runtime_arc.a compiler/bin/libhttp_runtime_arc.a compiler/lib/libfiber_runtime.a compiler/lib/libhttp_runtime.a compiler/lib/libfiber_runtime_gc.a compiler/lib/libhttp_runtime_gc.a compiler/lib/libfiber_runtime_arc.a compiler/lib/libhttp_runtime_arc.a # WebAssembly (wasm32-wasip1) cross-build toolchain — opt-in via `make wasm`. @@ -142,7 +142,7 @@ endef # the WASI random_get host call). Each compiles its non-portable half out under # `#ifndef __wasm__`, so adding them unskips file and random programs on wasm32 # without pretending fork/exec or pthreads exist. -WASM_RT_SRC ?= memory_runtime gpu_runtime string_runtime string_runtime_list list_runtime map_runtime map_runtime_hamt json_runtime effects_runtime test_runtime coverage_runtime web_runtime profiler_runtime wasm_builtins_runtime system_runtime random_runtime +WASM_RT_SRC ?= memory_runtime gpu_runtime string_runtime string_runtime_list list_runtime map_runtime map_runtime_hamt json_runtime effects_runtime test_runtime coverage_runtime web_runtime profiler_runtime wasm_builtins_runtime system_runtime file_runtime random_runtime # `make wasm-serve` static-host dir + port for the in-browser example. WASM_SERVE_DIR ?= examples/wasm WASM_SERVE_PORT ?= 8080 @@ -410,9 +410,12 @@ $(NATIVE_RUNTIME_STAMP): $(NATIVE_RUNTIME_INPUTS) $(NATIVE_RUNTIME_CONFIG) Makef $(CC) $(A) -include runtime/osp_arc_shim.h runtime/string_runtime.c -o bin/arc/string_runtime.o && \ $(CC) $(A) -include runtime/osp_arc_shim.h runtime/string_runtime_list.c -o bin/arc/string_runtime_list.o && \ $(CC) $(B) -include runtime/osp_arc_shim.h runtime/json_runtime.c -o bin/arc/json_runtime.o && \ + $(CC) $(A) -include runtime/osp_arc_shim.h runtime/file_runtime.c -o bin/arc/file_runtime.o && \ $(CC) -c -fPIC -O2 $(WARN_MAX) -Wpedantic -std=c11 -D_GNU_SOURCE runtime/fiber_runtime.c -o bin/fiber_runtime.o && \ $(CC) $(A) runtime/system_runtime.c -o bin/system_runtime.o && \ + $(CC) $(A) runtime/file_runtime.c -o bin/file_runtime.o && \ $(CC) $(A) runtime/effects_runtime.c -o bin/effects_runtime.o && \ + $(CC) $(A) runtime/effects_coro.c -o bin/effects_coro.o && \ $(CC) $(A) runtime/string_runtime.c -o bin/string_runtime.o && \ $(CC) $(A) runtime/string_runtime_list.c -o bin/string_runtime_list.o && \ $(CC) $(B) runtime/list_runtime.c -o bin/list_runtime.o && \ @@ -459,9 +462,9 @@ _runtime_wasm: @echo "==> building wasm runtime archive ($(WASM_TARGET), sysroot $(WASI_SYSROOT))" @cd compiler && set -e && $(MKDIR) bin/wasm lib && \ for u in $(WASM_RT_SRC); do \ - $(WASM_CC) $(WASM_CFLAGS) runtime/$$u.c -o bin/wasm/$$u.o; \ + $(WASM_CC) $(WASM_CFLAGS) runtime/$$u.c -o bin/wasm/$$u.o || exit 1; \ done && \ - $(WASM_AR) rcs bin/libosprey_runtime_wasm.a bin/wasm/*.o && \ + $(WASM_AR) rcs bin/libosprey_runtime_wasm.a $(addprefix bin/wasm/,$(addsuffix .o,$(WASM_RT_SRC))) && \ cp bin/libosprey_runtime_wasm.a lib/ # --- rust (crates/) --------------------------------------------------------- @@ -521,7 +524,7 @@ _coverage_check_rust: # memory_arc_tests covers the counting itself. OSSL_CFLAGS = $(OSSL) `pkg-config --cflags openssl 2>/dev/null || echo ""` OSSL_LIBS = `pkg-config --libs openssl 2>/dev/null || echo "-lssl -lcrypto"` -RT_THREADS = runtime/fiber_runtime.c runtime/system_runtime.c runtime/effects_runtime.c \ +RT_THREADS = runtime/fiber_runtime.c runtime/system_runtime.c runtime/file_runtime.c runtime/effects_runtime.c runtime/effects_coro.c \ runtime/profiler_runtime.c runtime/profiler_sampler.c # Frame-pointer profile for the profiler suite: its unwind tests need -g and # real frame chains, and it predates the WARN core, so it keeps its own flags. @@ -538,6 +541,7 @@ C_TEST_SUITES ?= memory_gc_stack_root_tests memory_arc_tests memory_gc_tests \ memory_pool_tests memory_runtime_tests memory_golden_tests gpu_runtime_tests \ list_tests map_tests string_runtime_tests json_runtime_tests \ effects_runtime_tests builtins_runtime_tests test_system_runtime \ + test_file_runtime \ test_http_length_validation http_server_send_tests http_server_request_tests \ fiber_runtime_tests http_runtime_tests profiler_runtime_tests \ coverage_runtime_tests @@ -557,11 +561,12 @@ C_SRC_map_tests = runtime/map_tests.c runtime/map_runtime.c runtime/map_runtime_ C_SRC_string_runtime_tests = runtime/string_runtime_tests.c runtime/string_runtime.c runtime/string_runtime_list.c runtime/memory_runtime.c C_SRC_json_runtime_tests = runtime/json_runtime_tests.c runtime/json_runtime.c C_LIBS_json_runtime_tests = -pthread -C_SRC_effects_runtime_tests = runtime/effects_runtime_tests.c runtime/effects_runtime.c runtime/profiler_runtime.c runtime/profiler_sampler.c +C_SRC_effects_runtime_tests = runtime/effects_runtime_tests.c runtime/effects_runtime.c runtime/effects_coro.c runtime/memory_arc.c runtime/gpu_runtime.c runtime/profiler_runtime.c runtime/profiler_sampler.c C_LIBS_effects_runtime_tests = -pthread C_SRC_builtins_runtime_tests = runtime/builtins_runtime_tests.c runtime/ffi_runtime.c runtime/random_runtime.c runtime/term_runtime.c runtime/test_runtime.c -C_SRC_test_system_runtime = runtime/test_system_runtime.c runtime/system_runtime.c runtime/memory_runtime.c +C_SRC_test_system_runtime = runtime/test_system_runtime.c runtime/system_runtime.c runtime/file_runtime.c runtime/memory_runtime.c C_LIBS_test_system_runtime = -pthread +C_SRC_test_file_runtime = runtime/test_file_runtime.c runtime/file_runtime.c runtime/memory_runtime.c C_FLAGS_test_http_length_validation = $(OSSL_CFLAGS) C_SRC_test_http_length_validation = runtime/test_http_length_validation.c C_FLAGS_http_server_send_tests = $(OSSL_CFLAGS) @@ -597,8 +602,30 @@ _test_c_runtime: # suite's gcov summaries, and gates every `language: "c"` entry in # coverage-thresholds.json at its threshold. A library's number is the MAX # line coverage across the suites linking it — per-TU summaries cannot be -# unioned, so max is the honest lower bound. wasm-only units (web_runtime.c, -# wasm_builtins_runtime.c) do not build natively and are not gated. +# unioned, so max is the honest lower bound. +# +# The gate reads the JSON and looks each key up in the summaries, so a runtime +# .c that no key names is compiled, instrumented, summarised — and discarded. +# effects_coro.c reached 375 lines of the whole continuation core in exactly +# that state, split out of an already-gated effects_runtime.c and inheriting +# none of its 90% floor. The completeness check below closes it: every unit that +# SHIPS must be gated or exempt, and an unlisted one fails the gate. +# +# "Ships" is decided by ARCHIVE MEMBERSHIP, not by a name pattern over +# runtime/*.c. The first cut of this check skipped `test_*` to get past the test +# harness sources — and so skipped runtime/test_runtime.c, which is a real +# member of every native archive, leaving the gate green with it ungated and its +# exemption entry doing nothing. Membership is the fact the check actually +# wants; a filename never was. Units built only for wasm (web_runtime.c, +# wasm_builtins_runtime.c) are absent from these lists and so are not required — +# they do not build natively, so gcov has nothing to measure. +C_SHIPPED_UNITS = $(sort $(basename $(notdir $(FIB_OBJ) $(HTTP_OBJ) \ + $(FIB_OBJ_GC) $(HTTP_OBJ_GC) $(FIB_OBJ_ARC) $(HTTP_OBJ_ARC)))) +# The exemptions, and why: term_runtime.c and test_runtime.c run every case in a +# FORKED CHILD whose gcov counters are never flushed back, so gcov reports 0% +# against passing assertions — gate them once the harness calls __gcov_dump in +# the child. +C_COV_EXEMPT ?= term_runtime test_runtime GCOV_TOOL ?= $(shell if $(CC) --version 2>/dev/null | grep -qi clang; then \ if command -v xcrun >/dev/null 2>&1; then echo "xcrun llvm-cov gcov"; \ else echo "llvm-cov gcov"; fi; \ @@ -613,6 +640,10 @@ _coverage_check_c_runtime: @libs=$$(jq -r '.projects | to_entries[] | select(.value.language=="c") | .key' "$(COVERAGE_THRESHOLDS_FILE)"); \ if [ -z "$$libs" ]; then echo "[c] FAIL: no C entries in $(COVERAGE_THRESHOLDS_FILE) -- the gate would pass vacuously"; exit 1; fi; \ fail=0; \ + for n in $(C_SHIPPED_UNITS); do \ + printf '%s\n' $(C_COV_EXEMPT) $$libs | grep -qx "$$n" || { \ + echo "[c] FAIL: runtime/$$n.c ships in a native archive but is neither gated in $(COVERAGE_THRESHOLDS_FILE) nor in C_COV_EXEMPT -- an ungated library cannot regress visibly"; fail=1; }; \ + done; \ for lib in $$libs; do \ thr=$$(jq -r --arg l "$$lib" '.projects[$$l].threshold' "$(COVERAGE_THRESHOLDS_FILE)"); \ best=$$(for f in compiler/bin/cov/*/summary.txt; do \ diff --git a/compiler/runtime/effects_coro.c b/compiler/runtime/effects_coro.c new file mode 100644 index 00000000..6b686af9 --- /dev/null +++ b/compiler/runtime/effects_coro.c @@ -0,0 +1,398 @@ +// effects_coro.c - Thread-as-continuation for resumable algebraic effects. +// +// A handler arm's `resume` needs the handled computation to be suspendable, so +// that computation runs on its own pthread and control ping-pongs across a +// condvar: the body thread blocks inside a `perform`, the host thread runs the +// matching arm, and `resume` hands a value back and unblocks the body. +// +// wasm32-wasip1 has no usable pthreads, so this whole unit is excluded from the +// wasm archive; resumable-effect programs link-fail there and are SKIPped by +// the wasm golden suite, exactly like the fiber and HTTP runtimes. +// [WASM-TARGET-EFFECTS] + +#include +#include +#include +#include +#include + +#include "effects_runtime.h" +#include "memory_hooks.h" +#include "profiler_runtime.h" + +// The payload of one in-flight `perform`, allocated per suspension and sized by +// the operation's REAL arity. +// +// This replaced a fixed `int64_t args[16]`, which kept the caller's declared +// arg_count but copied only sixteen words and answered every later index with +// zero. A seventeen-argument operation therefore made its policy decision from +// a fabricated 0 and the process exited successfully — silent data corruption +// with no diagnostic (#182). +// +// `kinds[i]` records whether `words[i]` is a managed pointer this mailbox OWNS +// or a bare scalar. The performer transfers its +1 at suspend and retiring the +// mailbox releases exactly the managed slots, so an operand cannot outlive the +// perform that sent it (a dangling read after `resume` returns) nor survive it +// (a leak, #185). Implements [EFFECTS-OPERATION-MAILBOX]. +typedef struct { + int64_t op_id; + int64_t *words; + uint8_t *kinds; + int64_t count; +} OpMailbox; + +typedef struct OspreyCoro { + pthread_mutex_t lock; + pthread_cond_t cond; + pthread_t thread; + bool started; + bool joined; + bool suspended; + bool done; + bool abort; + // One perform occupies the mailbox/resume_value channel at a time + // [EFFECTS-FIBER-PERFORM]. Concurrent performers (fibers spawned inside + // the handled body) queue on this flag instead of overwriting each + // other's arguments and stealing each other's resume value. + bool in_flight; + // The current suspension's payload, owned until a dispatcher takes it. + OpMailbox *mail; + int64_t resume_value; + int64_t result; + void *region_env; +} OspreyCoro; + +typedef struct CoroStartArgs { + OspreyCoro *coro; + int64_t (*body)(void *); + void *body_env; + HandlerSnapshot *snapshot; +} CoroStartArgs; + +static void *checked_malloc(size_t size, const char *what) { + void *p = malloc(size); + if (p == NULL) { + fprintf(stderr, "FATAL: Failed to allocate %s\n", what); + abort(); + } + return p; +} + +// Take over the performer's +1 on every managed slot. `args`/`kinds` are the +// performer's stack arrays, valid only for this call, so the words are copied. +static OpMailbox *mailbox_new(int64_t op_id, const int64_t *args, const uint8_t *kinds, + int64_t count) { + OpMailbox *mail = (OpMailbox *)checked_malloc(sizeof(OpMailbox), "effect operation mailbox"); + mail->op_id = op_id; + mail->count = count > 0 ? count : 0; + mail->words = NULL; + mail->kinds = NULL; + if (mail->count == 0) { + return mail; + } + // A positive arity with no argument array can only come from a codegen bug, + // and inventing zeros for it is the exact silent corruption this mailbox + // exists to end. [EFFECTS-OPERATION-MAILBOX] + if (args == NULL || kinds == NULL) { + fprintf(stderr, "FATAL: effect operation %lld declares %lld arguments but sent none\n", + (long long)op_id, (long long)count); + abort(); + } + mail->words = (int64_t *)checked_malloc((size_t)mail->count * sizeof(int64_t), + "effect operation arguments"); + mail->kinds = (uint8_t *)checked_malloc((size_t)mail->count, "effect operation argument kinds"); + for (int64_t i = 0; i < mail->count; i++) { + mail->words[i] = args[i]; + mail->kinds[i] = kinds[i]; + } + return mail; +} + +// Give back the +1 the performer transferred on every managed slot. The single +// place that decodes the OSP_OP_ARG_* kinds, so a mailbox being retired and a +// suspension that never built one cannot drift apart on which words are +// pointers. [EFFECTS-OPERATION-MAILBOX] +static void release_operands(const int64_t *words, const uint8_t *kinds, int64_t count) { + if (words == NULL || kinds == NULL) { + return; + } + for (int64_t i = 0; i < count; i++) { + if (kinds[i] == OSP_OP_ARG_MANAGED) { + osp_release((void *)(uintptr_t)words[i]); + } + } +} + +static void mailbox_free(OpMailbox *mail) { + if (mail == NULL) { + return; + } + release_operands(mail->words, mail->kinds, mail->count); + free(mail->words); + free(mail->kinds); + free(mail); +} + +void *__osprey_coro_new(void *env) { + OspreyCoro *coro = (OspreyCoro *)checked_malloc(sizeof(OspreyCoro), "effect continuation"); + pthread_mutex_init(&coro->lock, NULL); + pthread_cond_init(&coro->cond, NULL); + coro->started = false; + coro->joined = false; + coro->suspended = false; + coro->done = false; + coro->abort = false; + coro->in_flight = false; + coro->mail = NULL; + coro->resume_value = 0; + coro->result = 0; + coro->region_env = env; + return coro; +} + +static void *__osprey_coro_thread(void *raw) { + CoroStartArgs *args = (CoroStartArgs *)raw; + OspreyCoro *coro = args->coro; + // Effect continuations run on their own pthread; register so profiler + // samples attribute to them distinctly [PROF-COLLECT-REGISTRY]. + osp_prof_thread_register(-1, "effect"); + if (args->snapshot != NULL) { + __osprey_handler_restore(args->snapshot); + args->snapshot = NULL; + } + int64_t result = args->body(args->body_env); + free(args); + osp_prof_thread_unregister(); + + pthread_mutex_lock(&coro->lock); + coro->result = result; + coro->done = true; + coro->suspended = false; + pthread_cond_broadcast(&coro->cond); + pthread_mutex_unlock(&coro->lock); + return NULL; +} + +void __osprey_coro_start(void *raw, int64_t (*body)(void *), void *body_env, + HandlerSnapshot *snapshot) { + OspreyCoro *coro = (OspreyCoro *)raw; + if (coro == NULL || body == NULL) { + fprintf(stderr, "FATAL: Invalid effect continuation start\n"); + abort(); + } + CoroStartArgs *args = + (CoroStartArgs *)checked_malloc(sizeof(CoroStartArgs), "effect continuation start args"); + args->coro = coro; + args->body = body; + args->body_env = body_env; + args->snapshot = snapshot; + + // The body thread allocates and releases on the shared value heap while the + // host thread runs handler arms on it, so the memory backend must leave its + // single-threaded lock-free fast path BEFORE the second thread can exist — + // pthread_create is the happens-before barrier. Without this every + // resumable effect raced ARC's refcounts. [MEM-BACKENDS] + osp_mem_notify_multithreaded(); + + int rc = pthread_create(&coro->thread, NULL, __osprey_coro_thread, args); + if (rc != 0) { + free(args); + fprintf(stderr, "FATAL: Failed to start effect continuation thread\n"); + abort(); + } + pthread_mutex_lock(&coro->lock); + coro->started = true; + while (!coro->suspended && !coro->done) { + pthread_cond_wait(&coro->cond, &coro->lock); + } + pthread_mutex_unlock(&coro->lock); +} + +int64_t __osprey_coro_suspend(void *raw, int64_t op_id, const int64_t *args, const uint8_t *kinds, + int64_t arg_count) { + OspreyCoro *coro = (OspreyCoro *)raw; + if (coro == NULL) { + // Same rule as the aborted path below: the operands arrived at +1 for a + // mailbox, and without a continuation there is nothing to build one. + release_operands(args, kinds, arg_count); + return 0; + } + pthread_mutex_lock(&coro->lock); + // Claim the channel [EFFECTS-FIBER-PERFORM]: a second concurrent perform + // (e.g. from a sibling fiber) must wait its turn, or it would overwrite + // this perform's arguments and both would consume the same resume value — + // nondeterministic wrong answers with exit 0. The drive loop re-enters on + // re-suspension, so a queued perform is dispatched as soon as the current + // one's resume value is consumed. + while (coro->in_flight && !coro->abort) { + pthread_cond_wait(&coro->cond, &coro->lock); + } + if (coro->abort) { + pthread_mutex_unlock(&coro->lock); + // Killed while queued behind another perform: no mailbox was built, so + // nothing downstream will ever release these operands. Done outside the + // lock — the memory backend must not be entered holding it. + release_operands(args, kinds, arg_count); + pthread_exit(NULL); + } + coro->in_flight = true; + coro->mail = mailbox_new(op_id, args, kinds, arg_count); + coro->suspended = true; + pthread_cond_broadcast(&coro->cond); + while (coro->suspended && !coro->abort) { + pthread_cond_wait(&coro->cond, &coro->lock); + } + if (coro->abort) { + pthread_mutex_unlock(&coro->lock); + // The handoff already happened here, so the operands are NOT this + // thread's to release: either the dispatcher took the mailbox and + // retires it with __osprey_coro_mail_free, or it is still in + // `coro->mail` and __osprey_coro_free retires it. Releasing again would + // be a double free, not a leak fix. + pthread_exit(NULL); + } + int64_t resume_value = coro->resume_value; + coro->in_flight = false; + pthread_cond_broadcast(&coro->cond); + pthread_mutex_unlock(&coro->lock); + return resume_value; +} + +// Hand the current suspension's mailbox to the dispatcher, which owns it from +// here and must retire it with __osprey_coro_mail_free. Clearing the slot is +// what lets an arm resume and have the body perform again: the nested +// suspension installs a fresh mailbox instead of overwriting one still in use. +void *__osprey_coro_take_args(void *raw) { + OspreyCoro *coro = (OspreyCoro *)raw; + if (coro == NULL) { + return NULL; + } + pthread_mutex_lock(&coro->lock); + OpMailbox *mail = coro->mail; + coro->mail = NULL; + pthread_mutex_unlock(&coro->lock); + return mail; +} + +int64_t __osprey_coro_mail_op(void *raw) { + // Dispatching with no mailbox would select an arm from an invented + // operation id and silently run the wrong handler. There is no correct + // value to return. [EFFECTS-OPERATION-MAILBOX] + if (raw == NULL) { + fprintf(stderr, "FATAL: effect dispatch with no operation mailbox\n"); + abort(); + } + return ((OpMailbox *)raw)->op_id; +} + +int64_t __osprey_coro_mail_arg(void *raw, int64_t index) { + OpMailbox *mail = (OpMailbox *)raw; + // Answering an out-of-range slot with 0 is precisely the corruption this + // mailbox replaced. A dispatcher only ever reads indices below the arity + // its own signature declared, so reaching here is a compiler bug and the + // only honest response is to stop. [EFFECTS-OPERATION-MAILBOX] + if (mail == NULL || index < 0 || index >= mail->count) { + fprintf(stderr, "FATAL: effect operation argument %lld is outside the %lld sent\n", + (long long)index, (long long)(mail == NULL ? 0 : mail->count)); + abort(); + } + return mail->words[index]; +} + +void __osprey_coro_mail_free(void *raw) { mailbox_free((OpMailbox *)raw); } + +int64_t __osprey_coro_resume(void *raw, int64_t value) { + OspreyCoro *coro = (OspreyCoro *)raw; + if (coro == NULL) { + return 0; + } + pthread_mutex_lock(&coro->lock); + // Multi-shot rejection [EFFECTS-RESUME]: the thread-as-continuation model is + // single-shot — a consumed (completed) pthread stack cannot be re-run. A + // second `resume` on an already-finished continuation would silently return + // the stale first result (a wrong answer with exit 0), so reject it loudly + // instead. Legitimate re-entry (the body performed again) leaves the coro + // suspended, not done, and never reaches this guard. + if (coro->done) { + pthread_mutex_unlock(&coro->lock); + fprintf(stderr, + "fatal: continuation already resumed " + "(multi-shot resume is not supported)\n"); + exit(1); + } + coro->resume_value = value; + coro->suspended = false; + pthread_cond_broadcast(&coro->cond); + while (!coro->suspended && !coro->done) { + pthread_cond_wait(&coro->cond, &coro->lock); + } + int64_t result = coro->done ? coro->result : 0; + pthread_mutex_unlock(&coro->lock); + return result; +} + +int64_t __osprey_coro_done(void *raw) { + OspreyCoro *coro = (OspreyCoro *)raw; + if (coro == NULL) { + return 1; + } + pthread_mutex_lock(&coro->lock); + int64_t done = coro->done ? 1 : 0; + pthread_mutex_unlock(&coro->lock); + return done; +} + +int64_t __osprey_coro_result(void *raw) { + OspreyCoro *coro = (OspreyCoro *)raw; + if (coro == NULL) { + return 0; + } + pthread_mutex_lock(&coro->lock); + int64_t result = coro->result; + pthread_mutex_unlock(&coro->lock); + return result; +} + +void __osprey_coro_abort(void *raw) { + OspreyCoro *coro = (OspreyCoro *)raw; + if (coro == NULL) { + return; + } + pthread_mutex_lock(&coro->lock); + if (!coro->done) { + coro->abort = true; + coro->suspended = false; + pthread_cond_broadcast(&coro->cond); + } + pthread_mutex_unlock(&coro->lock); + if (coro->started && !coro->joined) { + pthread_join(coro->thread, NULL); + coro->joined = true; + } + pthread_mutex_lock(&coro->lock); + coro->done = true; + pthread_mutex_unlock(&coro->lock); +} + +void __osprey_coro_free(void *raw) { + OspreyCoro *coro = (OspreyCoro *)raw; + if (coro == NULL) { + return; + } + if (coro->started && !coro->joined) { + if (!coro->done) { + __osprey_coro_abort(coro); + } else { + pthread_join(coro->thread, NULL); + coro->joined = true; + } + } + // A suspension nobody dispatched — an aborted region, or an operation whose + // id matched no arm — still holds its managed operands. Retire it here or + // they outlive the program. [EFFECTS-OPERATION-MAILBOX] + mailbox_free(coro->mail); + coro->mail = NULL; + pthread_cond_destroy(&coro->cond); + pthread_mutex_destroy(&coro->lock); + free(coro); +} diff --git a/compiler/runtime/effects_runtime.c b/compiler/runtime/effects_runtime.c index fb44d82e..0b8e1a48 100644 --- a/compiler/runtime/effects_runtime.c +++ b/compiler/runtime/effects_runtime.c @@ -1,22 +1,24 @@ // effects_runtime.c - Runtime handler stack for algebraic effects -// Implements dynamic handler resolution for nested effect handlers +// Implements dynamic handler resolution for nested effect handlers. +// +// The `resume` half — thread-as-continuation, the operation mailbox and the +// coroutine drive protocol — lives in effects_coro.c, which shares only the +// handler snapshot declared in effects_runtime.h. #include #include #include -#include #include // int64_t — explicit so the wasm32-wasip1 sysroot resolves it #include -#include "profiler_runtime.h" +#include "effects_runtime.h" #ifdef __wasm__ // wasm32-wasip1 is single-threaded: the effect handler stack needs no real -// locking, so the mutex ops become no-ops. The thread-based coroutine -// continuation section (struct OspreyCoro onward) is excluded wholesale for -// wasm via `#ifndef __wasm__` — it needs pthread_create/cond/join/exit, which -// wasi-libc cannot honour. With those symbols absent from the wasm archive, -// resumable-effect programs link-fail and are SKIPped by the wasm golden suite, -// exactly like the fiber/HTTP runtimes. [WASM-TARGET-EFFECTS] +// locking, so the mutex ops become no-ops. effects_coro.c is excluded from the +// wasm archive wholesale — it needs pthread_create/cond/join/exit, which +// wasi-libc cannot honour. With those symbols absent, resumable-effect programs +// link-fail and are SKIPped by the wasm golden suite, exactly like the +// fiber/HTTP runtimes. [WASM-TARGET-EFFECTS] #define pthread_mutex_init(m, a) ((void)(m), (void)(a), 0) #define pthread_mutex_lock(m) ((void)(m), 0) #define pthread_mutex_unlock(m) ((void)(m), 0) @@ -175,10 +177,10 @@ void __osprey_handler_stack_cleanup(void) { } // HandlerSnapshot for copying handler state across fiber boundaries -typedef struct { +struct HandlerSnapshot { HandlerEntry entries[MAX_HANDLER_STACK_DEPTH]; int count; -} HandlerSnapshot; +}; // Snapshot the current thread's handler stack (called in parent before fiber_spawn) // Returns a heap-allocated snapshot that the caller must pass to __osprey_handler_restore @@ -218,276 +220,3 @@ void __osprey_handler_restore(HandlerSnapshot *snap) { free(snap); } - -// Thread-based effect continuations: a handler `resume` is implemented by -// running the handled computation on its own pthread and ping-ponging control -// via a condvar. wasm32-wasip1 has no usable pthreads, so this entire section -// is compiled only for native targets; on wasm the `__osprey_coro_*` symbols -// are intentionally absent, making resumable-effect programs link-fail and be -// SKIPped by the wasm golden suite. [WASM-TARGET-EFFECTS] -#ifndef __wasm__ -typedef struct OspreyCoro { - pthread_mutex_t lock; - pthread_cond_t cond; - pthread_t thread; - bool started; - bool joined; - bool suspended; - bool done; - bool abort; - // One perform occupies the op/args/resume_value channel at a time - // [EFFECTS-FIBER-PERFORM]. Concurrent performers (fibers spawned inside - // the handled body) queue on this flag instead of overwriting each - // other's arguments and stealing each other's resume value. - bool in_flight; - int64_t op_id; - int64_t args[16]; - int64_t arg_count; - int64_t resume_value; - int64_t result; - void *region_env; -} OspreyCoro; - -typedef struct CoroStartArgs { - OspreyCoro *coro; - int64_t (*body)(void *); - void *body_env; - HandlerSnapshot *snapshot; -} CoroStartArgs; - -void *__osprey_coro_new(void *env) { - OspreyCoro *coro = (OspreyCoro *)malloc(sizeof(OspreyCoro)); - if (coro == NULL) { - fprintf(stderr, "FATAL: Failed to allocate effect continuation\n"); - abort(); - } - pthread_mutex_init(&coro->lock, NULL); - pthread_cond_init(&coro->cond, NULL); - coro->started = false; - coro->joined = false; - coro->suspended = false; - coro->done = false; - coro->abort = false; - coro->in_flight = false; - coro->op_id = 0; - coro->arg_count = 0; - coro->resume_value = 0; - coro->result = 0; - coro->region_env = env; - for (int i = 0; i < 16; i++) { - coro->args[i] = 0; - } - return coro; -} - -static void *__osprey_coro_thread(void *raw) { - CoroStartArgs *args = (CoroStartArgs *)raw; - OspreyCoro *coro = args->coro; - // Effect continuations run on their own pthread; register so profiler - // samples attribute to them distinctly [PROF-COLLECT-REGISTRY]. - osp_prof_thread_register(-1, "effect"); - if (args->snapshot != NULL) { - __osprey_handler_restore(args->snapshot); - args->snapshot = NULL; - } - int64_t result = args->body(args->body_env); - free(args); - osp_prof_thread_unregister(); - - pthread_mutex_lock(&coro->lock); - coro->result = result; - coro->done = true; - coro->suspended = false; - pthread_cond_broadcast(&coro->cond); - pthread_mutex_unlock(&coro->lock); - return NULL; -} - -void __osprey_coro_start(void *raw, int64_t (*body)(void *), void *body_env, HandlerSnapshot *snapshot) { - OspreyCoro *coro = (OspreyCoro *)raw; - if (coro == NULL || body == NULL) { - fprintf(stderr, "FATAL: Invalid effect continuation start\n"); - abort(); - } - CoroStartArgs *args = (CoroStartArgs *)malloc(sizeof(CoroStartArgs)); - if (args == NULL) { - fprintf(stderr, "FATAL: Failed to allocate effect continuation start args\n"); - abort(); - } - args->coro = coro; - args->body = body; - args->body_env = body_env; - args->snapshot = snapshot; - - int rc = pthread_create(&coro->thread, NULL, __osprey_coro_thread, args); - if (rc != 0) { - free(args); - fprintf(stderr, "FATAL: Failed to start effect continuation thread\n"); - abort(); - } - pthread_mutex_lock(&coro->lock); - coro->started = true; - while (!coro->suspended && !coro->done) { - pthread_cond_wait(&coro->cond, &coro->lock); - } - pthread_mutex_unlock(&coro->lock); -} - -int64_t __osprey_coro_suspend(void *raw, int64_t op_id, int64_t *args, int64_t arg_count) { - OspreyCoro *coro = (OspreyCoro *)raw; - if (coro == NULL) { - return 0; - } - pthread_mutex_lock(&coro->lock); - // Claim the channel [EFFECTS-FIBER-PERFORM]: a second concurrent perform - // (e.g. from a sibling fiber) must wait its turn, or it would overwrite - // this perform's arguments and both would consume the same resume value — - // nondeterministic wrong answers with exit 0. The drive loop re-enters on - // re-suspension, so a queued perform is dispatched as soon as the current - // one's resume value is consumed. - while (coro->in_flight && !coro->abort) { - pthread_cond_wait(&coro->cond, &coro->lock); - } - if (coro->abort) { - pthread_mutex_unlock(&coro->lock); - pthread_exit(NULL); - } - coro->in_flight = true; - coro->op_id = op_id; - coro->arg_count = arg_count; - int64_t capped = arg_count; - if (capped > 16) { - capped = 16; - } - for (int64_t i = 0; i < capped; i++) { - coro->args[i] = args == NULL ? 0 : args[i]; - } - coro->suspended = true; - pthread_cond_broadcast(&coro->cond); - while (coro->suspended && !coro->abort) { - pthread_cond_wait(&coro->cond, &coro->lock); - } - if (coro->abort) { - pthread_mutex_unlock(&coro->lock); - pthread_exit(NULL); - } - int64_t resume_value = coro->resume_value; - coro->in_flight = false; - pthread_cond_broadcast(&coro->cond); - pthread_mutex_unlock(&coro->lock); - return resume_value; -} - -int64_t __osprey_coro_resume(void *raw, int64_t value) { - OspreyCoro *coro = (OspreyCoro *)raw; - if (coro == NULL) { - return 0; - } - pthread_mutex_lock(&coro->lock); - // Multi-shot rejection [EFFECTS-RESUME]: the thread-as-continuation model is - // single-shot — a consumed (completed) pthread stack cannot be re-run. A - // second `resume` on an already-finished continuation would silently return - // the stale first result (a wrong answer with exit 0), so reject it loudly - // instead. Legitimate re-entry (the body performed again) leaves the coro - // suspended, not done, and never reaches this guard. - if (coro->done) { - pthread_mutex_unlock(&coro->lock); - fprintf(stderr, - "fatal: continuation already resumed " - "(multi-shot resume is not supported)\n"); - exit(1); - } - coro->resume_value = value; - coro->suspended = false; - pthread_cond_broadcast(&coro->cond); - while (!coro->suspended && !coro->done) { - pthread_cond_wait(&coro->cond, &coro->lock); - } - int64_t result = coro->done ? coro->result : 0; - pthread_mutex_unlock(&coro->lock); - return result; -} - -int64_t __osprey_coro_done(void *raw) { - OspreyCoro *coro = (OspreyCoro *)raw; - if (coro == NULL) { - return 1; - } - pthread_mutex_lock(&coro->lock); - int64_t done = coro->done ? 1 : 0; - pthread_mutex_unlock(&coro->lock); - return done; -} - -int64_t __osprey_coro_op(void *raw) { - OspreyCoro *coro = (OspreyCoro *)raw; - if (coro == NULL) { - return 0; - } - pthread_mutex_lock(&coro->lock); - int64_t op = coro->op_id; - pthread_mutex_unlock(&coro->lock); - return op; -} - -int64_t __osprey_coro_arg(void *raw, int64_t index) { - OspreyCoro *coro = (OspreyCoro *)raw; - if (coro == NULL || index < 0 || index >= 16) { - return 0; - } - pthread_mutex_lock(&coro->lock); - int64_t arg = index < coro->arg_count ? coro->args[index] : 0; - pthread_mutex_unlock(&coro->lock); - return arg; -} - -int64_t __osprey_coro_result(void *raw) { - OspreyCoro *coro = (OspreyCoro *)raw; - if (coro == NULL) { - return 0; - } - pthread_mutex_lock(&coro->lock); - int64_t result = coro->result; - pthread_mutex_unlock(&coro->lock); - return result; -} - -void __osprey_coro_abort(void *raw) { - OspreyCoro *coro = (OspreyCoro *)raw; - if (coro == NULL) { - return; - } - pthread_mutex_lock(&coro->lock); - if (!coro->done) { - coro->abort = true; - coro->suspended = false; - pthread_cond_broadcast(&coro->cond); - } - pthread_mutex_unlock(&coro->lock); - if (coro->started && !coro->joined) { - pthread_join(coro->thread, NULL); - coro->joined = true; - } - pthread_mutex_lock(&coro->lock); - coro->done = true; - pthread_mutex_unlock(&coro->lock); -} - -void __osprey_coro_free(void *raw) { - OspreyCoro *coro = (OspreyCoro *)raw; - if (coro == NULL) { - return; - } - if (coro->started && !coro->joined) { - if (!coro->done) { - __osprey_coro_abort(coro); - } else { - pthread_join(coro->thread, NULL); - coro->joined = true; - } - } - pthread_cond_destroy(&coro->cond); - pthread_mutex_destroy(&coro->lock); - free(coro); -} -#endif // !__wasm__ — thread-based effect continuations excluded on wasm32-wasip1 diff --git a/compiler/runtime/effects_runtime.h b/compiler/runtime/effects_runtime.h new file mode 100644 index 00000000..47847afe --- /dev/null +++ b/compiler/runtime/effects_runtime.h @@ -0,0 +1,28 @@ +// Shared surface between the two halves of the algebraic-effect runtime: the +// dynamic handler stack (effects_runtime.c) and the thread-as-continuation +// machinery that implements `resume` (effects_coro.c). +#ifndef OSPREY_EFFECTS_RUNTIME_H +#define OSPREY_EFFECTS_RUNTIME_H + +#include + +// A copy of one thread's handler stack, taken on the thread that installs the +// handlers and restored on the thread that continues the computation. Opaque +// here: only effects_runtime.c knows the layout, everything else moves it by +// pointer. +typedef struct HandlerSnapshot HandlerSnapshot; + +HandlerSnapshot *__osprey_handler_snapshot(void); +void __osprey_handler_restore(HandlerSnapshot *snap); + +// Operand kinds in an operation mailbox. A MANAGED slot holds a heap pointer +// the mailbox OWNS — the performer hands over its +1 at suspend and retiring +// the mailbox releases it. A SCALAR slot is a bare machine word nobody owns. +// Codegen emits this same numbering (crates/osprey-codegen/src/effects.rs), so +// the two sides must be changed together: a slot mis-tagged MANAGED releases a +// reference that was never taken, and one mis-tagged SCALAR leaks. +// Implements [EFFECTS-OPERATION-MAILBOX]. +#define OSP_OP_ARG_SCALAR 0 +#define OSP_OP_ARG_MANAGED 1 + +#endif // OSPREY_EFFECTS_RUNTIME_H diff --git a/compiler/runtime/effects_runtime_tests.c b/compiler/runtime/effects_runtime_tests.c index 66d4e3ee..6edcfc4f 100644 --- a/compiler/runtime/effects_runtime_tests.c +++ b/compiler/runtime/effects_runtime_tests.c @@ -1,10 +1,15 @@ -// Assertion-driven tests for effects_runtime.c — the dynamic handler stack and -// the thread-based effect continuations behind `handle ... in` and `resume` -// ([EFFECTS-FIBER-PERFORM], [EFFECTS-RESUME], docs/specs/0009). Linked with -// profiler_runtime.c/profiler_sampler.c (the coro thread registers itself) by -// the Makefile's _test_c_runtime. POSIX-only harness (fork/waitpid) for the -// multi-shot rejection, which by contract exits the process. +// Assertion-driven tests for effects_runtime.c and effects_coro.c — the dynamic +// handler stack and the thread-based effect continuations behind +// `handle ... in` and `resume` ([EFFECTS-FIBER-PERFORM], [EFFECTS-RESUME], +// [EFFECTS-OPERATION-MAILBOX], docs/specs/0009). Linked with +// profiler_runtime.c/profiler_sampler.c (the coro thread registers itself) and +// with memory_arc.c — the one backend whose retain/release are real, so the +// mailbox's ownership of managed operands is observable — by the Makefile's +// _test_c_runtime. POSIX-only harness (fork/waitpid) for the multi-shot +// rejection, which by contract exits the process. #include +#include +#include #include #include #include @@ -12,6 +17,11 @@ #include #include +#include "effects_runtime.h" +#include "memory_hooks.h" + +size_t osp_arc_live_objects(void); + int __osprey_handler_push(const char *effect_name, const char *operation_name, void *handler_func_ptr, void *env); int __osprey_handler_pop(void); @@ -21,17 +31,17 @@ void *__osprey_handler_lookup_env(const char *effect_name, const char *operation_name); int __osprey_handler_stack_depth(void); void __osprey_handler_stack_cleanup(void); -void *__osprey_handler_snapshot(void); -void __osprey_handler_restore(void *snap); void *__osprey_coro_new(void *env); void __osprey_coro_start(void *coro, int64_t (*body)(void *), void *body_env, - void *snapshot); -int64_t __osprey_coro_suspend(void *coro, int64_t op_id, int64_t *args, - int64_t arg_count); + HandlerSnapshot *snapshot); +int64_t __osprey_coro_suspend(void *coro, int64_t op_id, const int64_t *args, + const uint8_t *kinds, int64_t arg_count); int64_t __osprey_coro_resume(void *coro, int64_t value); int64_t __osprey_coro_done(void *coro); -int64_t __osprey_coro_op(void *coro); -int64_t __osprey_coro_arg(void *coro, int64_t index); +void *__osprey_coro_take_args(void *coro); +int64_t __osprey_coro_mail_op(void *mail); +int64_t __osprey_coro_mail_arg(void *mail, int64_t index); +void __osprey_coro_mail_free(void *mail); int64_t __osprey_coro_result(void *coro); void __osprey_coro_abort(void *coro); void __osprey_coro_free(void *coro); @@ -147,13 +157,24 @@ typedef struct { #define RESUME_SECOND 7 #define CORO_BASE 100 +// Twenty scalars — well past the sixteen a fixed-width mailbox could hold, and +// the exact shape that used to arrive as zeros with no diagnostic (#182). +#define WIDE_ARITY 20 +#define SLOT_VALUE(i) ((int64_t)(10 * ((i) + 1))) + // Performs twice, then finishes with a value derived from both resumes — so // the final result proves each resume value reached the body exactly once. static int64_t body_two_performs(void *raw) { CoroEnv *e = raw; - int64_t args[2] = {10, 20}; - int64_t r1 = __osprey_coro_suspend(e->coro, OP_FIRST, args, 2); - int64_t r2 = __osprey_coro_suspend(e->coro, OP_SECOND, NULL, 0); + int64_t args[WIDE_ARITY]; + uint8_t kinds[WIDE_ARITY]; + for (int i = 0; i < WIDE_ARITY; i++) { + args[i] = SLOT_VALUE(i); + kinds[i] = OSP_OP_ARG_SCALAR; + } + int64_t r1 = + __osprey_coro_suspend(e->coro, OP_FIRST, args, kinds, WIDE_ARITY); + int64_t r2 = __osprey_coro_suspend(e->coro, OP_SECOND, NULL, NULL, 0); return e->base + r1 * r2; } @@ -162,16 +183,22 @@ static void t_coro_ping_pong(void) { CHECK(env.coro != NULL); __osprey_coro_start(env.coro, body_two_performs, &env, NULL); CHECK(__osprey_coro_done(env.coro) == 0); - CHECK(__osprey_coro_op(env.coro) == OP_FIRST); - CHECK(__osprey_coro_arg(env.coro, 0) == 10); - CHECK(__osprey_coro_arg(env.coro, 1) == 20); - CHECK(__osprey_coro_arg(env.coro, 2) == 0); // past arg_count - CHECK(__osprey_coro_arg(env.coro, -1) == 0); // negative index - CHECK(__osprey_coro_arg(env.coro, 16) == 0); // past the hard cap + + void *mail = __osprey_coro_take_args(env.coro); + CHECK(__osprey_coro_mail_op(mail) == OP_FIRST); + for (int i = 0; i < WIDE_ARITY; i++) { + CHECK(__osprey_coro_mail_arg(mail, i) == SLOT_VALUE(i)); + } + __osprey_coro_mail_free(mail); + // Taking transfers the mailbox: a second dispatcher must not see it again. + CHECK(__osprey_coro_take_args(env.coro) == NULL); + CHECK(__osprey_coro_resume(env.coro, RESUME_FIRST) == 0); // re-suspended CHECK(__osprey_coro_done(env.coro) == 0); - CHECK(__osprey_coro_op(env.coro) == OP_SECOND); - CHECK(__osprey_coro_arg(env.coro, 0) == 0); // zero-arg perform + void *empty = __osprey_coro_take_args(env.coro); + CHECK(__osprey_coro_mail_op(empty) == OP_SECOND); // zero-arg perform + __osprey_coro_mail_free(empty); + int64_t want = CORO_BASE + RESUME_FIRST * RESUME_SECOND; CHECK(__osprey_coro_resume(env.coro, RESUME_SECOND) == want); CHECK(__osprey_coro_done(env.coro) == 1); @@ -179,6 +206,46 @@ static void t_coro_ping_pong(void) { __osprey_coro_free(env.coro); } +static void *g_managed_operand; + +// Performs once, handing the mailbox a +1 on a managed operand exactly as a +// compiled `perform` does. +static int64_t body_managed_operand(void *raw) { + CoroEnv *e = raw; + int64_t args[1] = {(int64_t)(uintptr_t)g_managed_operand}; + uint8_t kinds[1] = {OSP_OP_ARG_MANAGED}; + osp_retain(g_managed_operand); + return e->base + __osprey_coro_suspend(e->coro, OP_FIRST, args, kinds, 1); +} + +// A managed slot is a reference the mailbox OWNS: retiring it drops exactly +// that reference — no more, no less. Dropping none is how every managed operand +// of a resumable operation used to survive to process exit (#185); dropping two +// would free an operand a handler arm still holds. +static void t_mailbox_owns_managed_slots(void) { + size_t before = osp_arc_live_objects(); + g_managed_operand = osp_alloc_tagged(16, OSP_MEM_RAW); + CHECK(osp_arc_live_objects() == before + 1); + + CoroEnv env = {.coro = __osprey_coro_new(NULL), .base = CORO_BASE}; + __osprey_coro_start(env.coro, body_managed_operand, &env, NULL); + + void *mail = __osprey_coro_take_args(env.coro); + CHECK(__osprey_coro_mail_arg(mail, 0) == + (int64_t)(uintptr_t)g_managed_operand); + __osprey_coro_mail_free(mail); + // The mailbox's reference is gone and this test's is not: still live. + CHECK(osp_arc_live_objects() == before + 1); + + CHECK(__osprey_coro_resume(env.coro, RESUME_FIRST) == + CORO_BASE + RESUME_FIRST); + __osprey_coro_free(env.coro); + + // ...and this test held the last one, so releasing it reclaims the object. + osp_release(g_managed_operand); + CHECK(osp_arc_live_objects() == before); +} + // The snapshot passed to start is restored ON the continuation's thread: the // body observes the parent's handlers. Cross-thread handler propagation is // what makes `perform` inside a handled fiber resolve at all. @@ -203,7 +270,7 @@ static void t_coro_snapshot_transfer(void) { static int64_t body_one_perform(void *raw) { CoroEnv *e = raw; - (void)__osprey_coro_suspend(e->coro, OP_FIRST, NULL, 0); + (void)__osprey_coro_suspend(e->coro, OP_FIRST, NULL, NULL, 0); return 99; } @@ -219,6 +286,61 @@ static void t_coro_abort(void) { __osprey_coro_free(env.coro); } +static void *g_queued_operand; +static volatile int g_queued_entered; + +// A sibling performer — a fiber under the same handler — arriving while another +// perform holds the channel. It takes its +1 for a mailbox, exactly as compiled +// code does, and then parks. +// The scalar slot is not decoration: releasing a bare integer as if it were a +// pointer is a wild free, so the release path must read the kinds, not the +// count. 7 is a value no allocator would ever return. +#define SCALAR_SLOT_WORD 7 + +static void *queued_performer(void *raw) { + int64_t args[2] = {(int64_t)(uintptr_t)g_queued_operand, SCALAR_SLOT_WORD}; + uint8_t kinds[2] = {OSP_OP_ARG_MANAGED, OSP_OP_ARG_SCALAR}; + osp_retain(g_queued_operand); + g_queued_entered = 1; + (void)__osprey_coro_suspend(raw, OP_SECOND, args, kinds, 2); + return NULL; // unreachable: the abort kills this thread inside suspend +} + +// Long enough for a created thread to reach suspend's in-flight wait. Aborting +// before it gets there takes the same branch, so this only decides whether the +// QUEUED path or the already-aborted path is the one exercised. +#define QUEUE_PARK_US 50000 + +// An aborting handler kills a queued performer before it can build a mailbox. +// The mailbox is what owns a managed operand and `mailbox_free` is what +// releases it — so on this path nothing downstream exists to do it, and the +// operand survived to process exit. The abort/resume tests above cover the +// active operation; only their COMBINATION reaches this. +// [EFFECTS-OPERATION-MAILBOX] +static void t_aborted_queued_perform_releases_its_operands(void) { + size_t before = osp_arc_live_objects(); + g_queued_operand = osp_alloc_tagged(16, OSP_MEM_RAW); + g_queued_entered = 0; + + CoroEnv env = {.coro = __osprey_coro_new(NULL), .base = 0}; + __osprey_coro_start(env.coro, body_one_perform, &env, NULL); // claims the channel + CHECK(__osprey_coro_done(env.coro) == 0); + + pthread_t queued; + CHECK(pthread_create(&queued, NULL, queued_performer, env.coro) == 0); + usleep(QUEUE_PARK_US); + CHECK(g_queued_entered == 1); + + __osprey_coro_abort(env.coro); // the arm returned without resuming + CHECK(pthread_join(queued, NULL) == 0); + __osprey_coro_free(env.coro); + + // Only this test's own reference is left, so releasing it reclaims the + // object. A queued performer's abandoned +1 would keep it alive here. + osp_release(g_queued_operand); + CHECK(osp_arc_live_objects() == before); +} + // Freeing a still-suspended continuation aborts it internally — no hang, no // leak of the parked thread. static void t_coro_free_while_suspended(void) { @@ -231,11 +353,14 @@ static void t_coro_free_while_suspended(void) { // Every continuation entry point tolerates NULL. static void t_coro_null_safety(void) { - CHECK(__osprey_coro_suspend(NULL, 1, NULL, 0) == 0); + CHECK(__osprey_coro_suspend(NULL, 1, NULL, NULL, 0) == 0); CHECK(__osprey_coro_resume(NULL, 1) == 0); CHECK(__osprey_coro_done(NULL) == 1); - CHECK(__osprey_coro_op(NULL) == 0); - CHECK(__osprey_coro_arg(NULL, 0) == 0); + CHECK(__osprey_coro_take_args(NULL) == NULL); + // __osprey_coro_mail_op / _mail_arg deliberately have no NULL tolerance: + // inventing an operation id or an argument is the silent corruption the + // mailbox exists to end, so both abort. [EFFECTS-OPERATION-MAILBOX] + __osprey_coro_mail_free(NULL); CHECK(__osprey_coro_result(NULL) == 0); __osprey_coro_abort(NULL); __osprey_coro_free(NULL); @@ -264,14 +389,21 @@ static void t_multishot_resume_exits(void) { } int main(void) { + // The ARC live-object counters are armed by OSPREY_ARC_DEBUG at boot and read + // 0 otherwise, so arm before any allocation — an unarmed run would make the + // mailbox-ownership assertions vacuously true. [GC-ARC-PERCEUS] + (void)setenv("OSPREY_ARC_DEBUG", "1", 1); + osp_mem_boot(); t_stack_shadowing(); t_name_truncation(); t_overflow_exact(); t_snapshot_restore(); t_cleanup_reinit(); t_coro_ping_pong(); + t_mailbox_owns_managed_slots(); t_coro_snapshot_transfer(); t_coro_abort(); + t_aborted_queued_perform_releases_its_operands(); t_coro_free_while_suspended(); t_coro_null_safety(); t_multishot_resume_exits(); diff --git a/compiler/runtime/file_runtime.c b/compiler/runtime/file_runtime.c new file mode 100644 index 00000000..888cecc9 --- /dev/null +++ b/compiler/runtime/file_runtime.c @@ -0,0 +1,205 @@ +// Portable file I/O for `readFile` / `writeFile`, and the thread-local failure +// channel every runtime entry point reports through (io_error.h). +// +// Split out of system_runtime.c, whose other half is the fork/exec process +// runtime that wasm32-wasip1 cannot have. This unit is portable — WASI supplies +// fopen/fread/fwrite — so it is in every archive, native and wasm. +// +// Two rules govern the code below, and both were learned from defects this file +// exists to close: +// +// 1. A LENGTH COMES FROM WHAT WAS READ, NEVER FROM A SEEK. fseek/ftell are +// entitled to fail on any non-seekable stream (a FIFO, a socket, a +// character device), and ftell reports -1 when they do. +// 2. A WRITE IS NOT DONE UNTIL THE FLUSH SUCCEEDS. stdio hands bytes to the +// OS at fclose, so that is where ENOSPC, EPIPE and EIO appear. +// +// Implements [BUILTIN-FILE], [BUILTIN-FILE-ERRMSG]. + +#include +#include +#include +#include +#include + +#include "io_error.h" + +#ifdef _WIN32 +#include "osprey_win_compat.h" +#endif + +// --- the failure channel ---------------------------------------------------- + +// Long enough for an operation, a filesystem path and a strerror sentence. +#define OSP_IO_ERROR_MAX 512 +#define OSP_IO_REASON_MAX 128 + +// Thread-local, exactly like errno: a fiber, a coroutine body and the HTTP +// server thread each keep their own, so one thread's failure can never be +// read as another's. +static __thread char io_error_text[OSP_IO_ERROR_MAX]; +static __thread int io_error_present; + +void osp_io_error_clear(void) { + io_error_present = 0; + io_error_text[0] = '\0'; +} + +// strerror() may hand back a shared static buffer, and this runtime calls it +// from several threads at once, so every platform uses its re-entrant form — +// except wasm32-wasip1, whose libc has no `strerror_r` at all and whose +// programs have no second thread to race with. Copying the message out +// immediately keeps even that branch's result private to `out`. +static void describe_errno(int err, char *out, size_t out_size) { +#if defined(_WIN32) + if (strerror_s(out, out_size, err) != 0) { + (void)snprintf(out, out_size, "errno %d", err); + } +#elif defined(__wasm__) + (void)snprintf(out, out_size, "%s", strerror(err)); +#elif defined(__GLIBC__) && defined(_GNU_SOURCE) + // The GNU form returns the message, which may or may not be `out`. + const char *message = strerror_r(err, out, out_size); + if (message != out) { + (void)snprintf(out, out_size, "%s", message); + } +#else + if (strerror_r(err, out, out_size) != 0) { + (void)snprintf(out, out_size, "errno %d", err); + } +#endif +} + +void osp_io_error_set(const char *op, const char *subject, int err) { + char reason[OSP_IO_REASON_MAX]; + if (err != 0) { + describe_errno(err, reason, sizeof(reason)); + } else { + (void)snprintf(reason, sizeof(reason), "unspecified failure"); + } + (void)snprintf(io_error_text, sizeof(io_error_text), "%s: %s: %s", + op != NULL ? op : "io", + subject != NULL ? subject : "", reason); + io_error_present = 1; +} + +const char *osp_io_error(void) { + return io_error_present ? io_error_text : NULL; +} + +char *osp_io_error_take(void) { + return io_error_present ? strdup(io_error_text) : NULL; +} + +// --- reading ---------------------------------------------------------------- + +// Starting capacity, then doubling. Sized so ordinary source and config files +// are read in a single fread with no reallocation. +#define FILE_READ_CHUNK_BYTES 65536 + +// Double `*cap`, or report failure. The NUL terminator lives in the capacity, +// so the caller's usable room is always `*cap - 1`. +static char *grow_buffer(char *buf, size_t *cap) { + if (*cap > SIZE_MAX / 2) { + return NULL; + } + size_t bigger = *cap * 2; + char *grown = realloc(buf, bigger); + if (grown != NULL) { + *cap = bigger; + } + return grown; +} + +// Drain `file` to a NUL-terminated heap buffer, or NULL with the channel set. +// The loop stops on a short fread — end of stream or error, told apart by +// ferror afterwards — so the byte count is whatever the stream actually +// produced and never a seek's opinion of it. +static char *read_stream(FILE *file, const char *filename) { + size_t cap = FILE_READ_CHUNK_BYTES; + size_t len = 0; + char *content = malloc(cap); + if (content == NULL) { + osp_io_error_set("readFile", filename, ENOMEM); + return NULL; + } + for (;;) { + if (len + 1 == cap) { + char *grown = grow_buffer(content, &cap); + if (grown == NULL) { + free(content); + osp_io_error_set("readFile", filename, ENOMEM); + return NULL; + } + content = grown; + } + size_t room = cap - 1 - len; + size_t got = fread(content + len, 1, room, file); + len += got; + if (got < room) { + break; + } + } + if (ferror(file)) { + int err = errno; + free(content); + osp_io_error_set("readFile", filename, err); + return NULL; + } + content[len] = '\0'; + return content; +} + +// Read a whole file. Returns a heap buffer the caller owns, or NULL with the +// reason on the channel. Implements [BUILTIN-FILE]. +char *read_file(char *filename) { + osp_io_error_clear(); + if (filename == NULL) { + osp_io_error_set("readFile", NULL, EINVAL); + return NULL; + } + FILE *file = fopen(filename, "r"); + if (file == NULL) { + osp_io_error_set("readFile", filename, errno); + return NULL; + } + char *content = read_stream(file, filename); + (void)fclose(file); + return content; +} + +// --- writing ---------------------------------------------------------------- + +// Write `content` over `filename`, returning the byte count written or a +// negative status with the reason on the channel. Every failure point is +// checked: a partial fwrite and a failed flush both mean the data is not on +// disk, and reporting either as success is silent data loss. +// Implements [BUILTIN-FILE]. +int64_t write_file(char *filename, char *content) { + osp_io_error_clear(); + if (filename == NULL || content == NULL) { + osp_io_error_set("writeFile", filename, EINVAL); + return -1; + } + FILE *file = fopen(filename, "w"); + if (file == NULL) { + osp_io_error_set("writeFile", filename, errno); + return -2; + } + size_t want = strlen(content); + size_t written = fwrite(content, 1, want, file); + if (written != want) { + int err = ferror(file) && errno != 0 ? errno : EIO; + (void)fclose(file); + osp_io_error_set("writeFile", filename, err); + return -3; + } + // The bytes reach the OS here, not at fwrite, so this is where a full disk + // or a hung-up pipe reports itself. Dropping this status announces a + // successful write of data that was never stored. + if (fclose(file) != 0) { + osp_io_error_set("writeFile", filename, errno); + return -4; + } + return (int64_t)written; +} diff --git a/compiler/runtime/io_error.h b/compiler/runtime/io_error.h new file mode 100644 index 00000000..d62db6e5 --- /dev/null +++ b/compiler/runtime/io_error.h @@ -0,0 +1,48 @@ +// The I/O failure-reason channel shared by the C runtime and codegen. +// +// Every runtime entry point that reports failure as a magic number (a negative +// int64, a NULL char*) loses WHY it failed the moment it returns: ENOENT, +// EACCES and ENOSPC all arrive at the Osprey program as the same -2. This +// channel carries the reason alongside that status so `Error { message }` holds +// a truthful sentence instead of the placeholder word "Error". +// +// Discipline, and it is not optional: +// +// * A producer calls osp_io_error_clear() on entry and osp_io_error_set() on +// EVERY failure path. A failure path that returns without setting is a +// silent failure — the exact defect this channel exists to delete. +// * The message is thread-local, like errno: a fiber, a coroutine body and +// the HTTP server thread each keep their own, so one thread's failure can +// never be read as another's. +// * Codegen clears immediately before the call and reads immediately after, +// so a stale reason from an earlier op can never be attributed to a later +// one. crates/osprey-codegen/src/extern_call.rs emits that pair. +// +// Implements [BUILTIN-FILE-ERRMSG]. +#ifndef OSPREY_IO_ERROR_H +#define OSPREY_IO_ERROR_H + +// Discard any reason held for the calling thread. After this, osp_io_error() +// reads NULL until the next osp_io_error_set(). +void osp_io_error_clear(void); + +// Record why `op` failed on `subject` (a path, handle or URL — may be NULL), +// with `err` an errno value, or 0 when the cause is not an errno. Formats +// "op: subject: reason" into the calling thread's buffer, truncating rather +// than allocating: a reporter that can itself fail out of memory is no reporter. +void osp_io_error_set(const char *op, const char *subject, int err); + +// The calling thread's current reason, or NULL if none is held. BORROWED: the +// pointer belongs to the channel and dies at this thread's next set/clear, so +// anything that outlives the call must use osp_io_error_take instead. +const char *osp_io_error(void); + +// The calling thread's current reason as a fresh heap copy the caller owns, or +// NULL if none is held. This is what codegen stores into an `Error { message }` +// — a Result can outlive any number of later I/O calls, and a borrowed pointer +// into the channel would read as whatever failed most recently, or as freed +// memory. Under the ARC backend this unit is built with osp_arc_shim.h, so the +// copy carries a Perceus header and the Result block's drop releases it. +char *osp_io_error_take(void); + +#endif // OSPREY_IO_ERROR_H diff --git a/compiler/runtime/system_runtime.c b/compiler/runtime/system_runtime.c index 2f8615d3..d758fca7 100644 --- a/compiler/runtime/system_runtime.c +++ b/compiler/runtime/system_runtime.c @@ -14,10 +14,9 @@ // // wasm32-wasip1 has neither fork/exec nor usable pthreads, so the process half // is compiled only for native targets — the same split effects_runtime.c makes -// for thread-based continuations. The file/JSON/string half below IS portable -// (WASI supplies fopen/fread/fwrite/remove), so this translation unit is in the -// wasm runtime archive and `readFile`/`writeFile` programs run there instead of -// link-failing the whole file. [WASM-TARGET] +// for thread-based continuations. This unit stays in the wasm archive for its +// stdio line-buffering constructor; the portable file half moved to +// file_runtime.c, which is in every archive. [WASM-TARGET] #ifndef __wasm__ #include #ifdef _WIN32 @@ -600,50 +599,7 @@ char *spawn_process(char *command) { #endif // _WIN32 #endif // !__wasm__ — fork/exec/pthreads are absent on wasm32-wasip1 -// Write file function - returns 0 for success, negative for error -int64_t write_file(char *filename, char *content) { - if (!filename || !content) { - return -1; - } - - FILE *file = fopen(filename, "w"); - if (!file) { - return -2; - } - - size_t written = fwrite(content, 1, strlen(content), file); - fclose(file); - - return (int64_t)written; -} - -// Read file function - returns content or NULL on error -char *read_file(char *filename) { - if (!filename) { - return NULL; - } - - FILE *file = fopen(filename, "r"); - if (!file) { - return NULL; - } - - // Get file size - fseek(file, 0, SEEK_END); - long size = ftell(file); - fseek(file, 0, SEEK_SET); - - // Allocate buffer and read content - char *content = malloc((size_t)size + 1); - if (!content) { - fclose(file); - return NULL; - } - - size_t read_size = fread(content, 1, (size_t)size, file); - content[read_size] = '\0'; - fclose(file); - - return content; -} +// read_file / write_file live in file_runtime.c: they are portable, they share +// the failure channel in io_error.h, and keeping them here left this unit's +// only wasm-relevant content buried under the process runtime. diff --git a/compiler/runtime/test_file_runtime.c b/compiler/runtime/test_file_runtime.c new file mode 100644 index 00000000..61012e0b --- /dev/null +++ b/compiler/runtime/test_file_runtime.c @@ -0,0 +1,178 @@ +// Assertion-driven tests for file_runtime.c — the portable read_file/write_file +// pair and the failure channel in io_error.h. Split out of +// test_system_runtime.c when file_runtime.c was split out of system_runtime.c: +// a suite covers one translation unit, and both files were over the size +// budget together. Linked with memory_runtime.c by the Makefile's +// _test_c_runtime; POSIX-only harness. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "io_error.h" + +extern int64_t write_file(char *filename, char *content); +extern char *read_file(char *filename); + +#define FILE_ROUNDTRIP_PATH "/tmp/osprey_file_runtime_test.txt" + +// write_file returns the byte count written; read_file returns the exact +// content; NULLs and missing files are rejected; rewrites truncate. +void test_file_roundtrip(void) { + assert(write_file(NULL, "x") == -1); + assert(write_file(FILE_ROUNDTRIP_PATH, NULL) == -1); + assert(read_file(NULL) == NULL); + assert(read_file("/nonexistent_osprey_dir/nope.txt") == NULL); + assert(write_file("/nonexistent_osprey_dir/nope.txt", "x") == -2); + const char *content = "line one\nline two\n"; + assert(write_file(FILE_ROUNDTRIP_PATH, (char *)(uintptr_t)content) == + (int64_t)strlen(content)); + char *back = read_file(FILE_ROUNDTRIP_PATH); + assert(back != NULL && strcmp(back, content) == 0); + free(back); + assert(write_file(FILE_ROUNDTRIP_PATH, (char *)(uintptr_t) "short") == 5); + char *truncated = read_file(FILE_ROUNDTRIP_PATH); + assert(truncated != NULL && strcmp(truncated, "short") == 0); // truncated + free(truncated); + assert(write_file(FILE_ROUNDTRIP_PATH, (char *)(uintptr_t) "") == 0); + char *emptied = read_file(FILE_ROUNDTRIP_PATH); + assert(emptied != NULL && emptied[0] == '\0'); + free(emptied); + assert(remove(FILE_ROUNDTRIP_PATH) == 0); +} + +#define FIFO_PATH "/tmp/osprey_file_runtime_fifo" +// Comfortably past any pipe buffer, so the writer below cannot finish before +// the reader disappears, and past any malloc slack, so an undersized +// destination buffer corrupts the heap instead of getting away with it. +#define FIFO_PAYLOAD_BYTES 1048576 + +// Fork a child running `body` against FIFO_PATH and return its pid; the caller +// reaps it. The FIFO is created here so neither side can race its existence. +static pid_t fork_fifo_peer(void (*body)(void)) { + (void)remove(FIFO_PATH); + assert(mkfifo(FIFO_PATH, 0600) == 0); + pid_t peer = fork(); + assert(peer >= 0); + if (peer == 0) { + body(); + _exit(0); + } + return peer; +} + +static void fifo_write_payload(void) { + FILE *out = fopen(FIFO_PATH, "w"); + if (out != NULL) { + for (size_t i = 0; i < FIFO_PAYLOAD_BYTES; i++) { + (void)fputc('A', out); + } + (void)fclose(out); + } +} + +// read_file must survive a NON-SEEKABLE stream. fseek/ftell both fail on a +// FIFO and ftell reports -1; sizing the destination from it allocated +// malloc((size_t)-1 + 1) — ZERO bytes — and then fread((size_t)-1) into it, +// overflowing the heap by however many bytes the writer chose to send. The +// length must come from what was actually read, never from a seek that a +// stream is entitled to refuse. Implements [BUILTIN-FILE]. +static void test_read_file_non_seekable(void) { + pid_t writer = fork_fifo_peer(fifo_write_payload); + char *content = read_file(FIFO_PATH); + int status = 0; + (void)waitpid(writer, &status, 0); + assert(content != NULL); + assert(strlen(content) == FIFO_PAYLOAD_BYTES); + for (size_t i = 0; i < FIFO_PAYLOAD_BYTES; i++) { + assert(content[i] == 'A'); + } + free(content); + assert(remove(FIFO_PATH) == 0); +} + +static void fifo_reader_hangs_up(void) { + FILE *in = fopen(FIFO_PATH, "r"); + if (in != NULL) { + usleep(50000); // let the writer's fopen return and fill the pipe buffer + (void)fclose(in); + } +} + +// A write that does not reach its destination must NOT report success. stdio +// buffers, so the bytes leave for the file at flush time — which is fclose — +// and a write_file that returns fwrite's count without checking it against the +// requested length, and drops fclose's status entirely, reports a successful +// write of data that was never stored. Silent data loss. Implements +// [BUILTIN-FILE]. +static void test_write_file_reports_a_failed_flush(void) { + void (*previous)(int) = signal(SIGPIPE, SIG_IGN); + pid_t reader = fork_fifo_peer(fifo_reader_hangs_up); + char *payload = malloc(FIFO_PAYLOAD_BYTES + 1); + assert(payload != NULL); + memset(payload, 'B', FIFO_PAYLOAD_BYTES); + payload[FIFO_PAYLOAD_BYTES] = '\0'; + int64_t written = write_file(FIFO_PATH, payload); + int status = 0; + (void)waitpid(reader, &status, 0); + free(payload); + assert(written < 0); + const char *reason = osp_io_error(); + assert(reason != NULL); + assert(strstr(reason, strerror(EPIPE)) != NULL); + (void)signal(SIGPIPE, previous); + assert(remove(FIFO_PATH) == 0); +} + +// Every failure carries WHY it failed, and every success retires the previous +// reason so a later failure cannot inherit a stale one. Without this the +// Osprey-level `Error { message }` reads the placeholder word "Error" and a +// missing directory is indistinguishable from a permissions denial or a full +// disk. Implements [BUILTIN-FILE-ERRMSG]. +static void test_io_failures_report_a_truthful_reason(void) { + const char *missing = "/nonexistent_osprey_dir/nope.txt"; + assert(read_file((char *)(uintptr_t)missing) == NULL); + const char *read_reason = osp_io_error(); + assert(read_reason != NULL); + assert(strstr(read_reason, missing) != NULL); + assert(strstr(read_reason, strerror(ENOENT)) != NULL); + + assert(write_file((char *)(uintptr_t)missing, (char *)(uintptr_t) "x") == -2); + const char *write_reason = osp_io_error(); + assert(write_reason != NULL); + assert(strstr(write_reason, missing) != NULL); + assert(strstr(write_reason, strerror(ENOENT)) != NULL); + + // A success must leave nothing behind for the next failure to borrow. + assert(write_file(FILE_ROUNDTRIP_PATH, (char *)(uintptr_t) "ok") == 2); + assert(osp_io_error() == NULL); + char *back = read_file(FILE_ROUNDTRIP_PATH); + assert(back != NULL && strcmp(back, "ok") == 0); + assert(osp_io_error() == NULL); + free(back); + assert(remove(FILE_ROUNDTRIP_PATH) == 0); + + // A rejected argument is a failure like any other and owes a reason too. + assert(write_file(NULL, (char *)(uintptr_t) "x") == -1); + assert(osp_io_error() != NULL); + assert(read_file(NULL) == NULL); + assert(osp_io_error() != NULL); +} + +int main(void) { + printf("Running File Runtime Tests...\n\n"); + + test_file_roundtrip(); + test_read_file_non_seekable(); + test_write_file_reports_a_failed_flush(); + test_io_failures_report_a_truthful_reason(); + + printf("=== ALL FILE RUNTIME TESTS PASSED ===\n"); + return 0; +} diff --git a/compiler/runtime/test_system_runtime.c b/compiler/runtime/test_system_runtime.c index d24709e5..bfa4edff 100644 --- a/compiler/runtime/test_system_runtime.c +++ b/compiler/runtime/test_system_runtime.c @@ -1,16 +1,22 @@ // Assertion-driven tests for system_runtime.c — the process runtime -// (spawn/await/cleanup with streamed callbacks), the legacy blocking -// spawn_process, and the portable read_file/write_file pair. Linked with -// memory_runtime.c by the Makefile's _test_c_runtime; POSIX-only harness. +// (spawn/await/cleanup with streamed callbacks) and the legacy blocking +// spawn_process. The read_file/write_file pair moved to +// test_file_runtime.c with the source it covers. Linked with memory_runtime.c +// by the Makefile's _test_c_runtime; POSIX-only harness. #include #include #include #include #include #include -#include +#include #include +// system_runtime.c caps concurrently tracked processes at MAX_PROCESSES and +// never recycles an id. The probe below must be free to burn every one of +// them, so it bounds itself well past that cap rather than guessing it. +#define MAX_PROCESSES_PROBE_LIMIT 4000 + // Include the system runtime header (we'll define the interface) extern int64_t spawn_process_with_handler(const char *command, void (*handler)(int64_t, int64_t, @@ -18,8 +24,6 @@ extern int64_t spawn_process_with_handler(const char *command, extern int64_t await_process(int64_t process_id); extern void cleanup_process(int64_t process_id); extern char *spawn_process(char *command); -extern int64_t write_file(char *filename, char *content); -extern char *read_file(char *filename); // Test event handler data typedef struct { @@ -330,31 +334,78 @@ void test_legacy_spawn_process(void) { free(big); } -#define FILE_ROUNDTRIP_PATH "/tmp/osprey_system_runtime_test.txt" - -// write_file returns the byte count written; read_file returns the exact -// content; NULLs and missing files are rejected; rewrites truncate. -void test_file_roundtrip(void) { - assert(write_file(NULL, "x") == -1); - assert(write_file(FILE_ROUNDTRIP_PATH, NULL) == -1); - assert(read_file(NULL) == NULL); - assert(read_file("/nonexistent_osprey_dir/nope.txt") == NULL); - assert(write_file("/nonexistent_osprey_dir/nope.txt", "x") == -2); - const char *content = "line one\nline two\n"; - assert(write_file(FILE_ROUNDTRIP_PATH, (char *)(uintptr_t)content) == - (int64_t)strlen(content)); - char *back = read_file(FILE_ROUNDTRIP_PATH); - assert(back != NULL && strcmp(back, content) == 0); - free(back); - assert(write_file(FILE_ROUNDTRIP_PATH, (char *)(uintptr_t) "short") == 5); - char *truncated = read_file(FILE_ROUNDTRIP_PATH); - assert(truncated != NULL && strcmp(truncated, "short") == 0); // truncated - free(truncated); - assert(write_file(FILE_ROUNDTRIP_PATH, (char *)(uintptr_t) "") == 0); - char *emptied = read_file(FILE_ROUNDTRIP_PATH); - assert(emptied != NULL && emptied[0] == '\0'); - free(emptied); - assert(remove(FILE_ROUNDTRIP_PATH) == 0); +// A child killed by a SIGNAL never carries an exit status, so WIFEXITED is +// false and the status word holds the signal number rather than a code. The +// runtime must report the documented -1 for it — reading WEXITSTATUS of a +// signalled status yields whatever the low byte happens to hold, which for +// SIGKILL is a plain 9 and is indistinguishable from `exit 9`. +// +// The command signals the tracked process ITSELF: the runtime runs it as +// `/bin/sh -c`, so `$$` is exactly the pid being waited on. Wrapping it in a +// second `sh -c` instead would only work where the outer shell exec's the +// inner one — bash does, dash does not, so on Linux the tracked shell would +// survive its child and exit NORMALLY with 137. +static void test_signalled_child_reports_minus_one(void) { + capture_reset(); + int64_t pid = spawn_process_with_handler("kill -9 $$", capture_handler); + assert(pid > 0); + assert(await_process(pid) == -1); + pthread_mutex_lock(&g_capture.mutex); + assert(g_capture.exit_events == 1); + assert(g_capture.exit_code == -1); // not the signal number, not 0 + pthread_mutex_unlock(&g_capture.mutex); + cleanup_process(pid); +} + +// Deny every new descriptor for the duration of `probe`. `dup` hands back the +// LOWEST free descriptor, so capping RLIMIT_NOFILE at that number makes each +// later allocation fail with EMFILE and nothing already open change. This is +// the only portable way to reach the out-of-descriptors branches: no argument +// can provoke them, and they are the ones that run when a long-lived program +// finally exhausts its table. +static void with_no_free_descriptors(void (*probe)(void)) { + int probe_fd = dup(STDIN_FILENO); + assert(probe_fd >= 0); + assert(close(probe_fd) == 0); + + struct rlimit saved; + assert(getrlimit(RLIMIT_NOFILE, &saved) == 0); + struct rlimit tight = saved; + tight.rlim_cur = (rlim_t)probe_fd; + assert(setrlimit(RLIMIT_NOFILE, &tight) == 0); + + probe(); + + assert(setrlimit(RLIMIT_NOFILE, &saved) == 0); +} + +// popen cannot get a descriptor, so the legacy blocking spawn reports failure +// rather than reading from a pipe it never opened. +static void probe_legacy_spawn_without_descriptors(void) { + assert(spawn_process("echo unreachable") == NULL); +} + +// Every id burned here is burned for the rest of the process, so this runs +// LAST. `next_process_id` only ever increments — cleanup_process frees the +// slot but never returns the id — so a program that has spawned MAX_PROCESSES +// times can never spawn again even with every slot free. The -2 below is that +// ceiling, reached without a single fork because the descriptor cap fails each +// attempt at the pipe, one id later. +static void probe_spawn_exhausts_descriptors_then_ids(void) { + int saw_pipe_failure = 0; + int saw_id_exhaustion = 0; + for (int attempt = 0; attempt < MAX_PROCESSES_PROBE_LIMIT; attempt++) { + int64_t result = spawn_process_with_handler("true", capture_handler); + assert(result == -4 || result == -2); // never a live process id + if (result == -4) { + saw_pipe_failure = 1; + } else { + saw_id_exhaustion = 1; + break; + } + } + assert(saw_pipe_failure); // pipe() denied, reported as -4 + assert(saw_id_exhaustion); // ids ran out, reported as -2 } int main(void) { @@ -370,7 +421,10 @@ int main(void) { test_captured_stderr_and_exact_code(); test_process_argument_rejection(); test_legacy_spawn_process(); - test_file_roundtrip(); + test_signalled_child_reports_minus_one(); + with_no_free_descriptors(probe_legacy_spawn_without_descriptors); + // LAST: exhausts the process-id space for the rest of this process. + with_no_free_descriptors(probe_spawn_exhausts_descriptors_then_ids); printf("=== ALL SYSTEM RUNTIME TESTS PASSED ===\n"); return 0; diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 07114a4a..27a418ec 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -1,6 +1,6 @@ { "_agent_pmo": "b636503", - "_doc": "Per-project coverage thresholds. Each entry is a project; `make test` measures it and fails hard below its threshold. Rust entries (language=rust) are individual crates under crates//, each measured separately from lcov.info; the vscode-extension entry is measured from its V8 coverage summary; C entries (language=c) are individual libraries under compiler/runtime/.c, each measured as gcov line coverage by the Makefile's _coverage_check_c_runtime (the best number across every unit-test suite that links the library). wasm-only units (web_runtime.c, wasm_builtins_runtime.c) do not build natively and are not gated, and neither are term_runtime.c/test_runtime.c: their suites run every case in a FORKED CHILD (a pty-less terminal and the TAP state machine can only be asserted on captured child output), and the child's gcov counters are never flushed back, so gcov reports 0% against 10k passing assertions. Gate them once the harness calls __gcov_dump in the child. FLOOR: every Rust crate is gated at >=95%. C LIBRARIES: a threshold must hold on EVERY platform the gate runs on, so each C entry is the WEAKEST measured platform minus ~2 points, not the local one. macOS and Linux disagree by far more than the '~2 points of gcov rounding' this file used to assume, and they disagree in BOTH directions -- measured on the same commit: system_runtime 78.24 on macOS vs 70.24 on Linux, fiber_runtime 74.83 vs 70.64, websocket_client_runtime 37.96 vs 30.34, but random_runtime 47.50 vs 50.00 and websocket_server_runtime 24.89 vs 27.01. Thresholds set from macOS alone passed locally and failed the required CI job. Re-measure on BOTH before ratcheting; Linux numbers are reproducible in a Debian container. C libraries TARGET >=90%; everything under it is a to-do, worst first: websocket_server 27, websocket_client 30, random 47, http_client 67, system 70, fiber 71, coverage_runtime 78, http_shared 81 (#197). RATCHET: thresholds are monotonically increasing -- when a project's coverage improves on every platform, bump its threshold to max(floor, weakest measured - 1) in the same PR so it can never regress.", + "_doc": "Per-project coverage thresholds. Each entry is a project; `make test` measures it and fails hard below its threshold. Rust entries (language=rust) are individual crates under crates//, each measured separately from lcov.info; the vscode-extension entry is measured from its V8 coverage summary; C entries (language=c) are individual libraries under compiler/runtime/.c, each measured as gcov line coverage by the Makefile's _coverage_check_c_runtime (the best number across every unit-test suite that links the library). wasm-only units (web_runtime.c, wasm_builtins_runtime.c) do not build natively and are not gated, and neither are term_runtime.c/test_runtime.c: their suites run every case in a FORKED CHILD (a pty-less terminal and the TAP state machine can only be asserted on captured child output), and the child's gcov counters are never flushed back, so gcov reports 0% against 10k passing assertions. Gate them once the harness calls __gcov_dump in the child. FLOOR: every Rust crate is gated at >=95%. C LIBRARIES: a threshold must hold on EVERY platform the gate runs on, so each C entry is the WEAKEST measured platform minus ~2 points, not the local one. macOS and Linux disagree by far more than the '~2 points of gcov rounding' this file used to assume, and they disagree in BOTH directions -- measured on the same commit: system_runtime 80.85 on macOS vs 72.79 on Linux, fiber_runtime 74.83 vs 70.64, websocket_client_runtime 37.96 vs 30.34, but random_runtime 47.50 vs 50.00 and websocket_server_runtime 24.89 vs 27.01. Thresholds set from macOS alone passed locally and failed the required CI job. Re-measure on BOTH before ratcheting; Linux numbers are reproducible in a Debian container. COMPLETENESS: every native compiler/runtime/.c must be either gated here or named in the Makefile's C_COV_EXEMPT, and the gate fails if one is neither -- the gate reads this file and looks each key up in the gcov summaries, so a library nobody named is compiled, instrumented, summarised and then discarded. effects_coro.c reached 375 lines of the entire continuation core that way, split out of an already-gated effects_runtime.c, and file_runtime.c arrived ungated the same way. C libraries TARGET >=90%; everything under it is a to-do, worst first: websocket_server 27, websocket_client 30, random 47, http_client 67, fiber 71, system 73, coverage_runtime 78, file_runtime 81, http_shared 81, effects_coro 88 (#197). RATCHET: thresholds are monotonically increasing -- when a project's coverage improves on every platform, bump its threshold to max(floor, weakest measured - 1) in the same PR so it can never regress.", "projects": { "osprey-ast": { "threshold": 97, @@ -62,6 +62,10 @@ "threshold": 90, "language": "c" }, + "effects_coro": { + "threshold": 88, + "language": "c" + }, "json_runtime": { "threshold": 87, "language": "c" @@ -75,7 +79,11 @@ "language": "c" }, "system_runtime": { - "threshold": 68, + "threshold": 71, + "language": "c" + }, + "file_runtime": { + "threshold": 79, "language": "c" }, "string_runtime": { diff --git a/crates/osprey-codegen/src/effect_mailbox.rs b/crates/osprey-codegen/src/effect_mailbox.rs new file mode 100644 index 00000000..8677664a --- /dev/null +++ b/crates/osprey-codegen/src/effect_mailbox.rs @@ -0,0 +1,95 @@ +//! The resumable-operation mailbox ABI, codegen half. +//! +//! A `perform` that suspends hands its operands to the C runtime as three +//! things: a word array, a parallel array of per-slot ownership kinds, and the +//! operation's real arity. The runtime half is `compiler/runtime/effects_coro.c` +//! and the kind numbering is shared through +//! `compiler/runtime/effects_runtime.h` — the two must change together, so they +//! are kept greppable as one unit. Implements [EFFECTS-OPERATION-MAILBOX]. + +use crate::builder::Codegen; +use crate::effects::{box_codegen_value, OpSig}; +use crate::llty::{LType, Value}; +use crate::types::{ltype_of, result_inner}; + +/// Mailbox operand kinds, mirroring `OSP_OP_ARG_*` in +/// `compiler/runtime/effects_runtime.h`. The runtime releases exactly the +/// MANAGED slots when it retires a mailbox, so a slot tagged here but not +/// retained drops a reference nobody took, and one retained but tagged SCALAR +/// leaks. Implements [EFFECTS-OPERATION-MAILBOX]. +const OP_ARG_SCALAR: &str = "0"; +const OP_ARG_MANAGED: &str = "1"; + +/// Whether this slot's word is a managed pointer the mailbox must own. A +/// declared slot is decided by its own LLVM type; an erased (generic) slot +/// travels as a bare `i64` whose real shape only the site's resolved +/// instantiation knows. Implements [EFFECTS-OPERATION-MAILBOX]. +fn slot_is_managed(sig: &OpSig, resolved: Option<&osprey_types::OpType>, i: usize) -> bool { + if sig.param_erased.get(i).copied().unwrap_or(false) { + return resolved.and_then(|r| r.params.get(i)).is_some_and(|t| { + result_inner(t).is_some() || matches!(ltype_of(t), LType::Ptr | LType::Str) + }); + } + let param = sig.param(i); + param.result_inner.is_some() || matches!(param.ty, LType::Ptr | LType::Str) +} + +fn store_slot(cg: &mut Codegen, arr_ty: &str, arr: &str, i: usize, ty: &str, operand: &str) { + let slot = cg.emit_reg(format!( + "getelementptr {arr_ty}, {arr_ty}* {arr}, i64 0, i64 {i}" + )); + cg.emit(format!("store {ty} {operand}, {ty}* {slot}")); +} + +fn first_slot(cg: &mut Codegen, arr_ty: &str, arr: &str) -> String { + cg.emit_reg(format!( + "getelementptr {arr_ty}, {arr_ty}* {arr}, i64 0, i64 0" + )) +} + +/// Retain a word already boxed by the perform site, through its pointer form — +/// the erased path boxes without retaining, so this is where a generic +/// operation's managed operand gains the +1 the mailbox owns. +fn retain_boxed_word(cg: &mut Codegen, word: &str) { + let ptr = cg.emit_reg(format!("inttoptr i64 {word} to i8*")); + crate::arc::escape_retain(cg, &Value::new(ptr, LType::Ptr)); +} + +/// Build the operation's word array and the parallel kind array beside it. +/// Every managed slot leaves here at +1 and the mailbox owns that reference +/// until the dispatcher retires it, so an operand can neither be freed while a +/// handler arm still holds it nor outlive the perform that sent it. +/// Implements [EFFECTS-OPERATION-MAILBOX]. +pub(crate) fn emit_mailbox_arrays( + cg: &mut Codegen, + sig: &OpSig, + resolved: Option<&osprey_types::OpType>, +) -> (String, String) { + let arr_ty = format!("[{} x i64]", sig.params.len()); + let kinds_ty = format!("[{} x i8]", sig.params.len()); + let arr = cg.emit_reg(format!("alloca {arr_ty}")); + let kinds = cg.emit_reg(format!("alloca {kinds_ty}")); + for (i, param) in sig.params.iter().copied().enumerate() { + let managed = slot_is_managed(sig, resolved, i); + let value = crate::cast::incoming_param(cg, format!("%__arg{i}"), param, None); + let word = if sig.param_erased.get(i).copied().unwrap_or(false) { + if managed { + retain_boxed_word(cg, &value.operand); + } + value.operand + } else { + box_codegen_value(cg, value).operand + }; + store_slot(cg, &arr_ty, &arr, i, "i64", &word); + let kind = if managed { + OP_ARG_MANAGED + } else { + OP_ARG_SCALAR + }; + store_slot(cg, &kinds_ty, &kinds, i, "i8", kind); + } + ( + first_slot(cg, &arr_ty, &arr), + first_slot(cg, &kinds_ty, &kinds), + ) +} diff --git a/crates/osprey-codegen/src/effects.rs b/crates/osprey-codegen/src/effects.rs index 63ea2fd6..0434dce7 100644 --- a/crates/osprey-codegen/src/effects.rs +++ b/crates/osprey-codegen/src/effects.rs @@ -43,7 +43,7 @@ impl OpSig { } } - fn param(&self, index: usize) -> ParamSig { + pub(crate) fn param(&self, index: usize) -> ParamSig { self.params .get(index) .copied() @@ -239,11 +239,13 @@ fn emit_unhandled_guard(cg: &mut Codegen, raw: &str, lookup_key: &str, operation fn declare_coro(cg: &mut Codegen) { cg.add_extern("declare i8* @__osprey_coro_new(i8*)"); cg.add_extern("declare void @__osprey_coro_start(i8*, i64 (i8*)*, i8*, i8*)"); - cg.add_extern("declare i64 @__osprey_coro_suspend(i8*, i64, i64*, i64)"); + cg.add_extern("declare i64 @__osprey_coro_suspend(i8*, i64, i64*, i8*, i64)"); cg.add_extern("declare i64 @__osprey_coro_resume(i8*, i64)"); cg.add_extern("declare i64 @__osprey_coro_done(i8*)"); - cg.add_extern("declare i64 @__osprey_coro_op(i8*)"); - cg.add_extern("declare i64 @__osprey_coro_arg(i8*, i64)"); + cg.add_extern("declare i8* @__osprey_coro_take_args(i8*)"); + cg.add_extern("declare i64 @__osprey_coro_mail_op(i8*)"); + cg.add_extern("declare i64 @__osprey_coro_mail_arg(i8*, i64)"); + cg.add_extern("declare void @__osprey_coro_mail_free(i8*)"); cg.add_extern("declare i64 @__osprey_coro_result(i8*)"); cg.add_extern("declare void @__osprey_coro_abort(i8*)"); cg.add_extern("declare void @__osprey_coro_free(i8*)"); @@ -692,6 +694,21 @@ fn coerce_to_answer(cg: &mut Codegen, value: Value, answer: &AnswerShape) -> Res crate::result::repack_to_inner(cg, value, inner) } Some(inner) => crate::result::make_ok(cg, value, inner), + // An arm that does not `resume` abandons the continuation, so ITS value + // becomes the whole `handle` block's result. Whether it CAN be that + // result is settled in inference, where the semantic types still exist + // (`check_abandoning_arm` in `crates/osprey-types/src/expr.rs`). + // + // A codegen-side guard cannot decide it: `any` and `int` are the same + // erased machine word here, so rejecting every scalar that meets a + // pointer answer rejects valid erased values, and accepting every + // pointer that meets a scalar answer boxes a heap address as a + // successful integer. Both directions need the semantic type. + // + // No ownership crosses this cast either, for the same reason it does + // not cross an erasing return: an erased word carries no evidence of + // whether a `+1` came with it. See `coerce_return` in `lower.rs` and + // docs/plans/0027-any-erasure-and-recovery.md. None => coerce_to(cg, value, answer.ty), } } @@ -727,7 +744,7 @@ fn gen_resuming_handler( let resolved = site_ops.and_then(|m| m.ops.get(&arm.operation)); let suspend_fn = format!("__resume_suspend_{effect}_{}_{id}_{op_id}", arm.operation); let arm_fn = format!("__resume_arm_{effect}_{}_{id}_{op_id}", arm.operation); - emit_suspend_fn(cg, &suspend_fn, op_id, &sig); + emit_suspend_fn(cg, &suspend_fn, op_id, &sig, resolved); emit_resuming_arm_fn( cg, arm, @@ -811,38 +828,33 @@ fn emit_resuming_body_fn( Ok(answer) } -fn emit_suspend_fn(cg: &mut Codegen, name: &str, op_id: usize, sig: &OpSig) { +fn emit_suspend_fn( + cg: &mut Codegen, + name: &str, + op_id: usize, + sig: &OpSig, + resolved: Option<&osprey_types::OpType>, +) { let saved = cg.enter_nested_fn(); let mut params = vec![(LType::Ptr, String::from("__coro"))]; for (i, param) in sig.params.iter().copied().enumerate() { params.push((param.ty, format!("__arg{i}"))); } - let args_ptr = if sig.params.is_empty() { - String::from("null") + let (args_ptr, kinds_ptr) = if sig.params.is_empty() { + (String::from("null"), String::from("null")) } else { - let arr_ty = format!("[{} x i64]", sig.params.len()); - let arr = cg.emit_reg(format!("alloca {arr_ty}")); - for (i, param) in sig.params.iter().copied().enumerate() { - let value = crate::cast::incoming_param(cg, format!("%__arg{i}"), param, None); - let boxed = box_codegen_value(cg, value); - let slot = cg.emit_reg(format!( - "getelementptr {arr_ty}, {arr_ty}* {arr}, i64 0, i64 {i}" - )); - cg.emit(format!("store i64 {}, i64* {slot}", boxed.operand)); - } - cg.emit_reg(format!( - "getelementptr {arr_ty}, {arr_ty}* {arr}, i64 0, i64 0" - )) + crate::effect_mailbox::emit_mailbox_arrays(cg, sig, resolved) }; let raw = cg.call( "i64", "__osprey_coro_suspend", - "i8*, i64, i64*, i64", + "i8*, i64, i64*, i8*, i64", &[ "%__coro", &op_id.to_string(), &args_ptr, + &kinds_ptr, &sig.params.len().to_string(), ], ); @@ -919,7 +931,12 @@ fn emit_drive_fn(cg: &mut Codegen, name: &str, arms: &[DriveArm]) -> Result<()> cg.emit(format!("ret i64 {result}")); cg.start_block(&dispatch_lbl); - let op = cg.call("i64", "__osprey_coro_op", "i8*", &["%__coro"]); + // Take the mailbox before reading it. An arm that resumes lets the body + // perform again, and that nested suspension installs a mailbox of its own; + // taking clears the coro's slot so the two activations never alias, and + // makes this activation responsible for retiring the one it holds. + let mail = cg.call("i8*", "__osprey_coro_take_args", "i8*", &["%__coro"]); + let op = cg.call("i64", "__osprey_coro_mail_op", "i8*", &[&mail]); let miss_lbl = cg.fresh_label(); let check_labels: Vec = arms.iter().map(|_| cg.fresh_label()).collect(); let arm_labels: Vec = arms.iter().map(|_| cg.fresh_label()).collect(); @@ -944,15 +961,19 @@ fn emit_drive_fn(cg: &mut Codegen, name: &str, arms: &[DriveArm]) -> Result<()> for (idx, param) in arm.sig.params.iter().copied().enumerate() { let raw = cg.call( "i64", - "__osprey_coro_arg", + "__osprey_coro_mail_arg", "i8*, i64", - &["%__coro", &idx.to_string()], + &[&mail, &idx.to_string()], ); let value = unbox_coro_value(cg, &raw, param.ty, param.result_inner); let value = crate::cast::coerce_param(cg, value, param)?; args.push(value.typed()); } let arm_result = cg.emit_reg(format!("call i64 @{}({})", arm.arm_fn, args.join(", "))); + // The arm borrowed its operands, so retiring the mailbox now drops the + // +1 the performer handed over. Anything the arm kept — stored into + // handler state, returned, or passed to `resume` — it retained itself. + cg.call_void("__osprey_coro_mail_free", "i8*", &[&mail]); let done_after = cg.call("i64", "__osprey_coro_done", "i8*", &["%__coro"]); let done_after_cond = cg.emit_reg(format!("icmp ne i64 {done_after}, 0")); let abort_lbl = cg.fresh_label(); @@ -968,6 +989,9 @@ fn emit_drive_fn(cg: &mut Codegen, name: &str, arms: &[DriveArm]) -> Result<()> } cg.start_block(&miss_lbl); + // No arm claimed this operation: the mailbox is still this activation's to + // retire, or its managed operands outlive the program. + cg.call_void("__osprey_coro_mail_free", "i8*", &[&mail]); cg.call_void("__osprey_coro_abort", "i8*", &["%__coro"]); cg.emit("ret i64 0"); cg.exit_nested_fn(saved, "i64", name, ¶ms); @@ -1030,10 +1054,16 @@ pub(crate) fn gen_resume(cg: &mut Codegen, value: Option<&Expr>) -> Result Value { +pub(crate) fn box_codegen_value(cg: &mut Codegen, value: Value) -> Value { // Every effect-boundary boxing erases pointer-ness from the ARC drop // walk: dup so the unboxing side owns +1 [GC-ARC-PERCEUS]. crate::arc::escape_retain(cg, &value); diff --git a/crates/osprey-codegen/src/expr.rs b/crates/osprey-codegen/src/expr.rs index 07ce1ed4..71891108 100644 --- a/crates/osprey-codegen/src/expr.rs +++ b/crates/osprey-codegen/src/expr.rs @@ -1215,37 +1215,11 @@ fn gen_interpolation(cg: &mut Codegen, parts: &[InterpolatedPart]) -> Result(arguments: &'a [Expr], named: &'a [NamedArgument]) -> Option<&'a Expr> { diff --git a/crates/osprey-codegen/src/extern_call.rs b/crates/osprey-codegen/src/extern_call.rs index 8d619e1e..b3b18f69 100644 --- a/crates/osprey-codegen/src/extern_call.rs +++ b/crates/osprey-codegen/src/extern_call.rs @@ -30,7 +30,8 @@ enum Ret { /// `Result`: the C `i64` is the success value; `< 0` ⇒ Error. ResultInt, /// `Result`: the C `i8*` is the success value; `null` ⇒ Error. - /// `Some(msg)` stores that constant on the error path (`readFile`). + /// `Some(msg)` is the FALLBACK reason, used only when the call recorded + /// none of its own on the failure channel [BUILTIN-FILE-ERRMSG]. ResultStr(Option<&'static str>), } @@ -182,21 +183,47 @@ fn emit(cg: &mut Codegen, sig: &Sig, ops: &[String]) -> Result { Ok(v) } Ret::ResultInt => { + clear_io_error(cg); let r = cg.call("i64", sig.cname, ¶ms, &op_refs); - // The negative-i64 runtime convention carries no message string; - // the Error arm falls back to the bare "Error" reason. - result_from_i64(cg, &r, None) + let reason = take_io_error(cg); + result_from_i64(cg, &r, None, Some(&reason)) } Ret::ResultStr(err) => { + clear_io_error(cg); let r = cg.call("i8*", sig.cname, ¶ms, &op_refs); + let reason = take_io_error(cg); // Own the raw C buffer; the Result payload store dups its own +1, // so this one drops at region end (null on the error path — no-op). crate::arc::own(cg, &Value::new(&r, LType::Str)); - result_from_nullable(cg, &r, err) + result_from_nullable(cg, &r, err, Some(&reason)) } } } +/// Retire any reason the calling thread is holding, so a failure recorded by an +/// EARLIER builtin can never be attributed to this one. Paired with +/// [`take_io_error`] around every call, this is what makes the channel +/// trustworthy rather than merely usually-right. Implements +/// [BUILTIN-FILE-ERRMSG]. +fn clear_io_error(cg: &mut Codegen) { + cg.add_extern("declare void @osp_io_error_clear()"); + cg.emit("call void @osp_io_error_clear()".to_string()); +} + +/// Take ownership of the reason the call just recorded — `null` when it +/// recorded none, which every consumer treats as "no reason given" and falls +/// back on. Owning is not optional: the channel's own buffer is reused by this +/// thread's next I/O call, but the `Result` built from it can outlive any number +/// of those, so a borrowed pointer would later read as an unrelated failure. +fn take_io_error(cg: &mut Codegen) -> String { + cg.add_extern("declare i8* @osp_io_error_take()"); + let reason = cg.emit_reg("call i8* @osp_io_error_take()".to_string()); + // The errmsg store dups its own +1, so this one drops at region end + // (null on the success path — a no-op). + crate::arc::own(cg, &Value::new(&reason, LType::Str)); + reason +} + #[cfg(test)] mod tests { #[test] @@ -226,6 +253,44 @@ mod tests { } } + #[test] + fn a_fallible_builtin_carries_the_runtime_failure_reason() { + // [BUILTIN-FILE-ERRMSG] The reason must be CLEARED before the call and + // TAKEN after it. Without the clear, a reason left by an earlier failed + // op is reported as this call's cause; without the take, the Error holds + // a borrowed pointer into a thread-local the next I/O call overwrites. + let parsed = osprey_syntax::parse_program( + "let written = writeFile(\"out.txt\", \"body\")\n\ + let loaded = readFile(\"out.txt\")\n", + ); + assert!( + parsed.errors.is_empty(), + "syntax errors: {:?}", + parsed.errors + ); + let ir = crate::compile_program(&parsed.program).expect("file builtin codegen"); + assert!(ir.contains("declare void @osp_io_error_clear()"), "{ir}"); + assert!(ir.contains("declare i8* @osp_io_error_take()"), "{ir}"); + for (callee, ret) in [("write_file", "i64"), ("read_file", "i8*")] { + let call = format!("call {ret} @{callee}("); + let at = ir.find(&call).unwrap_or_else(|| panic!("missing {callee}")); + let (before, after) = ir.split_at(at); + assert!( + before.rfind("call void @osp_io_error_clear()") + > before.rfind("call i8* @osp_io_error_take()"), + "{callee}: the clear must be the last channel op before the call" + ); + assert!( + after.contains("call i8* @osp_io_error_take()"), + "{callee}: no take after the call" + ); + } + // The reason outranks the static fallback, so a producer that recorded + // one is never reported as the placeholder. + assert!(ir.contains("File read error"), "fallback dropped: {ir}"); + assert!(ir.contains("icmp ne i8* "), "no reason-present test: {ir}"); + } + #[test] fn websocket_builtins_lower_to_the_c_runtime_abi() { // [BUILTIN-WEBSOCKET] Camel-case language names must not escape into diff --git a/crates/osprey-codegen/src/lib.rs b/crates/osprey-codegen/src/lib.rs index 2abc23ec..dc3d1802 100644 --- a/crates/osprey-codegen/src/lib.rs +++ b/crates/osprey-codegen/src/lib.rs @@ -23,6 +23,7 @@ mod collections; mod conv; mod coverage; mod effect_generics; +mod effect_mailbox; mod effects; mod error; mod expr; @@ -488,6 +489,47 @@ mod tests { assert!(ir.contains("@osp_alloc")); } + /// Recovering a pointer from an erased `any` word must take NO ownership. + /// The word is `LType::I64`, which is equally every `int` and every + /// BORROWED `any` parameter, so a rule that owns what comes back invents a + /// reference that was never transferred: with one, `fn identity(x: any) -> + /// any = x` made the epilogue move a fictitious owner out, release the real + /// one, and return a dangling pointer — `v=` instead of `v=ab` under + /// `--memory=arc`, silently. Owning an erased SCALAR is worse still: it + /// enters `7` in the ledger and later frees it. + /// + /// This guards that repair from being re-applied at the cast. The cast is + /// the wrong place: it cannot see which of the three an `i64` is. The gap + /// it leaves — an erasing return really does drop its referent — is a real + /// open defect, tracked in docs/plans/0027-any-erasure-and-recovery.md + /// (#208). [GC-ARC-PERCEUS] + #[test] + fn recovering_a_pointer_from_an_erased_word_takes_no_ownership() { + let ir = module( + "fn dynamic() -> any = \"a\" + \"b\"\nfn text() -> string = dynamic()\nprint(\"x\")\n", + ); + let body = function_body(&ir, "define i8* @text()"); + let recovered = body + .lines() + .find_map(|l| { + l.split(" = inttoptr") + .next() + .filter(|_| l.contains("inttoptr")) + }) + .map(str::trim) + .unwrap_or_default() + .to_string(); + assert!( + !recovered.is_empty(), + "expected the `any` word to be recovered as a pointer:\n{body}" + ); + assert!( + !body.contains(&format!("store i8* {recovered}, i8** %arc.")), + "the un-erased pointer `{recovered}` was entered in the ARC ledger, \ + but an erased word carries no reference to take over:\n{body}" + ); + } + #[test] fn match_lowers_to_phi() { let ir = module("fn pick(a: int, b: int) -> int = match a < b { true => a false => b }\n"); @@ -1210,6 +1252,50 @@ mod tests { } } + #[test] + fn a_resumable_operation_sends_every_argument_with_its_ownership_kind() { + // The operation mailbox is length-carrying and kind-tagged + // ([EFFECTS-OPERATION-MAILBOX]). Nothing else in Rust pins this ABI — + // a drift used to surface only as a C link error or, worse, as + // `osp_release` called on an integer. Seventeen slots is the arity a + // fixed sixteen-word mailbox silently zeroed (#182); the `string` slot + // is the one whose reference the mailbox owns (#185). + let ir = module( + "effect Wide { op: fn(int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, string) -> int }\n\ + fn body() -> int !Wide = perform Wide.op(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, \"x\")\n\ + fn main() -> int { let r = handle Wide op a b c d e f g h i j k l m n o p q => resume(p) in body()\n print(\"r=${toString(r)}\")\n 0 }\n", + ); + assert!( + ir.contains("declare i64 @__osprey_coro_suspend(i8*, i64, i64*, i8*, i64)"), + "suspend must take a kinds array beside the words:\n{ir}" + ); + // Both arrays are sized by the REAL arity — never a fixed capacity. + assert!( + ir.contains("alloca [17 x i64]") && ir.contains("alloca [17 x i8]"), + "the mailbox must be sized by the operation's declared arity:\n{ir}" + ); + // Scalar slots tag 0; the trailing `string` tags 1, and only that one is + // a reference the mailbox releases when it retires. + assert!( + ir.contains("store i8 1, i8*") && ir.contains("store i8 0, i8*"), + "each slot must carry its operand kind:\n{ir}" + ); + // The dispatcher takes the mailbox, reads through it, and retires it. + for symbol in [ + "@__osprey_coro_take_args", + "@__osprey_coro_mail_op", + "@__osprey_coro_mail_arg", + "@__osprey_coro_mail_free", + ] { + assert!(ir.contains(symbol), "missing {symbol} in:\n{ir}"); + } + // The superseded fixed-width accessors must not come back. + assert!( + !ir.contains("@__osprey_coro_arg(") && !ir.contains("@__osprey_coro_op("), + "the fixed-width mailbox accessors are gone:\n{ir}" + ); + } + #[test] fn handler_owned_mutable_state_threads_through_a_heap_cell() { // A `mut` an effect handler arm captures is promoted to a shared heap diff --git a/crates/osprey-codegen/src/lower.rs b/crates/osprey-codegen/src/lower.rs index 2181de8c..280891ec 100644 --- a/crates/osprey-codegen/src/lower.rs +++ b/crates/osprey-codegen/src/lower.rs @@ -296,6 +296,12 @@ fn coerce_return(cg: &mut Codegen, name: &str, body: Value) -> Result { return crate::result::fit_to_inner(cg, body, inner); } let ret_ty = cg.fn_ret_ltype(name).unwrap_or(LType::I64); + // No ownership crosses this cast in either direction, and that is a + // DELIBERATE gap: `LType::I64` is equally every `int`, every erased `any` + // and every BORROWED `any` parameter, so nothing here can tell a + // transferred pointer from a scalar. The defect that leaves open, and the + // repair that must NOT be retried, are recorded in + // docs/plans/0027-any-erasure-and-recovery.md (#208). crate::cast::coerce_to(cg, body, ret_ty) } diff --git a/crates/osprey-codegen/src/result.rs b/crates/osprey-codegen/src/result.rs index 725f58dc..874c4581 100644 --- a/crates/osprey-codegen/src/result.rs +++ b/crates/osprey-codegen/src/result.rs @@ -83,16 +83,41 @@ pub(crate) fn make_result_if_err( inner: LType, is_err: &str, msg: Option<&str>, +) -> Result { + make_result_if_err_because(cg, value, inner, is_err, msg, None) +} + +/// [`make_result_if_err`] with a RUNTIME reason: `reason` is an `i8*` operand +/// (null when the producer recorded none) that outranks the static `msg`. This +/// is how a failed builtin's real cause — "writeFile: out/x.db: No such file or +/// directory" — reaches `Error { message }` instead of the placeholder the +/// static fallback carries. Implements [BUILTIN-FILE-ERRMSG]. +pub(crate) fn make_result_if_err_because( + cg: &mut Codegen, + value: Value, + inner: LType, + is_err: &str, + msg: Option<&str>, + reason: Option<&str>, ) -> Result { let disc = cg.fresh_reg(); cg.emit(format!("{disc} = select i1 {is_err}, i8 1, i8 0")); - let errmsg = match msg { - Some(m) => { - let c = cg.string_constant(m); - cg.emit_reg(format!("select i1 {is_err}, i8* {}, i8* null", c.operand)) - } + let fallback = match msg { + Some(m) => cg.string_constant(m).operand, None => NO_MSG.to_string(), }; + let chosen = match reason { + Some(r) => { + let given = cg.emit_reg(format!("icmp ne i8* {r}, null")); + cg.emit_reg(format!("select i1 {given}, i8* {r}, i8* {fallback}")) + } + None => fallback, + }; + let errmsg = if chosen == NO_MSG { + NO_MSG.to_string() + } else { + cg.emit_reg(format!("select i1 {is_err}, i8* {chosen}, i8* null")) + }; make_result(cg, value, inner, &disc, &errmsg) } @@ -118,25 +143,46 @@ pub(crate) fn result_from_flag( /// `Result` from a C `i64` whose negative values signal failure — the /// uniform convention of the file/process/HTTP/JSON runtime (a negative handle, /// byte count, status or process id is Error). The success value carried is the -/// result itself; `msg` is the Error message text (`None` for a bare Error). -pub(crate) fn result_from_i64(cg: &mut Codegen, result: &str, msg: Option<&str>) -> Result { +/// result itself; `msg` is a static fallback message and `reason` the runtime +/// one the call recorded, which wins when present. +pub(crate) fn result_from_i64( + cg: &mut Codegen, + result: &str, + msg: Option<&str>, + reason: Option<&str>, +) -> Result { let err = cg.emit_reg(format!("icmp slt i64 {result}, 0")); - make_result_if_err(cg, Value::new(result, LType::I64), LType::I64, &err, msg) + make_result_if_err_because( + cg, + Value::new(result, LType::I64), + LType::I64, + &err, + msg, + reason, + ) } /// `Result` from a possibly-NULL C `char*` (`ptr` an `i8*` operand): -/// NULL ⇒ Error, else Success. The success slot keeps the pointer itself; when -/// `err` is `Some(msg)`, the errmsg slot carries that constant on the error path -/// so the `Error { message }` arm and `toString` (`Error(msg)`, e.g. -/// `readFile`'s `Error(File read error)`) both see a real reason. With `None` a -/// failure shows the bare `Error`. +/// NULL ⇒ Error, else Success. The success slot keeps the pointer itself. The +/// errmsg slot takes `reason` — what the runtime recorded about THIS call — +/// falling back to the static `err` text only when the producer recorded +/// nothing, so `Error { message }` and `toString` never show a placeholder for +/// a failure whose real cause is known. pub(crate) fn result_from_nullable( cg: &mut Codegen, ptr: &str, err: Option<&str>, + reason: Option<&str>, ) -> Result { let is_null = cg.emit_reg(format!("icmp eq i8* {ptr}, null")); - make_result_if_err(cg, Value::new(ptr, LType::Str), LType::Str, &is_null, err) + make_result_if_err_because( + cg, + Value::new(ptr, LType::Str), + LType::Str, + &is_null, + err, + reason, + ) } /// Branch on a Result's discriminant: load it, test `== 0` (Success), and emit diff --git a/crates/osprey-codegen/src/runtime.rs b/crates/osprey-codegen/src/runtime.rs index 23d51643..a58306e9 100644 --- a/crates/osprey-codegen/src/runtime.rs +++ b/crates/osprey-codegen/src/runtime.rs @@ -84,19 +84,50 @@ fn result_string(cg: &mut Codegen, v: &Value, wrap_success: bool) -> Result String { - cg.add_extern("declare i32 @sprintf(i8*, i8*, ...)"); + format_sized(cg, fmt, &[format!("i8* {arg}")]).operand +} + +/// Build `fmt` — a codegen-built template whose holes are all `%s` — into an +/// exactly-sized heap buffer, in two passes: measure with `osp_format_size`, +/// then fill with `osp_format_into`. `args` are complete LLVM operands +/// (`i8* %r7`). The returned buffer is owned by the current region. +/// +/// Measuring goes through `osp_format_size` rather than `snprintf` directly +/// because this IR is target-neutral and `size_t` is not: it is 32-bit on +/// wasm32 and 64-bit natively, so a literal size type here mismatches +/// wasi-libc at wasm-ld time. See `string_runtime.h`. [STRING-INTERPOLATION] +pub(crate) fn format_sized(cg: &mut Codegen, fmt: &str, args: &[String]) -> Value { + cg.add_extern("declare i64 @osp_format_size(i8*, ...)"); + cg.add_extern("declare void @osp_format_into(i8*, i64, i8*, ...)"); let fmtv = cg.string_constant(fmt); - let buf = cg.heap_alloc("64"); - let tmp = cg.fresh_reg(); + let extra = args.iter().fold(String::new(), |mut acc, a| { + acc.push_str(", "); + acc.push_str(a); + acc + }); + let len = cg.emit_reg(format!( + "call i64 (i8*, ...) @osp_format_size(i8* {}{extra})", + fmtv.operand + )); + let size = cg.emit_reg(format!("add i64 {len}, 1")); + let buf = cg.heap_alloc(&size); cg.emit(format!( - "{tmp} = call i32 (i8*, i8*, ...) @sprintf(i8* {buf}, i8* {}, i8* {arg})", + "call void (i8*, i64, i8*, ...) @osp_format_into(i8* {buf}, i64 {size}, i8* {}{extra})", fmtv.operand )); - crate::arc::own(cg, &Value::new(&buf, LType::Str)); - buf + let v = Value::new(buf, LType::Str); + crate::arc::own(cg, &v); + v } /// `print(x)` → `puts(toString(x))`; yields Unit. [BUILTIN-PRINT] @@ -108,6 +139,12 @@ pub(crate) fn gen_print(cg: &mut Codegen, v: Value) -> Result { Ok(Value::unit()) } +/// The widest `%lld` an i64 can produce: `-9223372036854775808` is 20 +/// characters, so 21 bytes hold every value with its terminator. Unlike the +/// `%s` templates above, this bound is a property of the type, not of a runtime +/// value, so a fixed block is sound here and saves the measuring pass. +const INT_STRING_BYTES: &str = "21"; + fn int_to_string(cg: &mut Codegen, v: Value) -> Result { cg.add_extern("declare i32 @sprintf(i8*, i8*, ...)"); let i = as_i64(cg, v)?; @@ -115,7 +152,7 @@ fn int_to_string(cg: &mut Codegen, v: Value) -> Result { // `long` is 32-bit while `long long` is 64-bit everywhere. `%lld` reads the // full i64 on every target; on LP64 (native) it is identical to `%ld`. let fmt = cg.string_constant("%lld"); - let buf = cg.heap_alloc("32"); + let buf = cg.heap_alloc(INT_STRING_BYTES); let tmp = cg.fresh_reg(); cg.emit(format!( "{tmp} = call i32 (i8*, i8*, ...) @sprintf(i8* {buf}, i8* {}, i64 {})", diff --git a/crates/osprey-codegen/src/strings.rs b/crates/osprey-codegen/src/strings.rs index b412eaff..737cd6b3 100644 --- a/crates/osprey-codegen/src/strings.rs +++ b/crates/osprey-codegen/src/strings.rs @@ -283,7 +283,7 @@ fn substring(cg: &mut Codegen, args: &[Expr], _named: &[NamedArgument]) -> Resul ); // The raw +1 return is owned here; the Result block dups its own copy. crate::arc::own(cg, &Value::new(&ptr, LType::Str)); - result_from_nullable(cg, &ptr, Some("substring: index out of range")) + result_from_nullable(cg, &ptr, Some("substring: index out of range"), None) } /// A fallible string transform returning a runtime `char*` that is NULL on @@ -301,7 +301,7 @@ fn nullable_str( let op_refs: Vec<&str> = ops.iter().map(String::as_str).collect(); let ptr = cg.call("i8*", cname, ¶ms, &op_refs); crate::arc::own(cg, &Value::new(&ptr, LType::Str)); - result_from_nullable(cg, &ptr, Some(errmsg)) + result_from_nullable(cg, &ptr, Some(errmsg), None) } /// `parseInt`/`parseFloat`: strict parse writing through an out-slot, returning @@ -409,7 +409,7 @@ fn from_codepoint(cg: &mut Codegen, args: &[Expr]) -> Result { let cp = arg(cg, args, 0, LType::I64)?; let ptr = cg.call("i8*", "osp_string_from_codepoint", "i64", &[&cp.operand]); crate::arc::own(cg, &Value::new(&ptr, LType::Str)); - result_from_nullable(cg, &ptr, Some("fromCodePoint: invalid code point")) + result_from_nullable(cg, &ptr, Some("fromCodePoint: invalid code point"), None) } /// `join(list: List, separator: string) -> string`. diff --git a/crates/osprey-types/src/expr.rs b/crates/osprey-types/src/expr.rs index a9958d94..b5d562e0 100644 --- a/crates/osprey-types/src/expr.rs +++ b/crates/osprey-types/src/expr.rs @@ -398,6 +398,10 @@ impl Checker { (Vec::new(), HashMap::new(), false) }; let answer = self.ctx.fresh(); + // Codegen picks the resuming lowering by exactly this predicate + // (`gen_handler` in osprey-codegen), and the two rules must agree. + let region_resumes = arms.iter().any(|a| osprey_ast::contains_resume(&a.body)); + let mut aborting: Vec<(String, Type)> = Vec::new(); for arm in arms { let (params, op_ret) = match inst_ops.get(&arm.operation) { Some(op) if op.params.len() == arm.params.len() => { @@ -441,8 +445,22 @@ impl Checker { // value is the handler's ANSWER. A `Unit` operation discards the // arm's value, so anything goes there. Implements // [EFFECTS-RESUME] and [EFFECTS-GENERIC-INSTANTIATION]. + // + // Value substitution is the rule for a region where NO arm resumes: + // the handler is inlined at each `perform`, so the arm's value is + // what the `perform` evaluates to. In a region where some other arm + // does resume, a non-resuming arm instead ABANDONS the continuation + // — the body is killed and this arm's value becomes the whole + // `handle` expression's answer, while the operation's result is + // never produced at all. Constraining it to `op_ret` there checks + // the wrong thing and leaves the answer unchecked, which is how a + // `string` arm came to answer an `int` handle: codegen boxed the + // pointer as an integer and the program printed a heap address as a + // successful result. Implements [EFFECTS-HANDLER-ARMS]. if osprey_ast::contains_resume(&arm.body) { self.push_assign(&answer, &arm_ty); + } else if region_resumes { + aborting.push((arm.operation.clone(), arm_ty)); } else if !self.ctx.prune(&op_ret).is_named(crate::ty::names::UNIT) { self.push_assign(&op_ret, &arm_ty); } else if self.ctx.prune(&arm_ty).is_named(crate::ty::names::RESULT) { @@ -462,9 +480,32 @@ impl Checker { self.handler_tys.push((pos, eff_args, inst_ops)); } self.push_assign(&answer, &body_ty); + // Checked only now: the handled expression is what pins the answer, so + // blaming the arm before it is known would report the mismatch backwards. + for (op, arm_ty) in aborting { + self.check_abandoning_arm(effect, &op, &answer, &arm_ty); + } answer } + /// An arm that abandons the continuation answers for the whole `handle`, so + /// its value must be able to BE that answer. `any` unifies with everything + /// [TYPE-ANY], so an erased word is accepted here — codegen cannot tell one + /// from a genuine `int`, which is why this check belongs in inference. + /// Implements [EFFECTS-HANDLER-ARMS]. + fn check_abandoning_arm(&mut self, effect: &str, op: &str, answer: &Type, arm_ty: &Type) { + if crate::unify::unify_assignable(&mut self.ctx, answer, arm_ty).is_ok() { + return; + } + let (want, got) = (self.ctx.prune(answer), self.ctx.prune(arm_ty)); + self.errors.push(TypeError::new(format!( + "handler arm `{effect}.{op}` never resumes, so its value becomes the whole \ + `handle` expression's result — but it is `{got}` and that result is `{want}`. \ + Give the arm a `resume`, or make every arm of this handler agree with the \ + handled expression's type" + ))); + } + fn lookup_ident(&mut self, name: &str, env: &TypeEnv) -> Type { // A bare nullary constructor (`Red`, `Empty`) is a value of its owner type. if self.ctors.get(name).is_some_and(|i| i.fields.is_empty()) { @@ -1441,6 +1482,77 @@ mod tests { }\n"); } + /// A mixed region: `b` never resumes, so it abandons the continuation and + /// its value is the whole `handle` result — checked against the ANSWER, not + /// against `b`'s declared result, which no `perform` ever receives. + /// [EFFECTS-HANDLER-ARMS] + fn mixed_region(op_ret: &str, body_ret: &str, body_tail: &str, arm: &str) -> String { + format!( + "effect Mixed {{ a: fn(int) -> int\n b: fn() -> {op_ret} }}\n\ + fn body() -> {body_ret} !Mixed = {{\n let ignored = perform Mixed.b()\n {body_tail}\n }}\n\ + let out = handle Mixed\n a x => resume(x)\n b => {arm}\n in body()\n" + ) + } + + #[test] + fn an_abandoning_arm_must_be_able_to_be_the_answer() { + // Both directions, and through a Result answer. Checking only one + // direction is not a partial gate: the unchecked one reached codegen's + // `coerce_to`, which boxed a heap address as a successful `int`. + for (op_ret, body_ret, body_tail, arm) in [ + ("int", "string", "\"done\"", "7"), + ("string", "int", "42", "\"dynamic\""), + ( + "string", + "Result", + "Success { value: 42 }", + "\"dynamic\"", + ), + ] { + let errs = bad(&mixed_region(op_ret, body_ret, body_tail, arm)); + assert!( + errs.iter().any(|e| e.message.contains("never resumes")), + "arm `{arm}` must not be allowed to answer `{body_ret}`; got {errs:?}" + ); + } + } + + #[test] + fn an_abandoning_arm_may_disagree_with_its_own_operation_result() { + // The arm's value is not the operation's result — that result is never + // produced. `b` declares `int` and answers `string`, and the handled + // body answers `string`, so the region is well typed. + ok(&mixed_region( + "int", + "string", + "\"unreached\"", + "\"stopped\"", + )); + // `any` answers anything [TYPE-ANY]. Codegen sees the same erased + // machine word as an `int`, which is why this is settled here. + ok( + &mixed_region("int", "string", "\"unreached\"", "erased()").replace( + "effect Mixed {", + "fn erased() -> any = \"x\"\neffect Mixed {", + ), + ); + } + + #[test] + fn a_fully_non_resuming_region_still_substitutes_values() { + // No arm resumes, so the handler is inlined at each `perform` and the + // arm's value IS the operation's result — the rule the abandoning case + // must not weaken, because it is what pins a generic effect's + // instantiation. [EFFECTS-GENERIC-INSTANTIATION] + let errs = bad("effect Mixed { b: fn() -> int }\n\ + fn body() -> string !Mixed = \"v=${perform Mixed.b()}\"\n\ + let out = handle Mixed\n b => \"not an int\"\n in body()\n"); + assert!( + !errs.is_empty(), + "a `string` arm cannot supply an `int` result" + ); + } + #[test] fn performed_effect_operation_must_be_declared() { // [EFFECTS-OP-TYPING] diff --git a/docs/effects-mailbox-branch-review.md b/docs/effects-mailbox-branch-review.md new file mode 100644 index 00000000..218f5fb1 --- /dev/null +++ b/docs/effects-mailbox-branch-review.md @@ -0,0 +1,197 @@ +# Branch regression review + +Baseline: `origin/main` at `a57673e2`. Reviewed through branch commit +`e5031dc9` plus the current working-tree changes. + +All three findings are resolved; each carries its resolution below. Both P1 +reproductions were confirmed against this branch before being fixed — the +review was right, and the `any` ownership convention it names was mine. + +## Review findings + +### P1 — The new `any` ownership convention causes ARC use-after-free and leaks + +`coerce_return` now transfers a managed value before erasing it to `i64`, and +registers every `i64 -> pointer` recovery as owned +(`crates/osprey-codegen/src/lower.rs:298-317`). That only balances when the word +was produced by the exact `pointer -> any` path. An `any` parameter is borrowed, +so forwarding it does not create the `+1` that the recovery side assumes. + +```osprey +fn identity(x: any) -> any = x +fn make() -> string = identity("a" + "b") +print("v=${make()}") +``` + +Under `--memory=arc`, `origin/main` prints `v=ab`; this branch exits zero and +prints `v=`. The recovered pointer is entered in the ARC ledger without a +retain. The epilogue then moves that fictitious owner out, releases the real +owner of the argument, and returns a dangling pointer. + +The converse also regresses: a heap value that remains erased has no typed +consumer at which to surrender its transferred reference. This is directly +reachable through the new mailbox: + +```osprey +effect Boxed { take: fn(any) -> Unit } + +fn erased() -> any = "a" + "b" +fn body() -> int !Boxed = { + perform Boxed.take(erased()) + 42 +} + +let out = handle Boxed + take x => resume() +in body() +print(toString(out)) +``` + +`origin/main` finishes with zero live ARC objects. This branch finishes with one +live three-byte object (`ab`). `slot_is_managed` classifies an explicit `any` +parameter from its `i64` ABI as scalar +(`crates/osprey-codegen/src/effect_mailbox.rs:23-35,72-89`), so the mailbox can +neither retain nor release the transferred pointer. The same leak occurs when a +heap-bearing `any` is observed only in erased form. + +The added tests cover a direct producer followed by a direct typed recovery +(`crates/osprey-codegen/src/lib.rs:492-543` and +`tests/regressions/basics/types/any_type_comprehensive.test.osp:8-17`). They do +not cover forwarding a borrowed `any`, discarding/observing it while still +erased, or carrying it through an effect operand. + +**Resolved — the convention is reverted.** Both reproductions were confirmed +first: `v=` with 0 live objects, and `42` with 1 live three-byte object. The +diagnosis is exactly right, and it generalises past the two cases named here — +`fn intish() -> any = 7` recovered as a `string` would have entered `7` in the +ledger and later freed it, so the rule was not merely unbalanced but memory- +unsafe. + +No rule at the cast can be sound, because the lowered type of an erased word is +the same `LType::I64` as every `int` and every borrowed `any` parameter, and +that is the one distinction the rule needs. Closing it properly means making +`any` distinguishable from `int` after lowering — the ABI change this review's +own recommendation calls for — not another cast-site patch. Until then the +erasure carries no ownership in either direction, which restores `origin/main`'s +behaviour on both programs above and leaves `origin/main`'s own defect standing: +returning a heap value AS `any` still drops its referent. That defect, the +repair documented as attempted and wrong so it is not tried a third time, and +the reproduction for each are in +[plan 0027](plans/0027-any-erasure-and-recovery.md), filed as +[#208](https://github.com/Nimblesite/osprey/issues/208). Reproducing it against +a clean `origin/main` worktree also turned up a second, backend-independent half +the review did not reach: an erased value recovered through a `let` annotation +drops the annotation and prints the pointer as a decimal integer, on default, gc +and arc alike, in both flavors — filed as +[#209](https://github.com/Nimblesite/osprey/issues/209). + +What replaces the reverted tests is coverage of the case that regressed, which +had none: `forward`/`forwarded` in +`tests/regressions/basics/types/any_type_comprehensive.test.osp` (both flavors) +assert borrowed-`any` forwarding under every backend with the live-object +oracle armed, and +`recovering_a_pointer_from_an_erased_word_takes_no_ownership` in +`crates/osprey-codegen/src/lib.rs` fails if an owner is ever entered in the +ledger at that cast again. + +### P2 — The new C coverage inventory check skips a shipped runtime unit + +The completeness loop skips every source whose name starts with `test_` before +checking the threshold and exemption inventories (`Makefile:631-635`). That +also skips `compiler/runtime/test_runtime.c`, which is a real member of every +native runtime archive, not just a test harness. + +The loophole is reproducible: removing `test_runtime` from `C_COV_EXEMPT` and +running the gate still reports success: + +```sh +make C_COV_EXEMPT='web_runtime wasm_builtins_runtime term_runtime' \ + _coverage_check_c_runtime +``` + +This contradicts the new guarantee at `Makefile:605-616` that every native +runtime unit must be gated or explicitly exempted. A future attempt to gate +`test_runtime.c`, or any new production unit named `test_*`, can therefore +silently leave it outside the ratchet. + +**Resolved.** The check no longer decides what ships by pattern-matching a +filename. `C_SHIPPED_UNITS` is derived from the archive object lists themselves +(`FIB_OBJ`, `HTTP_OBJ` and their GC/ARC variants), so membership — the fact the +check actually wanted — is what it tests. The negative control the review gives +now fails where it previously passed: + +```text +[c] FAIL: runtime/test_runtime.c ships in a native archive but is neither gated + in coverage-thresholds.json nor in C_COV_EXEMPT +``` + +`C_COV_EXEMPT` shrank to `term_runtime test_runtime` with that. `web_runtime` +and `wasm_builtins_runtime` were only ever listed because the old loop walked +`runtime/*.c`; they are absent from every native archive, so the new check does +not ask about them. + +### P3 — Effect documentation still reports the fixed mailbox defects as open + +The implementation and plan now mark #182 and #185 fixed, but the user-facing +effect documentation still says both are active skips: + +- `tests/effects/README.md:111-131` calls #182/#185 current critical defects. +- `tests/effects/resume/README.md:22-35` says position 17 is skipped and warns + against dynamic string answers until #185 is fixed. +- `docs/plans/README.md:22` still lists #182/#185 as showstoppers. +- Several plan references still locate `__osprey_coro_*` in + `effects_runtime.c`, although this branch moved it to `effects_coro.c`. + +These statements now contradict the passing tests and the updated spec, so a +reader cannot tell which limitations remain real. + +**Resolved.** #182 and #185 are struck through and marked fixed in +`tests/effects/README.md`, matching the shape already used for #183, and the +claims that depended on them are gone: `tests/effects/resume/README.md` now +describes a 17-argument operation with every position checked and a string +continuation answer asserted to release under ARC, rather than a skip and a +warning. `docs/plans/README.md` row 0016 leaves #184 as the only remaining +showstopper. The `__osprey_coro_*` references in plans 0016 and 0026 and in the +plans index now point at `effects_coro.c`; `docs/multitarget-js-dotnet.md` lists +it alongside `effects_runtime.c`. Both claims were verified against the suites +before being written down — the 17th position and the managed answer are +assertions in `resume_error_policies.test.{osp,ospml}`, and they pass under all +three backends with the leak oracle armed. + +## Verification performed + +- `cargo test -p osprey-codegen` — 117 passed. +- `cargo test -p osprey-types` — 242 passed. +- `make _test_c_runtime` — all C runtime suites passed. +- `make _coverage_check_c_runtime` — configured thresholds passed; the P2 + negative-control invocation above also passed when it should have failed. +- `make _runtime_wasm` — passed. +- Paired Default/ML effect, file, error, and `any` regression suites passed + under ARC. +- Both handler-answer mismatch fixtures produced all three expected + diagnostics. +- The P1 programs were run on both this branch and a clean `origin/main` + worktree to establish the behavioral regression. +- After the fixes, the same programs were re-run against both binaries: branch + and `origin/main` now agree — `v=ab` and `42`, zero live objects under ARC on + each — so nothing in P1 remains as a branch regression. The two `any` defects + that survive reproduce identically on `origin/main` and are filed as #208 and + #209 rather than fixed here. + +--- + +## Soft recommendations + +- It may be worth revisiting `any` as an ownership-carrying ABI rather than + patching more individual casts. A tag/provenance bit, or an explicit + borrow/forward/consume model for erased words, would let ARC distinguish a + transferred pointer from a borrowed or scalar `i64`. +- Small ARC goldens for borrowed-`any` forwarding, opaque/discarded heap + `any`, and `any` effect operands would likely prevent both halves of P1 from + recurring. Running them with the live-object oracle is important because one + failure is wrong output and the other is only visible as a leak. +- The C inventory check could narrowly exclude known harness source names, or + derive production units from the archive object lists, before consulting the + explicit exemption list. +- Updating the effect READMEs and plan index alongside the mailbox fix would + keep the documented support boundary aligned with the tests. diff --git a/docs/multitarget-js-dotnet.md b/docs/multitarget-js-dotnet.md index 0f27bffc..c604551a 100644 --- a/docs/multitarget-js-dotnet.md +++ b/docs/multitarget-js-dotnet.md @@ -438,6 +438,7 @@ Local Osprey context: - [`crates/osprey-codegen/src/effects.rs`](../../crates/osprey-codegen/src/effects.rs) - [`crates/osprey-codegen/src/fiber.rs`](../../crates/osprey-codegen/src/fiber.rs) - [`compiler/runtime/effects_runtime.c`](../../compiler/runtime/effects_runtime.c) +- [`compiler/runtime/effects_coro.c`](../../compiler/runtime/effects_coro.c) - [`compiler/runtime/fiber_runtime.c`](../../compiler/runtime/fiber_runtime.c) External references: diff --git a/docs/plans/0016-algebraic-effects-and-handlers.md b/docs/plans/0016-algebraic-effects-and-handlers.md index 4fa7a2fa..84c987a6 100644 --- a/docs/plans/0016-algebraic-effects-and-handlers.md +++ b/docs/plans/0016-algebraic-effects-and-handlers.md @@ -16,22 +16,25 @@ first-class open-row representation in Hindley–Milner function types. This pla supersedes retired plan 0008 and absorbs the handler-value work sketched in [plan 0013](0013-ml-flavor-frontend.md) Phase 0 and the effect-row-polymorphism gap flagged in -[plan 0015](0015-generics-and-variance.md). Open critical correctness defects: -resumable argument transport after position 16 -([#182](https://github.com/Nimblesite/osprey/issues/182)) -and effect loss through one curried ML lowering path -([#184](https://github.com/Nimblesite/osprey/issues/184)), plus managed string -continuation answers leaking under ARC -([#185](https://github.com/Nimblesite/osprey/issues/185)). +[plan 0015](0015-generics-and-variance.md). Remaining open critical correctness +defect: effect loss through one curried ML lowering path +([#184](https://github.com/Nimblesite/osprey/issues/184)). **Ten open effect defects share three root causes**, sequenced together in umbrella [#200](https://github.com/Nimblesite/osprey/issues/200) (which parents #182, #183, #185; #177, #179; #184, #178, #156; #180; #186): the operation -mailbox is fixed-width and untyped (`effects_runtime.c` `int64_t args[16]`), -resumption mode is scanned per *handler* rather than per arm -(`codegen/effects.rs` `arms.iter().any(contains_resume)`), and the handler set -lives on the thread's stack instead of travelling with the continuation. Fix -each once and the ten close in four steps — do not schedule them individually. +mailbox is fixed-width and untyped, resumption mode is scanned per *handler* +rather than per arm (`codegen/effects.rs` `arms.iter().any(contains_resume)`), +and the handler set lives on the thread's stack instead of travelling with the +continuation. Fix each once and the ten close in four steps — do not schedule +them individually. + +**Root cause 1 is discharged.** The mailbox is now length-carrying and +kind-tagged (`compiler/runtime/effects_coro.c`, split out of +`effects_runtime.c`), which closed #182 and #185 together; #183 was already +fixed and only its test was masking the fact. See +[`0017-AlgebraicEffects.md`](../specs/0017-AlgebraicEffects.md) +`[EFFECTS-OPERATION-MAILBOX]`. ## Summary @@ -60,12 +63,17 @@ work on WebAssembly). rejects recursive handler-arm re-entry, and requires an empty entry row. Explicit rows are checked contracts, not handlers. The runtime null-lookup guard in `crates/osprey-codegen/src/effects.rs` remains a defensive backstop. -- **Direct value substitution**: a non-resuming arm's value becomes - the `perform`'s result; handlers may own `mut` state - ([EFFECTS-HANDLER-STATE], `capture_list`/`build_env`/`reload_env` in - `effects.rs`). Reference: `tests/regressions/effects/http_state_levels.test.osp`. +- **Direct value substitution**: in a region where no arm resumes, a + non-resuming arm's value becomes the `perform`'s result; where some arm does, + a non-resuming arm abandons the continuation and its value is the whole + `handle`'s answer instead, which is what the checker holds it to + ([EFFECTS-HANDLER-ARMS], `check_abandoning_arm` in `osprey-types`). Handlers + may own `mut` state ([EFFECTS-HANDLER-STATE], + `capture_list`/`build_env`/`reload_env` in `effects.rs`). Reference: + `tests/regressions/effects/http_state_levels.test.osp`, + `tests/regressions/effects/abort_vs_resume.test.osp`. - **Single-shot deep `resume`**: an arm that mentions `resume` runs the body - on a pthread (`__osprey_coro_*`, `effects_runtime.c`), suspends at each + on a pthread (`__osprey_coro_*`, `effects_coro.c`), suspends at each `perform`, and `resume(v)` drives it to completion or the next operation. Reference: `tests/effects/resume/`, whose paired assertion suites cover value rewrite, LIFO audit, early-exit abort, outer-handler bridge, and unit markers. @@ -96,7 +104,7 @@ work on WebAssembly). nondeterministic wrong answers with exit 0 (audit repro: expected `r=3`, observed `r=4` on 4 of 5 runs). Each perform now claims the channel exclusively for its full ping-pong (`in_flight` in - `compiler/runtime/effects_runtime.c` `__osprey_coro_suspend`); queued + `compiler/runtime/effects_coro.c` `__osprey_coro_suspend`); queued performs are dispatched by the existing drive-loop re-entry. Locked by `tests/regressions/effects/fiber_effects.{osp,ospml}` §(3) — deterministic `race-free sum 30`. @@ -108,13 +116,16 @@ work on WebAssembly). inside it. Now a type error (`` `resume` is only valid inside a handler arm ``); pinned by `examples/failscompilation/resume_in_arm_lambda.ospo`. -1d. **Resuming operation arguments after position 16 become zero.** The - compiler accepts the operation, but the native continuation mailbox copies - only 16 arguments and `__osprey_coro_arg` returns zero for later positions. - The process exits successfully with corrupted data. Tracked as critical - [issue #182](https://github.com/Nimblesite/osprey/issues/182); the paired - `resume_error_policies.test.{osp,ospml}` suites prove positions 1–16 and keep - position 17 as an explicit known-failure skip. +1d. ~~**Resuming operation arguments after position 16 become zero.**~~ + **FIXED.** The native continuation mailbox copied only 16 arguments while + keeping the declared arity, and `__osprey_coro_arg` answered zero for later + positions, so the process exited successfully with corrupted data. Tracked as + critical [issue #182](https://github.com/Nimblesite/osprey/issues/182). The + mailbox is now allocated per suspension and sized by the operation's real + arity, and an out-of-range slot aborts instead of answering zero. The paired + `resume_error_policies.test.{osp,ospml}` suites assert positions 1–16, 1–17, + and nine managed with nine scalar operands in one operation — position 17 was + a known-failure skip, now a passing assertion. 1e. ~~**Direct handlers corrupt whole `Result` operation values.**~~ **FIXED.** Both `Success` and `Error` values used to reach the caller as @@ -132,13 +143,16 @@ work on WebAssembly). [issue #184](https://github.com/Nimblesite/osprey/issues/184). Paired golden examples use the verified flat form until the curried path is repaired. -1g. **A dynamic string continuation answer leaks under ARC.** A one-operation - handler that resumes with a string and returns a computed string produces - correct output but leaves one managed object live at exit. Repetition and - both syntax flavors reproduce it. Tracked as critical - [issue #185](https://github.com/Nimblesite/osprey/issues/185); paired tests - keep the unsafe shape as a known-failure skip while other string operation - paths still run under every memory mode. +1g. ~~**A dynamic string continuation answer leaks under ARC.**~~ **FIXED.** A + one-operation handler that resumed with a string and returned a computed + string produced correct output but left one managed object live at exit, in + both flavors. Tracked as critical + [issue #185](https://github.com/Nimblesite/osprey/issues/185). Two + independent leaks were behind it: `resume` was the one effect boundary that + received an owned value and never registered it, and the mailbox held a + reference to every managed operand that nothing released. Both are closed; + the whole `tests/effects` corpus now exits with zero live ARC objects, and + the paired suites assert the previously-skipped shape. 2. **First-class handler values do not parse.** ```osprey-ml @@ -179,7 +193,7 @@ second resume aborts with a diagnostic. is not supported)` and a nonzero exit when the coro is already done (the continuation was consumed). The legitimate drive→resume→drive re-entry leaves the coro *suspended*, not done, so it does not trip the guard. - (`compiler/runtime/effects_runtime.c`.) + (`compiler/runtime/effects_coro.c`.) - [ ] *(Optional, deferred.)* A **compile-time** diagnostic where statically obvious — an arm that `resume`s on two always-executed control-flow paths — could report the error before runtime. Not implemented: the @@ -338,8 +352,14 @@ The runtime guard is now defense in depth rather than normal effect checking. - [x] **Phase A** — reject multi-shot resume (runtime guard + failscompilation + 0017 §Status). *Done.* (Optional static-detection refinement deferred; the runtime guard is sound and total.) -- [ ] **Critical #182** — preserve every accepted resumable operation argument - or reject arities above a documented limit before code generation. +- [x] **Critical #182** — preserve every accepted resumable operation argument. + *Done.* The mailbox is allocated per suspension and sized by the + operation's real arity ([EFFECTS-OPERATION-MAILBOX]), so there is no + documented limit left to reject against; reading a slot the operation + never sent aborts instead of answering zero. Asserted at arities 16, 17 + and 18 (nine managed, nine scalar) in + `tests/effects/resume/resume_error_policies.test.{osp,ospml}`, and at 20 + scalars in `compiler/runtime/effects_runtime_tests.c`. - [x] **Critical #183** — preserve complete `Result` values through the direct handler ABI in both flavors and all memory modes. **Fixed; this item was left unchecked after the fix landed.** @@ -352,8 +372,13 @@ The runtime guard is now defense in depth rather than normal effect checking. broken behaviour and need the same correction; gh issue 183 can close. - [ ] **Critical #184** — keep effectful curried ML functions behaviorally equivalent to their flat parameter form. -- [ ] **Critical #185** — release managed continuation answers exactly once +- [x] **Critical #185** — release managed continuation answers exactly once under ARC, including nested and repeated string-valued resumptions. + *Done.* Two independent leaks: `resume` never registered the owned answer + it received, and the mailbox never released the managed operands it held. + The whole `tests/effects` corpus now exits with zero live ARC objects, and + `compiler/runtime/effects_runtime_tests.c` asserts the mailbox drops + exactly one reference per managed slot — no more, no less. - [ ] **Phase B** — first-class handler values + multi-install (AST, types, state, codegen, both surfaces, tests). *Unblocks plan 0013 Phase 0.* - [x] **Phase C** — static inferred-operation propagation, exact handler diff --git a/docs/plans/0026-structured-concurrency.md b/docs/plans/0026-structured-concurrency.md index 0525e0a2..1b730a62 100644 --- a/docs/plans/0026-structured-concurrency.md +++ b/docs/plans/0026-structured-concurrency.md @@ -3,7 +3,7 @@ **Subsystem:** `tree-sitter-osprey` + `crates/osprey-syntax` (both flavors) + `crates/osprey-ast` + `crates/osprey-types` (effect rows, turn graph) + `crates/osprey-codegen` + native runtime (`fiber_runtime.c`, -`effects_runtime.c`) +`effects_coro.c`) **Status:** design spec written; **no implementation started** **Spec:** [0036-StructuredConcurrency.md](../specs/0036-StructuredConcurrency.md) @@ -18,7 +18,7 @@ round-trip serialization — rather than adding a parallel subsystem. ## Existing seams this builds on -- `effects_runtime.c` / `__osprey_coro_*`: the continuation representation to +- `effects_coro.c` / `__osprey_coro_*`: the continuation representation to drop instead of resume; its per-handler serialization is the proto-turn. - `crates/osprey-types/src/effect_rows.rs`: the closed-program fixed point that already knows which operations a body can reach — reused twice, for diff --git a/docs/plans/0027-any-erasure-and-recovery.md b/docs/plans/0027-any-erasure-and-recovery.md new file mode 100644 index 00000000..d6b2187d --- /dev/null +++ b/docs/plans/0027-any-erasure-and-recovery.md @@ -0,0 +1,229 @@ +# `any` erasure: ownership and recovery + +**Status:** analysis complete, no fix started. +**Invariant under audit:** *a program the checker accepts either behaves as +documented or is rejected with a truthful error — it never prints a wrong answer +and never reads freed memory.* +**Audited against:** `crates/osprey-codegen/src/{lower,arc,cast,effects}.rs`, +`crates/osprey-types/src/{expr,unify,ty}.rs`, spec 0004 `[TYPE-ANY]`, spec 0018 +`[MEM-BACKENDS]`. + +Every observation below was produced by compiling and running a probe against +`target/release/osprey`, on this branch **and** on a clean `origin/main` +worktree at `a57673e2`. All three findings reproduce identically on both, in +both flavors, so none of them is a branch regression. Observed output is quoted +verbatim. + +Normative behaviour of `any` stays in spec 0004 `[TYPE-ANY]`. This plan holds +only what is *broken* about it, and is deleted when the checklist is done. + +--- + +## 1. Verdict + +`any` is erased to a machine word at lowering, and the word keeps no evidence of +what it was. Three consequences follow, and they are not variations of one bug — +they fail in different places, on different backends, with different symptoms. + +| # | Finding | Backends | Symptom | Issue | +|---|---------|----------|---------|-------| +| A | A heap value returned as `any` is released by the producing frame | `arc` only | Dangling read; two calls over-release | [#208](https://github.com/Nimblesite/osprey/issues/208) | +| B | A `let` annotation does not drive the recovery coercion a return type does | all | Prints the address as a decimal integer | [#209](https://github.com/Nimblesite/osprey/issues/209) | +| C | Recovering a pointer from a word that never was one is unchecked | all | SIGSEGV, no diagnostic | — | + +The common cause is representational: `LType::I64` is simultaneously every +`int`, every erased `any`, and every *borrowed* `any` parameter. Codegen cannot +distinguish them, so neither an ownership rule nor a recovery rule can key off +anything real. **Every fix below is blocked on removing that conflation**; the +findings are listed separately because they need different repairs once it is. + +--- + +## 2. Finding A — an erasing return frees its own referent (`--memory=arc`) + +```osprey +fn erased() -> any = "a" + "b" +fn read() -> string = erased() +print("e=${read()}") +``` + +``` +$ OSPREY_ARC_DEBUG=1 osprey a.osp --run --quiet --memory=arc +[osp-arc] exit: 0 live objects, 0 KiB (+0 immortal) +e= +``` + +`--memory=default` and `--memory=gc` print `e=ab`. + +Two recoveries in one expression over-release rather than merely dangle: + +```osprey +fn erased() -> any = "abcdefghijklmnopqrstuvwxyz" + "0123456789" +fn read() -> string = erased() +print("len=${length(read())} val=${read()}") +``` + +``` +$ OSPREY_ARC_DEBUG=1 osprey b.osp --run --quiet --memory=arc +[osp-arc] exit: 18446744073709551615 live objects, 18014398509481983 KiB (+0 immortal) +len=0 val= +``` + +`18446744073709551615` is `-1` unsigned: the ledger released an object it no +longer held. + +**Cause.** `coerce_return` erases the returned pointer to the declared `i64` +return type (`crates/osprey-codegen/src/lower.rs`). ARC matches owners by SSA +operand, so the `ptrtoint` result is a different register than the recorded +owner and `take_owner_anywhere` misses. The compensating `retain_val` in +`epilogue` is *also* a no-op, because `managed()` requires `LType::Str | +LType::Ptr` and the erased value is now `LType::I64`. Neither the move-out nor +the retain fires, so the frame releases the string while the caller holds the +word. + +## 3. Finding B — recovery is positional + +The same erased value recovers correctly through a return type and silently +miscompiles through a `let` annotation, in one program: + +```osprey +fn erased() -> any = "a" + "b" +fn viaReturn() -> string = erased() + +let viaLet: string = erased() + +print("viaReturn=${viaReturn()} viaLet=${viaLet}") +``` + +``` +$ osprey c.osp --check +c.osp: ok (4 statements) +$ osprey c.osp --run --quiet --memory=default +viaReturn=ab viaLet=4385840928 +$ osprey c.osp --run --quiet --memory=gc +viaReturn=ab viaLet=4351876928 +``` + +`viaLet` is the heap address rendered as an integer, and differs on every run. +The ML twin behaves identically. + +**Cause.** `coerce_return` coerces the body to the *declared* return `LType`, +and that is what emits the `inttoptr`. `Stmt::Let` has no equivalent: it lowers +the initializer and binds it as-is, never consulting the declared type. The +comment there states the intent — *"Bindings preserve their inferred +representation"* — which is right for every type whose lowered form already +matches its annotation, and wrong for the one type where it does not. + +## 4. Finding C — un-erasure is an unchecked assertion + +`any` unifies with everything and carries no tag, so nothing rejects recovering +a pointer from a word that was never one: + +```osprey +fn intish() -> any = 7 +fn useIt() -> string = intish() +print("x=${length(useIt())}") +``` + +``` +$ osprey d.osp --run --quiet --memory=default; echo "rc=$?" +rc=139 +``` + +`139` is SIGSEGV. This is the concrete cost of "code that consumes its +representation must already know what was passed": today every un-erasure is an +unchecked assertion, and the checker offers no way to make it a checked one. + +--- + +## 5. What already works, and must keep working + +*Forwarding* an erased value is safe — the word is borrowed and the frame that +built the value still owns it. `tests/regressions/basics/types/ +any_type_comprehensive.test.{osp,ospml}` pins it under all three backends with +the live-object oracle armed, and +`recovering_a_pointer_from_an_erased_word_takes_no_ownership` +(`crates/osprey-codegen/src/lib.rs`) fails if an owner is ever entered in the +ledger at that cast. + +```osprey +fn forward(x: any) -> any = x +fn forwarded() -> string = forward("dyn" + "amic") +``` + +## 6. The repair that is wrong — do not retry it + +Transferring the reference when erasing and taking ownership when recovering was +written, shipped briefly on this branch, and reverted. It balances **only** when +the word came from a `pointer -> any` erasure: + +- `fn identity(x: any) -> any = x` gains an owner it never received. The + epilogue moves that fictitious owner out, releases the real one, and returns a + dangling pointer — observed as `v=` where `origin/main` prints `v=ab`. +- `fn intish() -> any = 7` registers `7` as a pointer and later frees it. + +So the rule is not merely unbalanced, it is memory-unsafe in the *other* +direction, and it converts finding C from a crash into a heap corruption. Any +proposal that keys ownership off the machine word has this same flaw. + +## 7. What a real fix requires + +`any` must be distinguishable from `int` **after lowering**. Options, cheapest +first: + +1. **A distinct `LType`.** Keep the `i64` machine representation but give the + erased word its own lowered type, so `coerce_return`, `Stmt::Let` and the + effect mailbox can all see it. Fixes B and unblocks A; does not fix C. +2. **A provenance/tag bit.** Distinguishes a transferred pointer from a borrowed + or scalar word at runtime. Fixes A and C; costs a bit of every erased value + and an ABI break at the FFI boundary. +3. **Narrow `any` so recovery requires a `match`.** Removes the unchecked + assertion entirely, at the cost of breaking existing `any` call sites. + +Option 1 is a prerequisite for the others and is the only one with no surface +change. + +--- + +## TODO + +### Phase 1 — make the erasure visible to codegen + +- [ ] Give an erased `any` its own `LType` variant distinct from `I64`, keeping + the same machine representation and calling convention. +- [ ] Audit every `LType::I64` match arm in `crates/osprey-codegen/` for whether + it means *integer* or *machine word*; the ones meaning machine word take + the new variant too. +- [ ] `cross_flavor_ir_equiv` must stay green — the change is representational, + not observable. + +### Phase 2 — positional recovery (#209) + +- [ ] `Stmt::Let` coerces its initializer to the annotated type, as + `coerce_return` does for a declared return type. +- [ ] Corpus case: the `viaReturn`/`viaLet` program above, asserting both + recoveries print `ab`, in both flavors, under all three backends. + +### Phase 3 — ownership across the erasure (#208) + +- [ ] Decide transfer-on-erase vs borrow-only now that the erased type is + distinguishable, and record the decision in spec 0004. +- [ ] Corpus cases with `OSPREY_ARC_DEBUG=1`: erasing return recovered once and + twice, erased value through an effect operand, erased value discarded + while still erased. Each asserts zero live objects. +- [ ] `slot_is_managed` (`crates/osprey-codegen/src/effect_mailbox.rs`) must + classify an erased operand from the new type rather than its ABI. + +### Phase 4 — the unchecked assertion (finding C) + +- [ ] Decide between a runtime tag and a `match`-gated recovery surface; this is + a language change and needs its own spec section, not a codegen patch. +- [ ] `examples/failscompilation/` case for whichever recovery becomes illegal. + +### Phase 5 — verification + +- [ ] `make ci` green; differential harness under all three backends **and** + `OSPREY_TARGET=wasm32`. +- [ ] Re-run every probe in this plan and confirm the quoted output changed. +- [ ] Close #208 and #209 with the corpus case that pins each. +- [ ] Delete this plan and its README row. diff --git a/docs/plans/README.md b/docs/plans/README.md index 9816d410..d72f10de 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -11,7 +11,7 @@ checklist and records remaining work with concrete repros. | [0002](0002-codegen-generic-function-values.md) | Generic functions & lambdas as values | codegen | Slot-driven specialization, let-alias and the emit-once specialisation cache landed; **only** a returned still-generic lambda bails (needs per-instantiation cells) | Low (remainder) | | [0004](0004-collection-stdlib-completion.md) | Collection / map stdlib surface | stdlib/codegen | Premise resolved from the other side: spec 0012 was rewritten to the shipped prefixed surface (`length`/`isEmpty` the only bare names), so the bare-name items are **withdrawn**, not deferred. The receiver-directed miscompile is fixed across all **three** list-shaped layouts — handle, map, and flat literal — and the `+` operator's matching layout hole (a segfault on `xs + [1]`) with it. Left: one defect, `listGet` over a `List` | Low | | [0007](0007-fiber-select.md) | `select` over channels | syntax/runtime | Syntax is reserved; type checking rejects it; no runtime multiplexing. The direct-AST codegen path no longer silently returns the **first arm** — it raises the checker's own rejection — so the one acceptance bar reachable without the runtime primitive is met and the plan now carries a TODO checklist | Medium | -| ~~0008~~ | Effect `resume` / continuations | effects | **Done — plan retired.** Single-shot deep `resume` runs on the thread-as-continuation runtime (`__osprey_coro_*`, `effects_runtime.c`); multi-shot aborts with `fatal: continuation already resumed`. Proven by `explicit_resume_runs_the_performer_continuation` and `a_second_resume_aborts_the_program_at_runtime` (both cli_e2e) plus the paired `tests/effects/resume/` assertion suites. This row previously credited `multishot_resume_rejected.ospo`, a must-reject fixture that could not observe a runtime abort and passed on an unrelated arithmetic error; it is deleted and the real coverage is the cli_e2e test. A multi-shot-*capable* runtime, handler values, and open effect rows live in [plan 0016](0016-algebraic-effects-and-handlers.md) | — | +| ~~0008~~ | Effect `resume` / continuations | effects | **Done — plan retired.** Single-shot deep `resume` runs on the thread-as-continuation runtime (`__osprey_coro_*`, `effects_coro.c`); multi-shot aborts with `fatal: continuation already resumed`. Proven by `explicit_resume_runs_the_performer_continuation` and `a_second_resume_aborts_the_program_at_runtime` (both cli_e2e) plus the paired `tests/effects/resume/` assertion suites. This row previously credited `multishot_resume_rejected.ospo`, a must-reject fixture that could not observe a runtime abort and passed on an unrelated arithmetic error; it is deleted and the real coverage is the cli_e2e test. A multi-shot-*capable* runtime, handler values, and open effect rows live in [plan 0016](0016-algebraic-effects-and-handlers.md) | — | | ~~0009~~ | LSP context-awareness & cross-file | lsp | **Done — plan retired.** Completion is filtered by cursor position (`[LSP-COMPLETION-CONTEXT]`, `[LSP-COMPLETION-MEMBER]`), hover covers parameters and written type names (`[LSP-HOVER-WRITTEN]`), signature help triggers on the callee name, and every feature resolves across the project through the compiler's own loader (`[LSP-WORKSPACE]`). 115 `osprey-lsp` tests. The one deliberate remainder — the type of an arbitrary *sub-expression* — needs an expression-keyed table in `osprey-types` and is recorded in [spec 0020](../specs/0020-LanguageServerAndEditors.md) `[LSP-HOVER-WRITTEN]` | — | | [0010](0010-cross-language-benchmark-suite.md) | Cross-language benchmark suite | benchmarks | 22 cases × 7 langs (+ `rust-wasm` and ARC/GC backend columns); `intDiv` added; `-O2` + `@osp_alloc` landed and both reclaiming backends ship. `binarytrees` per the **tracked** `results.json`: default 2.53 GB, `--memory=arc` 2.98 MB (848× less, ~20% *slower*), `--memory=gc` 19.1 MB; alternate backends remain opt-in. `make test` replays the differential harness under both backends and the ARC pass requires zero leaks. Left: quicksort/mergesort (now unblocked), an `Iterator`→`List` collector, a test for the figure generator, and the array/float/bigint families | Low–High | | ~~0011~~ | Reclaiming memory backends (tracing GC + ARC) | codegen/runtime | **Done — plan retired.** Both native backends ship (`--memory=gc`, `--memory=arc`). `make test` requires backend-neutral output across the current differential corpus and zero live ARC values at each example's exit. The shipped contracts are documented in [spec 0018](../specs/0018-MemoryManagement.md) | — | @@ -19,7 +19,7 @@ checklist and records remaining work with concrete repros. | [0013](0013-ml-flavor-frontend.md) | ML flavor frontend (layout syntax, curry-by-default) | frontend/types/codegen/tooling | Frontend shipped (**78** `.ospml` twins, VSIX, equivalence tests, **15** ML must-reject fixtures); LSP now answers in the **authoring** flavor on one `[FLAVOR-SELECT]` chain (`[LSP-FLAVOR-RENDER]`, spec 0020) and a marker/extension conflict is a diagnostic, not a silent guess; the `osprey.toml` `[project].flavor` key is implemented. Only handler *values* + the optional `osprey convert` remain — and the handler-value block is **duplicated** in [0016](0016-algebraic-effects-and-handlers.md) Phase B, which owns the runtime it needs | Mostly done | | [0014](0014-modules-and-namespaces.md) | Modules, namespaces & multi-file apps | frontend/resolver/types/codegen/lsp | Default + ML project compilation, project-aware diagnostics, state-ownership enforcement and cross-file resolution are implemented. **Fourteen items remain, not the three this row used to name:** the architectural ones are opaque manifest aliases, separate per-unit checking of importers against signatures, and an incremental LSP project graph; also open are source-level names in debug info (`DISubprogram` still emits the mangled `__osp_*`), cross-flavor module IR equivalence, the docs generator, and state-boundary LSP warnings. Plus a defect: `opaque` is unenforced for record-payload types — a client outside the module can construct one and read its field | High | | [0015](0015-generics-and-variance.md) | Generics with `in`/`out` variance & generic effects | frontend/types/codegen (both flavors) | Core + generic-fn-values landed, **and so has the static handler/operation instantiation seam** (`crates/osprey-types/src/effect_rows.rs` — a `Stash` handler cannot discharge `Stash.take`), which this row used to list as remaining. Left: call-site type application (`identity(5)`) and its failscompilation fixture | Mostly done | -| [0016](0016-algebraic-effects-and-handlers.md) | Algebraic effects roadmap (resume/handler-values/multi-shot) | effects/types/codegen/runtime | Phases A and C done: tail + single-shot resume, generic effects, multi-shot abort (now covered by a runtime test), fiber-perform race fix, lambda-resume type error — **plus static operation inference, propagation, exact handler discharge and entry-point proof** (`crates/osprey-types/src/effect_rows.rs`, 84 checker tests, 12 must-reject fixtures), which this row used to list as outstanding. Whole-`Result` transport through direct handlers (#183) is fixed and locked. Left: handler values (Phase B — **duplicated** in [0013](0013-ml-flavor-frontend.md) Phase 0), wasm effects (D), diagnostics/LSP (E), an open-row variable in `Type::Fun`, and showstoppers #182/#184/#185 | High | +| [0016](0016-algebraic-effects-and-handlers.md) | Algebraic effects roadmap (resume/handler-values/multi-shot) | effects/types/codegen/runtime | Phases A and C done: tail + single-shot resume, generic effects, multi-shot abort (now covered by a runtime test), fiber-perform race fix, lambda-resume type error — **plus static operation inference, propagation, exact handler discharge and entry-point proof** (`crates/osprey-types/src/effect_rows.rs`, 84 checker tests, 12 must-reject fixtures), which this row used to list as outstanding. Whole-`Result` transport through direct handlers (#183) is fixed and locked, and so are #182 (operands now travel in a heap mailbox, so the 16-argument cliff is gone) and #185 (a managed continuation answer releases under ARC) — both former skips are assertions. Left: handler values (Phase B — **duplicated** in [0013](0013-ml-flavor-frontend.md) Phase 0), wasm effects (D), diagnostics/LSP (E), an open-row variable in `Type::Fun`, and showstopper #184 | High | | [0018](0018-documentation-comments.md) | Documentation comments (both flavors) | ast/syntax/lsp/cli | Phases 1–2 shipped: one structured `DocComment` model, `///` and `(** … *)` both captured on every declaration form, hover renders it, `[Symbol]` links resolve. All of Phase 3 remains — doctest **execution**, user-declaration `--docs` export, and `//!` inner docs (whose `DocScope::Inner` arm is unreachable today) | Medium | | [0019](0019-ml-elegance.md) | ML flavor elegance (inline unions, equational clauses, ML `?:`, positional payloads; historical plain-arithmetic phase superseded by `[ARITH-CHECKED]`) | frontend/types/codegen (both flavors) | Syntax phases shipped and pinned. Phase 2's silent-wrap decision is historical only; checked-`Result` arithmetic is the shipped contract. `binarytrees.ospml` had silently stopped compiling under `[ARITH-CHECKED]` — migrated, and `cross_flavor_ir_equiv` now walks `benchmarks` so it cannot rot again. The `Wrap(v)`-over-named-payload disagreement is **fixed**: a constructor arm's binders now bind by the *pattern form* (`Ctor { a, b }` by name, `Ctor(a, b)` by column) rather than by the declaration, and ML's only destructure lowers positionally like its Default twin — closing a silent miscompile that returned a payload **pointer** out of an `-> int` function. `?:` now also enforces [PATTERN-RESULT-DEFAULT]'s Result-scrutinee rule, which it had been silently inheriting the ordinary match auto-wrap around. Left: regression tests for two already-fixed defects | Medium | | [0020](0020-package-manager.md) | Source-derived package registry and manager | package core/CLI/API/WASM web/trust plane | Specs 0029–0032 and a 66-source research corpus are complete; **no implementation started** | Very High | @@ -28,6 +28,7 @@ checklist and records remaining work with concrete repros. | [0023](0023-gpu-computation.md) | GPU computation: surface completion & device backends | types/codegen/runtime/CLI | Stages 1–2 shipped bar the delegated items, and **stage 3 (kernel extraction, `[GPU-KERNEL-EXTRACT]`) has landed for lambda kernels**: `gpu_kernel.rs` lifts each to `@__gpu_kernel_N` with captures as leading uniform parameters and no environment pointer, gated by an `OSPREY_GPU_KERNELS=extract|inline` differential over the whole `tests/core/gpu` corpus. Three shapes still inline (closure cells, builtins-by-name, host-bound bodies) and the host-backend payoff is **unmeasured**. Corpus deepened 34 → 100 cases, exposing four real defects (float/bool `fromGpu` read-back, `toGpu` list-value tag loss, ML `i64::MIN`, order-sensitive kernel element typing). Stages 4–7 — every device backend — unstarted; **still no GPU execution** | High | | [0024](0024-staged-effects.md) | Staged effects: static handlers as lowering passes | ast/syntax/CLI | Stage 1 shipped in the Default flavor: `static effect`, `handle static`, the four static-handler obligations with five must-reject fixtures, a region-stack rewrite with per-region helper specialization, `osprey --deps`, and a staged assertion suite. Road-tested: zero handler-runtime residue in the emitted IR, a GPU kernel performing a static effect accepted **with no change to the purity gate**, a `wasm32` link where the resuming twin fails on `__osprey_coro_free`, and the [STAGE-FALSIFY] gate passed — one unannotated `twice` serving both stages, so stage polymorphism needs no stage inference. Left: ML parity, hygiene/name-resolution hardening, instantiation-keyed rules, the reactive layer, device dialects | Medium–High | | [0026](0026-structured-concurrency.md) | Structured concurrency: cancellation & turn isolation | syntax/types/codegen/native runtime | Spec 0036 written (normative target, research-cited); **no implementation started**. Builds on shipped seams: the thread-as-continuation resume runtime (drop instead of resume) and `[EFFECTS-FIBER-PERFORM]` round-trip serialization (the proto-turn). Phases 5 (`within`/`race`) blocked on [0007](0007-fiber-select.md)'s multiplexing primitive | High | +| [0027](0027-any-erasure-and-recovery.md) | `any` erasure: ownership and recovery | types/codegen | Analysis complete, **no fix started**. Three findings, each verified on this branch *and* on a clean `origin/main` worktree, in both flavors: a heap value returned as `any` is released by the producing frame under `--memory=arc`, and two recoveries in one expression underflow the ledger to `-1` ([#208](https://github.com/Nimblesite/osprey/issues/208)); a `let` annotation does not drive the recovery coercion a declared return type does, so the address prints as a decimal integer on **every** backend ([#209](https://github.com/Nimblesite/osprey/issues/209)); and un-erasing a word that was never a pointer is unchecked (SIGSEGV, no diagnostic). All three are blocked on one representational fact — `LType::I64` is equally every `int`, every erased `any` and every *borrowed* `any` parameter. The transfer-on-erase repair was shipped briefly on this branch, proved memory-unsafe in the other direction, and is recorded so it is not retried | Medium | | [0025](0025-gpu-graphics-backends.md) | GPU graphics backends: one shader library, many device APIs | examples/graphics + Makefile + CLI tests | The only place Osprey code makes a GPU execute instructions — and it does so by bypassing `gpu*`. macOS/Metal ships and renders at 103 fps; the Osprey scene sources are byte-identical on every platform, and a mutation-checked drift guard (`graphics_scenes.rs`) derives its expectations from both shader libraries rather than listing them. **The whole Direct3D 12 backend — `base.hlsl`, three bridge files, the Makefile branch — has never been compiled, linked or run**; the exact five-step Windows CI job that would settle it is written down. Vulkan/SPIR-V and the convergence where `gpu*` *emits* these shaders are unstarted | Medium–High | These were surfaced from `CodegenError::unsupported(...)` call sites, the diff --git a/docs/specs/0012-Built-InFunctions.md b/docs/specs/0012-Built-InFunctions.md index bbb78608..57dca2d3 100644 --- a/docs/specs/0012-Built-InFunctions.md +++ b/docs/specs/0012-Built-InFunctions.md @@ -432,10 +432,37 @@ greeting = "Hello, " + name + "!" ## File System Functions — [BUILTIN-FILE] ### `writeFile(path: string, content: string) -> Result` -Writes or replaces a file and returns the number of bytes written. +Writes or replaces a file and returns the number of bytes written. `Success` +means every byte reached the file: a partial write and a failed flush are both +`Error`. Buffering means the bytes leave for the file when it is closed, so a +full disk or a hung-up pipe is discovered there rather than at the write, and +either one is reported. ### `readFile(path: string) -> Result` -Reads a complete file. +Reads a complete stream. The length is whatever the stream produced, not what +seeking to the end claimed it would be — a FIFO, socket or character device +cannot be seeked and reports its size as `-1`, so a seek-derived length reads +those sources as empty and truncates or overruns their contents. + +### Failure reasons — [BUILTIN-FILE-ERRMSG] + +An `Error` from a file operation carries the operation, the subject and the +operating system's own explanation: + +```osprey +match writeFile("out/report.txt", body) { + Success { value } => print("wrote ${toString(value)} bytes") + Error { message } => print(message) // writeFile: out/report.txt: No such file or directory +} +``` + +Discarding that reason makes a missing directory, a permissions denial and a +full disk indistinguishable at the point they are handled, so a program cannot +choose to create the directory, ask for access, or free space. The reason +travels on a thread-local channel that the caller clears immediately before the +operation and takes ownership of immediately after, which is what lets a +`Result` outlive later I/O without inheriting an unrelated failure's message. A +runtime operation that reports no reason falls back to a fixed description. ## Process Operations — [BUILTIN-PROCESS] diff --git a/docs/specs/0017-AlgebraicEffects.md b/docs/specs/0017-AlgebraicEffects.md index 1838c769..d3fc5c45 100644 --- a/docs/specs/0017-AlgebraicEffects.md +++ b/docs/specs/0017-AlgebraicEffects.md @@ -241,33 +241,139 @@ Resuming handlers have these rules: pattern. The known deviation is across sibling operations: adding `resume` to one arm also changes a non-resuming sibling from substitution to early exit. This region-wide behavior is tracked as - [issue #177](https://github.com/Nimblesite/osprey/issues/177). Until it is - fixed, keep sibling operations in the same mode. + [issue #177](https://github.com/Nimblesite/osprey/issues/177). - `resume` is lexical to the arm. It is rejected at top level and inside a lambda declared in an arm, because that lambda has no live arm continuation. - Explicit resume is native-only. WebAssembly supports direct value-substitution handlers but not the pthread-backed continuation runtime. +`[EFFECTS-HANDLER-ARMS]` An arm's value is checked against whichever of the two +things it actually supplies, which follows from the region's mode: + +- No arm in the region resumes: the arm's value substitutes for its operation's + declared result, and the handled expression's own value is the region's result. +- Some arm in the region resumes: an arm that returns without resuming abandons + the continuation. The operation's result is never produced — the `perform` + waiting for it never returns — and the arm's value becomes the result of the + whole `handle` expression, so that is what it is checked against. + +Disagreement in the second case is a type error naming both types: + +```text +handler arm `Mixed.b` never resumes, so its value becomes the whole `handle` +expression's result — but it is `string` and that result is `int`. Give the arm +a `resume`, or make every arm of this handler agree with the handled +expression's type +``` + +The conformance cases are +`examples/failscompilation/effect_arm_answer_type_mismatch.ospo` and its ML twin +`ml_effect_arm_answer_type_mismatch.ospo`, which cover both directions and a +`Result` answer; `tests/regressions/effects/abort_vs_resume.test.osp` holds the +accepted counterparts. + +The rule lives in inference rather than code generation because it needs the +source types. An `any` arm answers anything [TYPE-ANY], and by the time a value +reaches code generation an erased `any` and an `int` are the same machine word: +a check there would either reject every valid erased answer or let a pointer +through as a successful integer. + +### Known limits of abandoning a region + +Abandoning a region ends the suspended computation with `pthread_exit`, and a +killed thread runs no epilogue. Two consequences are unresolved. Both predate +the operation mailbox and neither is reachable with scalar operands, which is +why `tests/regressions/effects/abort_vs_resume.test.osp` passes the ARC leak +oracle: its operands are integers. + +**Heap operands owned by the killed frames are not reclaimed.** The mailbox's +own reference is retired correctly — the dispatcher frees it, and a performer +killed before the handoff releases what it took ([EFFECTS-OPERATION-MAILBOX]) — +but the performing frame's *own* reference, the one an ordinary return would +drop, is abandoned with the stack. Under `--memory=arc` with `OSPREY_ARC_DEBUG=1` +this program reports one live object at exit, the six bytes of `alpha`: + +```osprey +effect Label { tag: fn(string) -> string } + +fn ask(subject) !Label = perform Label.tag(subject) + +let answer = handle Label + tag subject => match subject == "alpha" { + true => "stopped at ${subject}" + false => resume("saw ${subject}") + } +in ask("al" + "pha") +``` + +Reclaiming them needs generated cleanup along the abort path — unwinding — not a +release the runtime could issue, because the owning slots are `alloca`s in every +frame on the killed stack. + +**Abandoning a region whose body is awaiting a spawned fiber deadlocks.** +`__osprey_coro_abort` joins the body thread, and a body blocked in `await` of a +fiber the same abort has just killed inside its own `perform` never returns: + +```osprey +fn pair(a, b) !Label = { + let f1 = spawn ask(a) + let f2 = spawn ask(b) + await(f1) + await(f2) +} +``` + +Resolving it means deciding what `await` of an abandoned fiber yields, which is +the same cancellation question as [issue #177](https://github.com/Nimblesite/osprey/issues/177). +Until then, do not `await` inside a region whose arms can abandon it. + Native resume uses one suspended pthread stack as the continuation. Regions whose arms contain no `resume` stay on the direct handler-call path. -Two critical implementation defects currently limit operation values: - -- [issue #182](https://github.com/Nimblesite/osprey/issues/182): the native - resumable-operation mailbox transports 16 arguments. The compiler accepts a - 17th argument, but the runtime silently delivers zero for it. +`[EFFECTS-OPERATION-MAILBOX]` A resumable operation's arguments cross into the +handler in a **mailbox** allocated per suspension: a word array sized by the +operation's real arity, a parallel array of operand kinds, and that arity. The +mailbox carries no fixed capacity, so an operation of any declared arity +delivers every argument it was given. + +Each slot's kind says whether its word is a managed pointer or a bare scalar, +and the mailbox **owns** the managed ones: the performer transfers a reference +when it suspends, and retiring the mailbox releases exactly those slots. A +handler arm therefore borrows its operands for the whole time it can reach them +— including after a `resume` returns, when the performer's own frame may already +be gone — and an operand can neither be freed early nor outlive its perform. + +The dispatcher *takes* the mailbox before reading it, so an arm that resumes can +let the body perform again: the nested suspension installs its own mailbox +instead of overwriting one still in use. Reading a slot the operation never sent +is a compiler bug, not a recoverable condition, and aborts rather than answering +zero. + +Three critical implementation defects previously limited operation values; all +three are fixed and locked by paired Default/ML cases under `tests/effects`: + +- ~~[issue #182](https://github.com/Nimblesite/osprey/issues/182): the native + resumable-operation mailbox transports 16 arguments; the compiler accepts a + 17th but the runtime silently delivers zero for it.~~ **Fixed** by the + length-carrying mailbox above. - ~~[issue #183](https://github.com/Nimblesite/osprey/issues/183): a direct handler corrupts an operation result whose type is `Result`.~~ **Fixed.** - A direct handler now transports a complete `Result` operation value in - both flavors and under all three memory backends, covered by - `tests/effects/errors/direct_recovery.test.{osp,ospml}` case 10, "handlers can - return whole Result operation values" — formerly a `Skip`, now a `Pass` locked - by the shared golden. Resuming handlers keep their separate coverage. -- [issue #185](https://github.com/Nimblesite/osprey/issues/185): under ARC, a + A direct handler transports a complete `Result` operation value in both + flavors and under all three memory backends. +- ~~[issue #185](https://github.com/Nimblesite/osprey/issues/185): under ARC, a resuming handler leaks one managed object when its completed continuation - answer is a dynamic string. - -Both defects have paired Default/ML known-failure cases under `tests/effects`. + answer is a dynamic string.~~ **Fixed** by the kind-tagged mailbox above, + together with registering the continuation answer as owned at the `resume` + site — the one effect boundary that received an owned value and never claimed + it. + +Coverage: `tests/effects/errors/direct_recovery.test.{osp,ospml}` case 10 for +the whole-`Result` operation value, and +`tests/effects/resume/resume_error_policies.test.{osp,ospml}` for the managed +continuation answer, the sixteen- and seventeen-argument boundaries, and nine +managed with nine scalar operands crossing one operation. Each ran as a +self-passing `Skip` before it was made to assert. The ARC exit audit in +`crates/run_test_corpus.sh` is what proves the release half — the value +assertions pass either way. `[EFFECTS-FIBER-PERFORM]` Concurrent performs into one resuming handler are serialized for the full suspend-to-resume round trip. This prevents arguments diff --git a/examples/failscompilation/effect_arm_answer_type_mismatch.ospo b/examples/failscompilation/effect_arm_answer_type_mismatch.ospo new file mode 100644 index 00000000..069839f7 --- /dev/null +++ b/examples/failscompilation/effect_arm_answer_type_mismatch.ospo @@ -0,0 +1,50 @@ +// [EFFECTS-HANDLER-ARMS] An arm that never resumes in a region where another +// arm does ABANDONS the continuation: the handled body is killed and the arm's +// value becomes the whole `handle` expression's result. So the arm's value must +// be able to BE that result, in BOTH directions and through a `Result` answer. +// +// Checking only one direction is not a partial gate, it is a broken one: the +// unchecked direction reaches `coerce_to`, which boxes a pointer as an integer +// and hands back a heap address as a successful `int`. And the check cannot +// live in codegen, where `any` and `int` are the same erased machine word. +effect Mixed { + a: fn(int) -> int + b: fn() -> int +} + +effect Texts { + a: fn(int) -> int + b: fn() -> string +} + +fn intArm() !Mixed = "v=${perform Mixed.b()}" + +fn textArm() -> int !Texts = { + let ignored = perform Texts.b() + 42 +} + +fn textArmToResult() -> Result !Texts = { + let ignored = perform Texts.b() + Success { value: 42 } +} + +// scalar arm value, string answer +let scalarAnswer = handle Mixed + a x => resume(x) + b => 7 +in intArm() + +// pointer arm value, int answer — the direction codegen used to box silently +let pointerAnswer = handle Texts + a x => resume(x) + b => "dyn" + "amic" +in textArm() + +// pointer arm value, Result answer — the path that bypassed the guard entirely +let resultAnswer = handle Texts + a x => resume(x) + b => "dyn" + "amic" +in textArmToResult() + +print("${scalarAnswer} ${pointerAnswer} ${toString(resultAnswer)}") diff --git a/examples/failscompilation/effect_arm_answer_type_mismatch.ospo.expectedoutput b/examples/failscompilation/effect_arm_answer_type_mismatch.ospo.expectedoutput new file mode 100644 index 00000000..b45eccda --- /dev/null +++ b/examples/failscompilation/effect_arm_answer_type_mismatch.ospo.expectedoutput @@ -0,0 +1,3 @@ +handler arm `Mixed.b` never resumes, so its value becomes the whole `handle` expression's result — but it is `int` and that result is `string`. Give the arm a `resume`, or make every arm of this handler agree with the handled expression's type +handler arm `Texts.b` never resumes, so its value becomes the whole `handle` expression's result — but it is `string` and that result is `int`. Give the arm a `resume`, or make every arm of this handler agree with the handled expression's type +handler arm `Texts.b` never resumes, so its value becomes the whole `handle` expression's result — but it is `string` and that result is `Result`. Give the arm a `resume`, or make every arm of this handler agree with the handled expression's type diff --git a/examples/failscompilation/ml_effect_arm_answer_type_mismatch.ospo b/examples/failscompilation/ml_effect_arm_answer_type_mismatch.ospo new file mode 100644 index 00000000..0ea960df --- /dev/null +++ b/examples/failscompilation/ml_effect_arm_answer_type_mismatch.ospo @@ -0,0 +1,48 @@ +// osprey: flavor=ml +// ML twin of effect_arm_answer_type_mismatch.ospo. [EFFECTS-HANDLER-ARMS] +// An arm that never resumes in a region where another arm does abandons the +// continuation, so its value becomes the whole `handle` result — checked in +// both directions and through a `Result` answer, in both flavors. +effect Mixed + a : int => int + b : Unit => int + +effect Texts + a : int => int + b : Unit => string + +intArm : Unit -> string ! Mixed +intArm () = "v=${perform Mixed.b ()}" + +textArm : Unit -> int ! Texts +textArm () = + ignored = perform Texts.b () + 42 + +textArmToResult : Unit -> Result ! Texts +textArmToResult () = + ignored = perform Texts.b () + Success(value = 42) + +// scalar arm value, string answer +scalarAnswer = + handle Mixed + a x => resume x + b => 7 + in intArm () + +// pointer arm value, int answer — the direction codegen used to box silently +pointerAnswer = + handle Texts + a x => resume x + b => "dyn" + "amic" + in textArm () + +// pointer arm value, Result answer — the path that bypassed the guard entirely +resultAnswer = + handle Texts + a x => resume x + b => "dyn" + "amic" + in textArmToResult () + +print "${scalarAnswer} ${pointerAnswer} ${toString resultAnswer}" diff --git a/examples/failscompilation/ml_effect_arm_answer_type_mismatch.ospo.expectedoutput b/examples/failscompilation/ml_effect_arm_answer_type_mismatch.ospo.expectedoutput new file mode 100644 index 00000000..b45eccda --- /dev/null +++ b/examples/failscompilation/ml_effect_arm_answer_type_mismatch.ospo.expectedoutput @@ -0,0 +1,3 @@ +handler arm `Mixed.b` never resumes, so its value becomes the whole `handle` expression's result — but it is `int` and that result is `string`. Give the arm a `resume`, or make every arm of this handler agree with the handled expression's type +handler arm `Texts.b` never resumes, so its value becomes the whole `handle` expression's result — but it is `string` and that result is `int`. Give the arm a `resume`, or make every arm of this handler agree with the handled expression's type +handler arm `Texts.b` never resumes, so its value becomes the whole `handle` expression's result — but it is `string` and that result is `Result`. Give the arm a `resume`, or make every arm of this handler agree with the handled expression's type diff --git a/tests/WASM_UNPORTABLE.txt b/tests/WASM_UNPORTABLE.txt index a0c567de..328ea71e 100644 --- a/tests/WASM_UNPORTABLE.txt +++ b/tests/WASM_UNPORTABLE.txt @@ -60,8 +60,8 @@ tests/regressions/effects/fiber_effects.test.osp fiber_spawn_env_owned tests/regressions/effects/fiber_effects.test.ospml fiber_spawn_env_owned tests/regressions/effects/handler_scoping.test.osp __osprey_coro_suspend tests/regressions/effects/handler_scoping.test.ospml __osprey_coro_suspend -tests/regressions/effects/http_state_levels.test.osp http_get_response -tests/regressions/effects/http_state_levels.test.ospml http_get_response +tests/regressions/effects/http_state_levels.test.osp http_response_body +tests/regressions/effects/http_state_levels.test.ospml http_response_body tests/regressions/effects/retry_until_valid.test.osp __osprey_coro_suspend tests/regressions/effects/retry_until_valid.test.ospml __osprey_coro_suspend tests/regressions/fiber/cpu_profiling_demo.test.osp __osprey_coro_suspend @@ -77,5 +77,5 @@ tests/regressions/http/http_response_handle.test.osp http_response_body tests/regressions/http/http_response_handle.test.ospml http_response_body tests/regressions/http/http_server_example.test.osp http_create_server tests/regressions/http/http_server_example.test.ospml http_create_server -tests/regressions/http/tui_repo_table.test.osp http_create_server -tests/regressions/http/tui_repo_table.test.ospml http_create_server +tests/regressions/http/tui_repo_table.test.osp http_response_body +tests/regressions/http/tui_repo_table.test.ospml http_response_body diff --git a/tests/effects/README.md b/tests/effects/README.md index 99c1ae52..4402f5c5 100644 --- a/tests/effects/README.md +++ b/tests/effects/README.md @@ -113,9 +113,11 @@ substitution handlers, but not the current pthread-backed resume runtime. The suites keep known failures visible as TAP skips rather than reporting them as successes or making unrelated CI unusable: -- [#182](https://github.com/Nimblesite/osprey/issues/182): resumable operations - silently replace arguments after the 16th with zero. The paired resume suite - proves all 16 supported positions and skips the unsafe 17th-position repro. +- ~~[#182](https://github.com/Nimblesite/osprey/issues/182): resumable operations + silently replace arguments after the 16th with zero.~~ **Fixed.** Operands + travel in a heap mailbox instead of a fixed register window, so there is no + 16-argument cliff. The former skip is now an assertion: the paired suite + performs a 17-argument operation and checks every position arrives intact. - ~~[#183](https://github.com/Nimblesite/osprey/issues/183): direct handlers corrupt whole `Result` operation values.~~ **Fixed.** A direct operation may return a complete `Result`; `errors/direct_recovery.test.{osp,ospml}` case @@ -125,10 +127,11 @@ as successes or making unrelated CI unusable: curried ML function can silently skip handled effects. The paired golden examples use the verified tuple-parameter form while the issue retains the failing and passing reproducers. -- [#185](https://github.com/Nimblesite/osprey/issues/185): a resuming handler - whose completed continuation answer is a dynamic string leaks an ARC object. - String operation values remain covered with scalar final answers; the leaking - managed-answer shape is an explicit paired skip. +- ~~[#185](https://github.com/Nimblesite/osprey/issues/185): a resuming handler + whose completed continuation answer is a dynamic string leaks an ARC + object.~~ **Fixed.** The managed-answer shape is now an assertion rather than + a skip: both the resume value and the answer are built at runtime, so neither + is an immortal literal, and the ARC exit audit sees no survivor. ## Invalid programs diff --git a/tests/effects/errors/direct_recovery.test.osp b/tests/effects/errors/direct_recovery.test.osp index d00e90be..28126149 100644 --- a/tests/effects/errors/direct_recovery.test.osp +++ b/tests/effects/errors/direct_recovery.test.osp @@ -36,9 +36,6 @@ effect LookupError { find: fn(string) -> Result } -/// Verdict lets the known Result-ABI failure remain visible without breaking CI. -type Verdict = Pass | Fail { reason: string } | Skip { why: string } - /// Return positive input unchanged and ask for a replacement only at zero. fn requirePositive(value) !RecoveryError = match value > 0 { true => value @@ -287,7 +284,7 @@ fn deepRecursionCase() = { ]) } -/// Drive the known whole-Result corruption and pass automatically once it is fixed. +/// A direct arm returning a whole `Result` must deliver that exact value. fn wholeResultOperationCase() = { mut lookups = 0 let combined = handle LookupError @@ -300,11 +297,11 @@ fn wholeResultOperationCase() = { } in combineLookups() - // Issue #183 tracks direct handlers corrupting Result wrappers in both flavors. - match combined == 39 && lookups == 2 { - true => Pass - false => Skip { why: "CRITICAL #183: direct handlers corrupt whole Result values" } - } + // Both arms travel as complete Result blocks: Success{4} scales to 40 and + // Error{"absent"} matches to its 6-character length. + checkAll("whole Result operation values", [ + combined == 39, lookups == 2 + ]) } /// A run that never fails must never enter a recovery arm. diff --git a/tests/effects/errors/direct_recovery.test.ospml b/tests/effects/errors/direct_recovery.test.ospml index f8734a2b..ea8ac242 100644 --- a/tests/effects/errors/direct_recovery.test.ospml +++ b/tests/effects/errors/direct_recovery.test.ospml @@ -31,9 +31,6 @@ effect LookupError (** Look up `key`, reporting absence as a `Result` the caller matches on. *) find : string => Result -(** Verdict lets the known Result-ABI failure remain visible without breaking CI. *) -type Verdict = Pass | Fail string | Skip string - (** Return positive input unchanged and ask for a replacement only at zero. *) requirePositive : int -> int ! RecoveryError requirePositive value = @@ -283,7 +280,7 @@ deepRecursionCase () = (value - 8 ?: 0) == 4 ] -(** Drive the known whole-Result corruption and pass automatically once it is fixed. *) +(** A direct arm returning a whole `Result` must deliver that exact value. *) wholeResultOperationCase () = mut lookups = 0 combined = @@ -295,10 +292,11 @@ wholeResultOperationCase () = _ => Error(message = "absent") in combineLookups () - // Issue #183 tracks direct handlers corrupting Result wrappers in both flavors. - match combined == 39 && lookups == 2 - true => Pass - false => Skip "CRITICAL #183: direct handlers corrupt whole Result values" + // Both arms travel as complete Result blocks: Success{4} scales to 40 and + // Error{"absent"} matches to its 6-character length. + checkAll "whole Result operation values" [ + combined == 39, lookups == 2 + ] (** A run that never fails must never enter a recovery arm. diff --git a/tests/effects/resume/README.md b/tests/effects/resume/README.md index 3c16868c..172fd219 100644 --- a/tests/effects/resume/README.md +++ b/tests/effects/resume/README.md @@ -19,11 +19,11 @@ The suites cover: - a handler transforming the completed continuation answer; - 32 sequential suspensions settling exactly once; - integer, string, boolean and `Unit` operations in one handler; -- string continuation answers as a known ARC leak tracked by - [critical issue #185](https://github.com/Nimblesite/osprey/issues/185); and -- the operation-argument boundary: 16 positions pass, while the silently - truncated 17th position is skipped with - [critical issue #182](https://github.com/Nimblesite/osprey/issues/182). +- string continuation answers, asserted to release under ARC — formerly the + leak in [#185](https://github.com/Nimblesite/osprey/issues/185); and +- the operation-argument boundary: a 17-argument operation with every position + checked — formerly truncated after the 16th by + [#182](https://github.com/Nimblesite/osprey/issues/182). The older focused files retain their complete former stdout transcripts as internal oracles, then assert operation counts, supplied values, abort behavior, @@ -32,8 +32,8 @@ handler reachability and settlement order. Resume is currently deep, single-shot and native-only. A second resume of a completed continuation aborts with a clear diagnostic. Direct handlers that do not contain `resume` do not use this runtime and can compile to WebAssembly. -Until #185 is fixed, a resuming region should not finish with a dynamic string -when using ARC; default memory and tracing GC are separate paths. +A resuming region may finish with a dynamic string under every memory backend; +the case is asserted here rather than avoided. Run this category with: diff --git a/tests/effects/resume/resume_error_policies.test.osp b/tests/effects/resume/resume_error_policies.test.osp index 1bd85674..c5cac7c9 100644 --- a/tests/effects/resume/resume_error_policies.test.osp +++ b/tests/effects/resume/resume_error_policies.test.osp @@ -353,7 +353,7 @@ fn heterogeneousResumeCase() = { ]) } -/// StringAnswerRecovery reproduces the ARC leak for a managed continuation answer. +/// StringAnswerRecovery carries a managed continuation answer out of a handler. effect StringAnswerRecovery { /// Request the replacement text the handler supplies. get: fn() -> string @@ -365,9 +365,24 @@ fn stringAnswerBody() -> string !StringAnswerRecovery = { value + "!" } -/// Keep the leaking ARC path visible until critical issue #185 is fixed. -fn stringAnswerArcKnownFailureCase() = - Skip { why: "CRITICAL #185: resuming handlers leak string continuation answers under ARC" } +/// A managed continuation answer reaches the caller and owns exactly one +/// reference: the ARC exit audit sees no survivor. Both the resume value and +/// the answer are built at runtime, so neither is an immortal literal whose +/// retain and release are no-ops. +fn stringAnswerCase() = { + mut calls = 0 + let answer = handle StringAnswerRecovery + get => { + calls = calls + 1 ?: calls + resume("rea" + "dy") + } + in stringAnswerBody() + + checkAll("managed continuation answer", [ + answer == "ready!", calls == 1, + length(answer) == 6 + ]) +} /// SixteenArgumentRecovery locks the largest operation payload the current runtime transports. effect SixteenArgumentRecovery { @@ -396,22 +411,59 @@ fn sixteenArgumentBoundaryCase() = { ]) } -/// Verdict lets a known critical runtime failure remain visible without making unrelated CI red. -type Verdict = Pass | Fail { reason: string } | Skip { why: string } - -/// SeventeenArgumentRecovery is accepted today but runtime argument 17 becomes zero. -effect SeventeenArgumentRecovery { - /// Recover across seventeen integer arguments — the first arity that spills to the stack. +/// WideArgumentRecovery reaches past the sixteen slots the mailbox once held. +effect WideArgumentRecovery { + /// Recover across seventeen integer arguments — the first arity past that width. recover: fn(int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int) -> int + /// Recover across nine managed and nine scalar slots interleaved past it. + describe: fn(string, int, string, int, string, int, string, int, string, int, string, int, string, int, string, int, string, int) -> string } /// This is the exact operation shape tracked by critical issue #182. -fn seventeenArgumentBody() !SeventeenArgumentRecovery = - perform SeventeenArgumentRecovery.recover(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17) +fn seventeenArgumentBody() !WideArgumentRecovery = + perform WideArgumentRecovery.recover(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17) + +/// Text built at runtime, never a literal, so each managed slot is refcounted. +fn slotText(n) = "s" + "${n}" + +/// Nine managed and nine scalar arguments cross one operation together. +fn wideManagedBody() !WideArgumentRecovery = perform WideArgumentRecovery.describe( + slotText(1), 1, slotText(2), 2, slotText(3), 3, slotText(4), 4, slotText(5), 5, + slotText(6), 6, slotText(7), 7, slotText(8), 8, slotText(9), 9) -/// Keep the unsafe boundary named in TAP until #182 makes it safe to execute. -fn seventeenArgumentKnownFailureCase() = - Skip { why: "CRITICAL #182: resumable operation argument 17 is silently replaced with zero" } +/// Argument seventeen arrives as sent instead of silently becoming zero. +fn seventeenArgumentCase() = { + mut observed = "" + let result = handle WideArgumentRecovery + recover a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 a13 a14 a15 a16 a17 => { + observed = "${a1}|${a2}|${a3}|${a4}|${a5}|${a6}|${a7}|${a8}|${a9}|${a10}|${a11}|${a12}|${a13}|${a14}|${a15}|${a16}|${a17}" + resume(a17) + } + in seventeenArgumentBody() + + checkAll("seventeen-argument resume boundary", [ + result == 17, + observed == "1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17" + ]) +} + +/// Managed and scalar slots past that boundary all arrive, and every managed +/// one releases: an argument the mailbox retains but never drops survives to +/// the ARC exit audit exactly like a leaked answer would. +fn wideManagedArgumentCase() = { + mut calls = 0 + let joined = handle WideArgumentRecovery + describe t1 n1 t2 n2 t3 n3 t4 n4 t5 n5 t6 n6 t7 n7 t8 n8 t9 n9 => { + calls = calls + 1 ?: calls + resume("${t1}${n1}${t2}${n2}${t3}${n3}${t4}${n4}${t5}${n5}${t6}${n6}${t7}${n7}${t8}${n8}${t9}${n9}") + } + in wideManagedBody() + + checkAll("wide managed argument transport", [ + joined == "s11s22s33s44s55s66s77s88s99", calls == 1, + length(joined) == 27 + ]) +} // Register recovery that resumes the caller with a fallback. test("resuming recovery continues after the failed operation", resumeRecoveryContinuesCase) @@ -433,9 +485,11 @@ test("resume preserves tagged recovery choices", resumeTaggedChoiceCase) test("thirty-two sequential recoveries settle exactly once", deepResumeStressCase) // Register multiple operation result types in one resuming handler. test("one handler resumes integer string boolean and Unit operations", heterogeneousResumeCase) -// Register the managed continuation-answer ARC leak as a known failure. -test("string continuation answers must release under ARC", stringAnswerArcKnownFailureCase) -// Register the current safe operation-argument boundary. +// Register the managed continuation answer that must release under ARC. +test("string continuation answers release under ARC", stringAnswerCase) +// Register the sixteen-argument operation boundary. test("sixteen resumable operation arguments arrive intact", sixteenArgumentBoundaryCase) -// Register the next boundary as an explicit critical known failure. -test("seventeen resumable operation arguments must not be truncated", seventeenArgumentKnownFailureCase) +// Register the first arity past the mailbox's former fixed width. +test("seventeen resumable operation arguments arrive intact", seventeenArgumentCase) +// Register managed and scalar arguments interleaved past that width. +test("managed and scalar arguments cross one wide operation", wideManagedArgumentCase) diff --git a/tests/effects/resume/resume_error_policies.test.osp.expectedoutput b/tests/effects/resume/resume_error_policies.test.osp.expectedoutput index db2225e6..5041d437 100644 --- a/tests/effects/resume/resume_error_policies.test.osp.expectedoutput +++ b/tests/effects/resume/resume_error_policies.test.osp.expectedoutput @@ -8,8 +8,9 @@ ok 7 - resume preserves Success and Error values ok 8 - resume preserves tagged recovery choices ok 9 - thirty-two sequential recoveries settle exactly once ok 10 - one handler resumes integer string boolean and Unit operations -ok 11 - string continuation answers must release under ARC # SKIP CRITICAL #185: resuming handlers leak string continuation answers under ARC +ok 11 - string continuation answers release under ARC ok 12 - sixteen resumable operation arguments arrive intact -ok 13 - seventeen resumable operation arguments must not be truncated # SKIP CRITICAL #182: resumable operation argument 17 is silently replaced with zero -1..13 -# tests=13 passed=11 failed=0 skipped=2 +ok 13 - seventeen resumable operation arguments arrive intact +ok 14 - managed and scalar arguments cross one wide operation +1..14 +# tests=14 passed=14 failed=0 skipped=0 diff --git a/tests/effects/resume/resume_error_policies.test.ospml b/tests/effects/resume/resume_error_policies.test.ospml index 201f4fbb..656aa8d2 100644 --- a/tests/effects/resume/resume_error_policies.test.ospml +++ b/tests/effects/resume/resume_error_policies.test.ospml @@ -331,7 +331,7 @@ heterogeneousResumeCase () = length "osprey.local" == 12 ] -(** StringAnswerRecovery reproduces the ARC leak for a managed continuation answer. *) +(** StringAnswerRecovery carries a managed continuation answer out of a handler. *) effect StringAnswerRecovery (** Request the replacement text the handler supplies. *) get : Unit => string @@ -342,9 +342,23 @@ stringAnswerBody () = value = perform StringAnswerRecovery.get () value + "!" -(** Keep the leaking ARC path visible until critical issue #185 is fixed. *) -stringAnswerArcKnownFailureCase () = - Skip "CRITICAL #185: resuming handlers leak string continuation answers under ARC" +(** A managed continuation answer reaches the caller and owns exactly one + reference: the ARC exit audit sees no survivor. Both the resume value and + the answer are built at runtime, so neither is an immortal literal whose + retain and release are no-ops. *) +stringAnswerCase () = + mut calls = 0 + answer = + handle StringAnswerRecovery + get => + calls := calls + 1 ?: calls + resume ("rea" + "dy") + in stringAnswerBody () + + checkAll "managed continuation answer" [ + answer == "ready!", calls == 1, + length answer == 6 + ] (** SixteenArgumentRecovery locks the largest operation payload the current runtime transports. *) effect SixteenArgumentRecovery @@ -372,22 +386,57 @@ sixteenArgumentBoundaryCase () = observed == "1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16" ] -(** Verdict lets a known critical runtime failure remain visible without making unrelated CI red. *) -type Verdict = Pass | Fail string | Skip string - -(** SeventeenArgumentRecovery is accepted today but runtime argument 17 becomes zero. *) -effect SeventeenArgumentRecovery - (** Recover across seventeen integer arguments — the first arity that spills to the stack. *) +(** WideArgumentRecovery reaches past the sixteen slots the mailbox once held. *) +effect WideArgumentRecovery + (** Recover across seventeen integer arguments — the first arity past that width. *) recover : (int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int) => int + (** Recover across nine managed and nine scalar slots interleaved past it. *) + describe : (string, int, string, int, string, int, string, int, string, int, string, int, string, int, string, int, string, int) => string (** This is the exact operation shape tracked by critical issue #182. *) -seventeenArgumentBody : Unit -> int ! SeventeenArgumentRecovery +seventeenArgumentBody : Unit -> int ! WideArgumentRecovery seventeenArgumentBody () = - perform SeventeenArgumentRecovery.recover 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 + perform WideArgumentRecovery.recover 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 + +(** Text built at runtime, never a literal, so each managed slot is refcounted. *) +slotText n = "s" + "${n}" -(** Keep the unsafe boundary named in TAP until #182 makes it safe to execute. *) -seventeenArgumentKnownFailureCase () = - Skip "CRITICAL #182: resumable operation argument 17 is silently replaced with zero" +(** Nine managed and nine scalar arguments cross one operation together. *) +wideManagedBody : Unit -> string ! WideArgumentRecovery +wideManagedBody () = + perform WideArgumentRecovery.describe (slotText 1) 1 (slotText 2) 2 (slotText 3) 3 (slotText 4) 4 (slotText 5) 5 (slotText 6) 6 (slotText 7) 7 (slotText 8) 8 (slotText 9) 9 + +(** Argument seventeen arrives as sent instead of silently becoming zero. *) +seventeenArgumentCase () = + mut observed = "" + result = + handle WideArgumentRecovery + recover a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 a13 a14 a15 a16 a17 => + observed := "${a1}|${a2}|${a3}|${a4}|${a5}|${a6}|${a7}|${a8}|${a9}|${a10}|${a11}|${a12}|${a13}|${a14}|${a15}|${a16}|${a17}" + resume a17 + in seventeenArgumentBody () + + checkAll "seventeen-argument resume boundary" [ + result == 17, + observed == "1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17" + ] + +(** Managed and scalar slots past that boundary all arrive, and every managed + one releases: an argument the mailbox retains but never drops survives to + the ARC exit audit exactly like a leaked answer would. *) +wideManagedArgumentCase () = + mut calls = 0 + joined = + handle WideArgumentRecovery + describe t1 n1 t2 n2 t3 n3 t4 n4 t5 n5 t6 n6 t7 n7 t8 n8 t9 n9 => + calls := calls + 1 ?: calls + resume "${t1}${n1}${t2}${n2}${t3}${n3}${t4}${n4}${t5}${n5}${t6}${n6}${t7}${n7}${t8}${n8}${t9}${n9}" + in wideManagedBody () + + checkAll "wide managed argument transport" [ + joined == "s11s22s33s44s55s66s77s88s99", calls == 1, + length joined == 27 + ] // Register recovery that resumes the caller with a fallback. test "resuming recovery continues after the failed operation" resumeRecoveryContinuesCase @@ -409,9 +458,11 @@ test "resume preserves tagged recovery choices" resumeTaggedChoiceCase test "thirty-two sequential recoveries settle exactly once" deepResumeStressCase // Register multiple operation result types in one resuming handler. test "one handler resumes integer string boolean and Unit operations" heterogeneousResumeCase -// Register the managed continuation-answer ARC leak as a known failure. -test "string continuation answers must release under ARC" stringAnswerArcKnownFailureCase -// Register the current safe operation-argument boundary. +// Register the managed continuation answer that must release under ARC. +test "string continuation answers release under ARC" stringAnswerCase +// Register the sixteen-argument operation boundary. test "sixteen resumable operation arguments arrive intact" sixteenArgumentBoundaryCase -// Register the next boundary as an explicit critical known failure. -test "seventeen resumable operation arguments must not be truncated" seventeenArgumentKnownFailureCase +// Register the first arity past the mailbox's former fixed width. +test "seventeen resumable operation arguments arrive intact" seventeenArgumentCase +// Register managed and scalar arguments interleaved past that width. +test "managed and scalar arguments cross one wide operation" wideManagedArgumentCase diff --git a/tests/regressions/basics/errors/error_messages.test.osp b/tests/regressions/basics/errors/error_messages.test.osp index 35b07770..24054560 100644 --- a/tests/regressions/basics/errors/error_messages.test.osp +++ b/tests/regressions/basics/errors/error_messages.test.osp @@ -7,6 +7,17 @@ /// A user-declared fallible function — its Error reason must render in toString. fn boom() -> Result = Error { message: "user boom" } +/// A Result payload is a runtime value of ANY length, so `toString` must size +/// its buffer from the string it is handed. The fixed 64-byte block it used to +/// format into was a heap buffer overflow, not a size heuristic: rendering any +/// longer Success payload or Error reason wrote past the allocation and +/// corrupted whatever followed it. [BUILTIN-TOSTRING] +fn longText() = "a Result payload far longer than the sixty-four byte block toString once formatted into, which made printing one a heap overflow" + +fn longFailure() -> Result = Error { message: longText() } + +fn longSuccess() -> Result = Success { value: longText() } + // ---- numeric parsing ---- match parseInt("nope") { Success { value } => print("parseInt: unexpected ${value}\n") @@ -185,7 +196,19 @@ fn collectionAndSuccessCase() = { ]) } +/// `Success(` + payload + `)` is nine characters more than the payload — the +/// exact count the fixed-size buffer got wrong. +fn oversizedPayloadCase() = { + let text = longText() + checkAll("toString sizes its buffer for the payload", [ + toString(longFailure()) == "Error(${text})", + toString(longSuccess()) == "Success(${text})", + length(toString(longSuccess())) == ((length(text) + 9) ?: 0) + ]) +} + test("numeric failures retain their reasons", numericErrorCase) test("string failures retain their reasons", stringErrorCase) test("cursor failures retain their reasons", cursorErrorCase) test("collections fail safely and valid Results succeed", collectionAndSuccessCase) +test("toString sizes its buffer for the payload", oversizedPayloadCase) diff --git a/tests/regressions/basics/errors/error_messages.test.osp.expectedoutput b/tests/regressions/basics/errors/error_messages.test.osp.expectedoutput index da79b6ea..d8e1b16a 100644 --- a/tests/regressions/basics/errors/error_messages.test.osp.expectedoutput +++ b/tests/regressions/basics/errors/error_messages.test.osp.expectedoutput @@ -58,5 +58,6 @@ ok 1 - numeric failures retain their reasons ok 2 - string failures retain their reasons ok 3 - cursor failures retain their reasons ok 4 - collections fail safely and valid Results succeed -1..4 -# tests=4 passed=4 failed=0 skipped=0 +ok 5 - toString sizes its buffer for the payload +1..5 +# tests=5 passed=5 failed=0 skipped=0 diff --git a/tests/regressions/basics/errors/error_messages.test.ospml b/tests/regressions/basics/errors/error_messages.test.ospml index e0cd4d59..2506496a 100644 --- a/tests/regressions/basics/errors/error_messages.test.ospml +++ b/tests/regressions/basics/errors/error_messages.test.ospml @@ -8,6 +8,19 @@ (** A user-declared fallible function — its Error reason must render in toString. *) boom : Unit -> Result boom () = Error(message = "user boom") + +(** A Result payload is a runtime value of ANY length, so `toString` must size + its buffer from the string it is handed. The fixed 64-byte block it used to + format into was a heap buffer overflow, not a size heuristic: rendering any + longer Success payload or Error reason wrote past the allocation and + corrupted whatever followed it. [BUILTIN-TOSTRING] *) +longText () = "a Result payload far longer than the sixty-four byte block toString once formatted into, which made printing one a heap overflow" + +longFailure : Unit -> Result +longFailure () = Error(message = longText ()) + +longSuccess : Unit -> Result +longSuccess () = Success(value = longText ()) // ---- numeric parsing ---- match parseInt "nope" Success value => print "parseInt: unexpected ${value}\n" @@ -163,7 +176,18 @@ collectionAndSuccessCase () = toString (boom ()) == "Error(user boom)" ] +(** `Success(` + payload + `)` is nine characters more than the payload — the + exact count the fixed-size buffer got wrong. *) +oversizedPayloadCase () = + text = longText () + checkAll "toString sizes its buffer for the payload" [ + toString (longFailure ()) == "Error(${text})", + toString (longSuccess ()) == "Success(${text})", + length (toString (longSuccess ())) == ((length text + 9) ?: 0) + ] + test "numeric failures retain their reasons" numericErrorCase test "string failures retain their reasons" stringErrorCase test "cursor failures retain their reasons" cursorErrorCase test "collections fail safely and valid Results succeed" collectionAndSuccessCase +test "toString sizes its buffer for the payload" oversizedPayloadCase diff --git a/tests/regressions/basics/files/file_io_json_workflow.test.osp b/tests/regressions/basics/files/file_io_json_workflow.test.osp index 993f8b12..31839aa2 100644 --- a/tests/regressions/basics/files/file_io_json_workflow.test.osp +++ b/tests/regressions/basics/files/file_io_json_workflow.test.osp @@ -22,9 +22,11 @@ print("-- Step 3: Testing Result toString --") print("Write Result toString: ${toString(writeResult)}") // Result byte count print("Read Result toString: ${toString(readResult)}") // Tests Result toString -/// Test error case for Result +/// A failure must say WHY [BUILTIN-FILE-ERRMSG]: the operation, the path and +/// the operating system's own reason, never a placeholder that reads the same +/// for a missing file, a permissions denial and a full disk. let errorResult = readFile("nonexistent_file_xyz123.txt") -print("Error Result toString: ${toString(errorResult)}") // Should print "Error" +print("Error Result toString: ${toString(errorResult)}") print("=== Test Complete ===") @@ -42,14 +44,32 @@ fn fileWorkflowCase() = { Success { value } => value == expected Error { message } => false } + /// `length(message) > 0` is what this used to assert, and the placeholder + /// "Error" satisfied it — so the test could not tell a truthful reason from + /// a content-free one. Name the parts instead. let missingFails = match missing { Success { value } => false - Error { message } => length(message) > 0 + Error { message } => contains(message, "readFile") + && contains(message, "nonexistent_file_xyz123.txt") + && contains(message, "No such file or directory") + } + let unwritableFails = match writeFile("no_such_dir_xyz123/out.txt", "x") { + Success { value } => false + Error { message } => contains(message, "writeFile") + && contains(message, "no_such_dir_xyz123/out.txt") + && contains(message, "No such file or directory") + } + /// A success must leave no reason behind for the next failure to inherit. + let staleReasonCleared = match writeFile("test_stale_reason.txt", "fresh") { + Success { value } => value == 5 + Error { message } => false } checkAll("file write/read Results", [ writeMatches, readMatches, - missingFails + missingFails, + unwritableFails, + staleReasonCleared ]) } test("file workflow persists content and reports missing files", fileWorkflowCase) diff --git a/tests/regressions/basics/files/file_io_json_workflow.test.osp.expectedoutput b/tests/regressions/basics/files/file_io_json_workflow.test.osp.expectedoutput index 412bba40..0957f8ba 100644 --- a/tests/regressions/basics/files/file_io_json_workflow.test.osp.expectedoutput +++ b/tests/regressions/basics/files/file_io_json_workflow.test.osp.expectedoutput @@ -6,7 +6,7 @@ Read successful! -- Step 3: Testing Result toString -- Write Result toString: Success(23) Read Result toString: Success(Hello, Osprey file I/O!) -Error Result toString: Error(File read error) +Error Result toString: Error(readFile: nonexistent_file_xyz123.txt: No such file or directory) === Test Complete === ok 1 - file workflow persists content and reports missing files 1..1 diff --git a/tests/regressions/basics/files/file_io_json_workflow.test.ospml b/tests/regressions/basics/files/file_io_json_workflow.test.ospml index 2445d2f0..6c80f8bd 100644 --- a/tests/regressions/basics/files/file_io_json_workflow.test.ospml +++ b/tests/regressions/basics/files/file_io_json_workflow.test.ospml @@ -24,9 +24,11 @@ print "-- Step 3: Testing Result toString --" print "Write Result toString: ${toString writeResult}" // Tests Result toString print "Read Result toString: ${toString readResult}" // Tests Result toString -(** Test error case for Result *) +(** A failure must say WHY [BUILTIN-FILE-ERRMSG]: the operation, the path and + the operating system's own reason, never a placeholder that reads the same + for a missing file, a permissions denial and a full disk. *) errorResult = readFile "nonexistent_file_xyz123.txt" -print "Error Result toString: ${toString errorResult}" // Should print "Error" +print "Error Result toString: ${toString errorResult}" print "=== Test Complete ===" @@ -42,12 +44,24 @@ fileWorkflowCase () = readMatches = match readBack Success value => value == expected Error message => false + (** `length message > 0` is what this used to assert, and the placeholder + "Error" satisfied it — so the test could not tell a truthful reason from + a content-free one. Name the parts instead. *) missingFails = match missing Success value => false - Error message => length message > 0 + Error message => contains message "readFile" && contains message "nonexistent_file_xyz123.txt" && contains message "No such file or directory" + unwritableFails = match writeFile "no_such_dir_xyz123/out.txt" "x" + Success value => false + Error message => contains message "writeFile" && contains message "no_such_dir_xyz123/out.txt" && contains message "No such file or directory" + (** A success must leave no reason behind for the next failure to inherit. *) + staleReasonCleared = match writeFile "test_stale_reason.txt" "fresh" + Success value => value == 5 + Error message => false checkAll "file write/read Results" [ writeMatches, readMatches, - missingFails + missingFails, + unwritableFails, + staleReasonCleared ] test "file workflow persists content and reports missing files" fileWorkflowCase diff --git a/tests/regressions/basics/types/any_type_comprehensive.test.osp b/tests/regressions/basics/types/any_type_comprehensive.test.osp index 7b3feca1..33741fa2 100644 --- a/tests/regressions/basics/types/any_type_comprehensive.test.osp +++ b/tests/regressions/basics/types/any_type_comprehensive.test.osp @@ -5,6 +5,22 @@ fn getDynamicValue() -> any = 42 /// This should also pass - explicit any with parameter fn processAnyValue(input) -> any = input + 10 ?: 0 +/// Every `any` above carries a SCALAR, which is why erased HEAP values went +/// uncovered. Forwarding one through `any` must not disturb it: the erasure +/// carries no ownership either way, so `forward` merely borrows the word while +/// the caller's frame keeps owning the string it built. +/// +/// A codegen rule that owned the recovered pointer broke exactly this — the +/// epilogue moved that invented owner out, released the real one, and returned +/// a dangling pointer, printing nothing under `--memory=arc`. Built at runtime +/// rather than written as a literal, or the pointer would be a global and never +/// counted at all. Returning a heap value AS `any` is the case that still does +/// not work; docs/plans/0027-any-erasure-and-recovery.md tracks it (#208). +/// [GC-ARC-PERCEUS] +fn forward(x: any) -> any = x + +fn forwarded() -> string = forward("dyn" + "amic") + print("Explicit any return type works") print("getDynamicValue() = ${getDynamicValue()}") print("processAnyValue(5) = ${processAnyValue(5)}") @@ -135,6 +151,8 @@ fn anyTypeBehaviorCase() = { checkAll("any values and inferred union behavior", [ toString(getDynamicValue()) == "42", toString(processAnyValue(5)) == "15", + forwarded() == "dynamic", + length(forwarded()) == 7, processAny(42) == "Processed any", processAny("hello") == "Processed any", processAny(true) == "Processed any", diff --git a/tests/regressions/basics/types/any_type_comprehensive.test.ospml b/tests/regressions/basics/types/any_type_comprehensive.test.ospml index 38b7dbb6..db189edb 100644 --- a/tests/regressions/basics/types/any_type_comprehensive.test.ospml +++ b/tests/regressions/basics/types/any_type_comprehensive.test.ospml @@ -7,6 +7,24 @@ getDynamicValue () = 42 processAnyValue : any -> any processAnyValue input = input + 10 ?: 0 +(** Every `any` above carries a SCALAR, which is why erased HEAP values went + uncovered. Forwarding one through `any` must not disturb it: the erasure + carries no ownership either way, so `forward` merely borrows the word while + the caller's frame keeps owning the string it built. + + A codegen rule that owned the recovered pointer broke exactly this — the + epilogue moved that invented owner out, released the real one, and returned + a dangling pointer, printing nothing under `--memory=arc`. Built at runtime + rather than written as a literal, or the pointer would be a global and never + counted at all. Returning a heap value AS `any` is the case that still does + not work; docs/plans/0027-any-erasure-and-recovery.md tracks it (#208). + [GC-ARC-PERCEUS] *) +forward : any -> any +forward x = x + +forwarded : Unit -> string +forwarded () = forward ("dyn" + "amic") + print "Explicit any return type works" print "getDynamicValue() = ${getDynamicValue ()}" print "processAnyValue(5) = ${processAnyValue 5}" @@ -147,6 +165,8 @@ anyTypeBehaviorCase () = checkAll "any values and inferred union behavior" [ toString (getDynamicValue ()) == "42", toString (processAnyValue 5) == "15", + forwarded () == "dynamic", + length (forwarded ()) == 7, processAny 42 == "Processed any", processAny "hello" == "Processed any", processAny true == "Processed any", diff --git a/tests/regressions/effects/abort_vs_resume.test.osp b/tests/regressions/effects/abort_vs_resume.test.osp index dafb817a..27effdc0 100644 --- a/tests/regressions/effects/abort_vs_resume.test.osp +++ b/tests/regressions/effects/abort_vs_resume.test.osp @@ -56,11 +56,38 @@ let aborted = handle Step in pipeline() print("aborted result=" + toString(aborted)) +/// An abandoning arm answers for the WHOLE `handle`, so it is the arm's value +/// that must match the handled expression's type. The operation's declared +/// result is never produced — the `perform` asking for it never returns — so +/// `stop` may answer a `string` while declaring `int`. Reconciling the two is +/// inference's job, not codegen's: by the time codegen sees an arm value, the +/// erased machine word an `any` travels in is indistinguishable from an `int`, +/// so a shape test there would reject valid programs and miss invalid ones. +/// [EFFECTS-HANDLER-ARMS] +effect Mixed { + scale: fn(int) -> int + stop: fn() -> int +} + +fn mixedPipeline() -> string !Mixed = { + let scaled = perform Mixed.scale(4) + let stopped = perform Mixed.stop() + "unreached ${scaled} ${stopped}" +} + +let stoppedAnswer = handle Mixed + scale x => resume((x * 2) ?: 0) + stop => "stopped" +in mixedPipeline() + +print("mixed answer: ${stoppedAnswer}") + // Verifies both continuation policies and their exact operation counts. test("resuming completes while returning aborts the pipeline", fn() => checkAll("abort versus resume state", [ recovered == 60, counter == 3, aborted == sentinel, counter2 == 2, - recovered != aborted + recovered != aborted, + stoppedAnswer == "stopped" ])) diff --git a/tests/regressions/effects/abort_vs_resume.test.osp.expectedoutput b/tests/regressions/effects/abort_vs_resume.test.osp.expectedoutput index a0122768..9a549f59 100644 --- a/tests/regressions/effects/abort_vs_resume.test.osp.expectedoutput +++ b/tests/regressions/effects/abort_vs_resume.test.osp.expectedoutput @@ -11,6 +11,7 @@ pipeline saw step1 -> 10 poison at step 2 (input 2) -> abort, discard the rest audit[abort-run]: step 1 (input 1) settled, downstream=-99 aborted result=-99 +mixed answer: stopped ok 1 - resuming completes while returning aborts the pipeline 1..1 # tests=1 passed=1 failed=0 skipped=0 diff --git a/tests/regressions/effects/abort_vs_resume.test.ospml b/tests/regressions/effects/abort_vs_resume.test.ospml index 0e20e8d8..3ff08d36 100644 --- a/tests/regressions/effects/abort_vs_resume.test.ospml +++ b/tests/regressions/effects/abort_vs_resume.test.ospml @@ -52,11 +52,38 @@ aborted = in pipeline () print ("aborted result=" + toString aborted) +(** An abandoning arm answers for the WHOLE `handle`, so it is the arm's value + that must match the handled expression's type. The operation's declared + result is never produced — the `perform` asking for it never returns — so + `stop` may answer a `string` while declaring `int`. Reconciling the two is + inference's job, not codegen's: by the time codegen sees an arm value, the + erased machine word an `any` travels in is indistinguishable from an `int`, + so a shape test there would reject valid programs and miss invalid ones. + [EFFECTS-HANDLER-ARMS] *) +effect Mixed + scale : int => int + stop : Unit => int + +mixedPipeline : Unit -> string ! Mixed +mixedPipeline () = + scaled = perform Mixed.scale 4 + stopped = perform Mixed.stop () + "unreached ${scaled} ${stopped}" + +stoppedAnswer = + handle Mixed + scale x => resume ((x * 2) ?: 0) + stop => "stopped" + in mixedPipeline () + +print "mixed answer: ${stoppedAnswer}" + // Verify both continuation policies and their exact operation counts. test "resuming completes while returning aborts the pipeline" (\() => checkAll "abort versus resume state" [ recovered == 60, counter == 3, aborted == sentinel, counter2 == 2, - recovered != aborted + recovered != aborted, + stoppedAnswer == "stopped" ]) diff --git a/tests/regressions/effects/http_state_levels.test.osp b/tests/regressions/effects/http_state_levels.test.osp index c282bec7..039192e4 100644 --- a/tests/regressions/effects/http_state_levels.test.osp +++ b/tests/regressions/effects/http_state_levels.test.osp @@ -132,10 +132,19 @@ mut taskCount = 0 // Db layer handle Persist flush snap => { - let saved = writeFile("target/osprey_http_state_levels.db", snap) + /// A relative path resolves against the PROCESS's working directory, not + /// the program's, so this lands wherever the corpus was invoked from — + /// the repo root, for `make test`. `target/` would be no better: it only + /// exists when that cwd happens to be the root. What the assertions need + /// is a name no other case writes; where it lands is the caller's, which + /// is why .gitignore names it. [BUILTIN-FILE-ERRMSG] + let saved = writeFile("osprey_http_state_levels.db", snap) diskBytes = match saved { Success { value } => length(snap) - Error { message } => (0 - 1) ?: 0 + // Say WHY [BUILTIN-FILE-ERRMSG]. Storing a bare -1 here is how a + // failed persist reached the assertions as "expected true, got + // false", with the operating system's own explanation discarded. + Error { message } => { print("[persist] ${message}") 0 - 1 ?: 0 } } diskBytes } diff --git a/tests/regressions/effects/http_state_levels.test.ospml b/tests/regressions/effects/http_state_levels.test.ospml index 7067ba8e..986d7dfe 100644 --- a/tests/regressions/effects/http_state_levels.test.ospml +++ b/tests/regressions/effects/http_state_levels.test.ospml @@ -81,10 +81,21 @@ mut taskCount = 0 handle Persist flush snap => - saved = writeFile "target/osprey_http_state_levels.db" snap + (** A relative path resolves against the PROCESS's working directory, not + the program's, so this lands wherever the corpus was invoked from — + the repo root, for `make test`. `target/` would be no better: it only + exists when that cwd happens to be the root. What the assertions need + is a name no other case writes; where it lands is the caller's, which + is why .gitignore names it. [BUILTIN-FILE-ERRMSG] *) + saved = writeFile "osprey_http_state_levels.db" snap diskBytes := match saved Success value => length snap - Error message => (0 - 1) ?: 0 + // Say WHY [BUILTIN-FILE-ERRMSG]. Storing a bare -1 here is how a + // failed persist reached the assertions as "expected true, got + // false", with the operating system's own explanation discarded. + Error message => + print "[persist] ${message}" + 0 - 1 ?: 0 diskBytes bytes => diskBytes in handle Metrics diff --git a/website/src/blog/2026-07-25-exceptions-and-panics-were-a-mistake.md b/website/src/blog/2026-07-25-exceptions-and-panics-were-a-mistake.md index 9b4e98a7..3704f97b 100644 --- a/website/src/blog/2026-07-25-exceptions-and-panics-were-a-mistake.md +++ b/website/src/blog/2026-07-25-exceptions-and-panics-were-a-mistake.md @@ -289,12 +289,12 @@ Important limits include: - An `Error` currently always carries a string message. Although signatures use `Result`, `E` cannot yet be an arbitrary error value. That is why these examples use `Result`. - ~~The compiler does not yet prove that every effect has a handler.~~ **It does now.** A program that performs an effect nothing handles fails to build, with the effect and operation named: `unhandled effect operations at program entry: Log.write; add a matching handle`. The check reaches through helper calls, lambdas passed to higher-order functions, and fibers. It reasons over a closed program's operation summaries rather than an effect-row variable in a function type, so a future surface with independently quantified rows in public higher-order signatures would need more work — see the [algebraic-effects specification](/spec/0017-algebraiceffects/). Handlers that pause and resume work remain native-only; WebAssembly handlers must return immediately. - The compiler automatically extracts the success value from a `Result` in six convenience cases. If the value is actually an `Error`, the current code can discard that error and produce a zero or default value. Osprey therefore does **not** yet force explicit handling of every `Result` on every path. This is an alpha safety gap to fix, not intended language behaviour. -- Resuming effect operations currently transport 16 arguments. The compiler accepts a 17th, but the runtime silently replaces it with zero. This is tracked as [critical issue #182](https://github.com/Nimblesite/osprey/issues/182); paired tests prove the safe boundary and skip the corrupting case. +- ~~Resuming effect operations transport 16 arguments; the compiler accepts a 17th, but the runtime silently replaces it with zero.~~ **Fixed** — [critical issue #182](https://github.com/Nimblesite/osprey/issues/182). An operation's arguments now travel in a mailbox sized by its real arity, so an operation of any width delivers every argument it was given. Paired tests assert 16, 17 and 18 arguments. - ~~Direct handlers corrupt whole `Result` operation values.~~ **Fixed** — [critical issue #183](https://github.com/Nimblesite/osprey/issues/183) now passes in both flavors under all three memory backends. Whole Results passed through explicit `resume` keep their separate tests. - An unannotated four-argument curried ML helper can silently skip effects performed through its body. The verified workaround is a flat parenthesised parameter list while [critical issue #184](https://github.com/Nimblesite/osprey/issues/184) is open. -- Under ARC memory management, a resuming handler whose completed answer is a dynamic string currently leaks one managed object. Paired tests keep the failing path visible while [critical issue #185](https://github.com/Nimblesite/osprey/issues/185) is open. +- ~~Under ARC memory management, a resuming handler whose completed answer is a dynamic string leaks one managed object.~~ **Fixed** — [critical issue #185](https://github.com/Nimblesite/osprey/issues/185). The whole effects test corpus now exits with zero live objects under ARC. -This post states the standard Osprey is aiming for. To meet it, the compiler must reject missing handlers, preserve every accepted operation value and never silently discard an `Error`. The language features already run; the checks and two value-transport paths above are not yet complete. +This post states the standard Osprey is aiming for. To meet it, the compiler must reject missing handlers, preserve every accepted operation value and never silently discard an `Error`. Operation values now transport intact; the remaining gaps above are the `Error` payload type, automatic `Result` extraction, and the curried ML path. ## Stop hiding the second return channel