diff --git a/ISSUES.md b/ISSUES.md index 44b1125..0b13f0b 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -173,6 +173,24 @@ Resolved entries are **removed** from this file. Language-visible decisions behi **Summary:** The parser recovers at statement boundaries by emitting `StmtKind::Error` placeholders (R10), and sema's return-flow analysis already suppresses cascading `MissingReturn` diagnostics for *sema-level* errors via the TIR `Unreachable` sentinel. The parse-error path leaks between those two mechanisms: astgen lowers `StmtKind::Error` to *nothing*, so a function whose only `return` failed to parse reaches sema with a body that genuinely ends without returning, and the user gets a bogus E0036 stacked on the real parse diagnostic (reproduced 2026-09-11: a typo'd `return Person{name=p.name, age=.age + 1}` produced E0100 at the typo *and* E0036 "missing return" on the function signature, pointing the user at the wrong place). **Resolution:** Lower `StmtKind::Error` to a UIR error/unreachable sentinel (or have sema treat it as one) so the existing TIR `Unreachable` rule suppresses `MissingReturn` for parse-broken bodies, matching the cascade suppression sema tests already enforce for sema-internal errors. Regression test: a function whose only return statement fails to parse yields exactly the parse diagnostic, no E0036. +### I-176 — Slicing a borrowed `str`/`bytes` param whose argument is inline (SSO) leaks the promotion buffer + +**Files:** `ryo-backend/src/codegen/views.rs` (`emit_ensure_heap_for_view_base`), `runtime/src/lib.rs` (`__ryo_str_ensure_heap` / `__ryo_bytes_ensure_heap`), `ryo-frontend/src/ownership/` (no free is ever scheduled for borrowed params) +**Summary:** With the tagged-slot string runtime, creating a view from an owner-typed value promotes an inline (≤ 23 B) representation to heap so the view addresses memory that never moves. Plain locals write the promoted triple back into the binding's codegen locals (freed at last use) and struct fields promote in place (the struct drop frees the field) — but a *borrowed* parameter (`fn f(s: str): v = s[0:1]`) has no scheduled free: the callee promotes its by-value copy of the caller's inline triple into a fresh heap buffer that nothing owns. Reproduced under Valgrind (16 bytes definitely lost per call) with `fn scan(s: str): v = s[0:1]; print(v)` called on an `int_to_str` argument. Heap and static arguments are unaffected (promotion no-ops; the view borrows the caller's buffer). The naive fix is unsound: at any free site a callee-promoted buffer is indistinguishable from a caller-owned heap buffer — both are plain `(ptr, len, cap)` triples — so freeing the param would double-free caller memory whenever the argument was heap. +**Resolution:** The ownership pass already computes view liveness for the P2 freeze; use it to schedule a free at the view's last use when a slice/`ToView` base is a borrowed (non-`inout`) `str`/`bytes` param, and make the promotion distinguishable at runtime — e.g. promote through a callee that reports whether it allocated, or route borrowed-param view bases through `ryo_str_from_view`-style materialization as a tracked temporary owner instead of in-place promotion. Regression test: the repro above must come out Valgrind-clean. + +### I-177 — AOT binaries die with SIGSEGV on stack overflow; no guard-page detection or diagnostic + +**Files:** `ryo-backend/src/codegen.rs` (function prologue emission), `ryo-backend/src/linker.rs` (link-time stack size / guard-page setup), `runtime/src/` (no stack-limit check or signal handler exists) +**Summary:** Recursion past the OS stack limit in a Ryo AOT binary hits the guard page blind and dies with SIGSEGV (exit 139) and no message; Rust under identical conditions aborts cleanly with `thread 'main' has overflowed its stack` (exit 134). Reproduced 2026-09-15 with `benchmarks/eager_destruction/eager_destruction.ryo` at depth 210,000 (8176 KB main-thread stack, macOS): Ryo segfaults at ~208k–210k frames while both Rust arms abort with the diagnostic at ~74.6k. Recursion-heavy programs (parsers, tree walkers) get an undebuggable crash instead of an error. +**Resolution:** Emit a stack-limit check in function prologues (compare SP against a limit recorded at startup, abort with a message) or install a SIGSEGV/SIGBUS handler on an alternate signal stack that recognizes guard-page hits and reports them; Cranelift provides no stack probes for this, so the check or handler is ours. Decide the budget semantics (fixed limit vs querying the main-thread stack size at startup) and add a test that recurses past the limit and asserts a clean exit code plus diagnostic. + +### I-178 — Tail-position calls are not emitted as tail calls; recursion depth is frame-size-limited + +**Files:** `ryo-backend/src/codegen/expr.rs` (`emit_call`), `ryo-backend/src/codegen/mod.rs` (`emit_stmt` tail handling), `ryo-frontend/src/ownership/` (pending-drop information at tail position) +**Summary:** When a call sits in true tail position — no pending drops, which is exactly what eager destruction arranges — codegen still emits a normal `call` followed by `return`, so every recursion frame is materialized and maximum depth is frame size × stack size. Measured 2026-09-15 on `benchmarks/eager_destruction`: ~80 B/frame → SIGSEGV at ~208k frames on an 8 MB stack, with the wall time dominated by first-touch cache misses and page faults on the ever-growing stack (CodSpeed: cache misses +400%, memory R/W +81%, while instructions fell −47%). Cranelift supports explicit `return_call` on aarch64/x86_64; emitting it for tail-position calls reuses the frame, giving O(1) stack, unbounded tail recursion, and collapsing those first-touch misses. The benchmark README already claims the tail-position story ("allowing the compiler to optimize the stack frames") — the compiler does not deliver it yet; Cranelift never performs tail-call optimization on its own. +**Resolution:** In codegen, detect calls in tail position with no drops scheduled after them and emit Cranelift `return_call` instead of `call` + `return`. Requires the caller/callee signatures to satisfy `return_call` constraints, the ownership pass to guarantee no frees are pending after the call, and a CLIF-level test: `return_call` present for a self-tail-call after eager destruction, absent when a drop follows. Tail calls remove the overflow only for tail-recursive code — the guard-page diagnostic for non-tail recursion is still needed separately. + --- ## 🟢 Cleanup @@ -357,12 +375,6 @@ Resolved entries are **removed** from this file. Language-visible decisions behi **Summary:** The repo convention is lowercase with underscores for docs (special files like `README.md` excepted). The eight `ryo-*-*.md` files under `docs/dev/` use hyphens instead. `NOTES.md` was renamed to `notes.md` as the cheap half of this cleanup; the hyphenated set was scoped out because each rename must also update every inbound link (`CLAUDE.md`, `ISSUES.md`, the roadmap, and the docs/dev README index at minimum). **Resolution:** One sweep: `git mv` each `ryo-*.md` to its underscore form, then repo-wide grep for each old basename to update links. Verify no residual references with a final grep for `ryo-.*\.md` across tracked markdown. -### I-171 — No small-string optimization: runtime-created `str` values heap-allocate, even ≤ 15-byte ones - -**Files:** `runtime/src/lib.rs` (`RyoStrFat`, `ryo_str_alloc` / `ryo_str_concat` / `ryo_int_to_str` / `ryo_str_free`), `ryo-backend/src/codegen/mod.rs` (`STR_SLOT_SIZE` 24-byte slot layout), `ryo-backend/src/codegen/` (every site emitting str alloc/free/concat calls, including struct field drop glue) -**Summary:** `str` is a 24-byte fat pointer (ptr, len, cap). Static literals are the exception — they point into `.rodata` with cap == 0 (non-heap-owned, never freed) and empty strings avoid allocation entirely — but every runtime-created string (`int_to_str`, concatenation, anything built at runtime) heap-allocates, no matter how short. Swift's `String` inlines up to 15 bytes on 64-bit, so on string-churn workloads names like `user499999` never touch the heap there. That allocation difference is a likely contributor to Swift's win over both Rust and Ryo in `benchmarks/struct_records/` (~1.7x over Ryo AOT, ~2.1x over Rust), alongside the struct-return, field-copy, and drop-glue traffic the benchmark exercises (and to its edge in `many_small_strings`), per the 2026-09-11 checkpoint. Short strings dominate real programs (identifiers, keys, small messages), so this is the largest known structural gap in Ryo's string runtime, and it compounds with M9 structs: every short runtime-created `str` field embedded in an aggregate pays a heap alloc + free per copy. -**Resolution:** Repurpose the 24-byte slot as a tagged union: a discriminator (e.g. in the cap word or the final byte) selects between the current heap fat pointer and an inline representation holding up to ~22 bytes plus length in the slot itself. Runtime entry points (`alloc`, `concat`, `free`, `len`, comparison) branch on the tag; inline strings make `free` a no-op and `concat` fall back to heap only past the inline capacity. This is a breaking change to `RyoStrFat` and every codegen site that materializes or drops a `str` (including struct field drops and sret paths), so it should land as one coordinated runtime+codegen change with ASan coverage; re-checkpoint `many_small_strings` and `struct_records` to measure the win. - ### I-172 — Consuming struct update has no ergonomic form: move + mutate + return dance, no update sugar, no clone **Files:** `ryo-frontend/src/ownership/structs.rs` (`check_field_move_out`, E0043), `docs/specification.md` (§5.1 ownership rules; §4.5 struct literals; the operator-uniqueness rule reserving `..` for type bounds), `benchmarks/struct_records/` (the motivating measurement) @@ -375,11 +387,23 @@ Resolved entries are **removed** from this file. Language-visible decisions behi **Summary:** Every benchmark suite carries its own `run_benchmarks.sh`, and they are literally copies: the struct_records_reuse and struct_records_inout scripts were created by `sed`-substituting the suite name into the struct_records one. Each copy re-implements the same mechanism — prerequisite checks, `cargo build --release`, per-language compile lines, the compiler-version banner, the macOS/Linux `measure_mem` switch, the hyperfine invocation — with the suite-specific part (which arms exist, build commands, run commands) interleaved rather than declared. Drift is already visible: only some suites have Go or Python arms, the version-banner formats differ subtly, the table format and Version-column convention live only in prose in the global README, and adding a suite means another 100-line fork (two were added on 2026-09-11). The same duplication extends to registration: a new suite must be added to the root `codspeed.yml` exec list *and* both AOT build lists in `.github/workflows/codspeed.yml` by hand. **Resolution:** One shared runner (a single script, or a small `xtask`-style tool) where each suite declares its arms — name, source file, build command, run command — in one manifest (e.g. a TOML/YAML per suite or one central file), and the framework does everything else: prereq checks, builds, correctness run (assert checksum) before timing, version capture, RSS measurement, hyperfine, and emitting the README results table (Version column included) in the canonical format. Suite registration for CodSpeed should be generated from the same manifest so `codspeed.yml` and the workflow lists can't drift from the suites. Migrate the existing 11 suites and delete the per-suite scripts. -### I-175 — Consuming `str` concat always allocates a fresh exact-size buffer; no in-place append on a provably-unique lhs +### I-179 — Slot-out producer results are copied into a second slot instead of written into the binding's slot + +**Files:** `ryo-backend/src/codegen/expr.rs` (`emit_slot_out_call` and its call sites) +**Summary:** Every slot-out producer call writes its 24-byte result into a temporary stack slot, after which codegen reloads all three words and re-stores them into the binding's own slot — 3 extra loads + 3 extra stores per call and +24 B of frame per live string. Disassembly of `eager_destruction`'s `recursive` (aarch64, 2026-09-15): `mov x0, sp; blr _ryo_int_to_str` writes slot A at `sp`, then three `ldur`/`stur` pairs copy A into slot B at `sp+0x18`; the frame is 80 B where 56 B would do. +**Resolution:** When the consumer of a slot-out call is a `let`/`mut` binding, pass the binding's own slot address as the out pointer (single write, no copy); keep temp+copy only for results that feed larger expressions. No ABI change — the callee signature is identical, only the pointer argument's provenance changes. + +### I-180 — Provably-inline builtin producers still use slot-out and pay a no-op tagged free + +**Files:** `ryo-backend/src/codegen/expr.rs` (producer call sites, e.g. `int_to_str` :1033-1045), `ryo-frontend/src/builtins.rs` (builtin registry), `runtime/src/` (`ryo_int_to_str` and the other bounded formatters) +**Summary:** `int_to_str(i64)` produces at most 20 chars — always under the 23-byte inline capacity — so its result is provably always-inline, yet codegen treats it as an opaque producer: an address-taken stack slot (defeating register allocation and forcing every use through memory), plus an unconditional `ryo_str_free` extern call that is a guaranteed no-op on the inline tag. Same shape for the other bounded producers (bool/char/float formatters, small conversions). +**Resolution:** Add a max-output-length annotation to the builtin registry; when it is ≤ the inline capacity, (1) return the tagged slot by value in registers (multi-value return) instead of slot-out, so the value only touches the stack if spilled, and (2) elide `ryo_str_free` for that value entirely — the inline tag is statically known, generalizing the elision the cap=0 static-literal path already performs. + +### I-181 — `(ptr, len)` pairs flow through codegen as packed i128; extracting a half costs a 128-bit shift legalization -**Files:** `runtime/src/lib.rs` (`ryo_str_concat`, `__ryo_str_push`, `RyoStrFat`), `ryo-backend/src/codegen/` (concat call sites), `ryo-frontend/src/ownership/` (the reassign/dead-binding analysis that already proves uniqueness), `benchmarks/string_building/` (the tracking measure) -**Summary:** `s = s + suffix` compiles to `ryo_str_concat`, which allocates a fresh exact-size buffer (`cap == len`), copies both operands, and frees the old buffer at the reassign — so a 50,000-iteration append loop is O(n²) (~1.25 GB copied, the entire ~11.8x gap to Rust in `benchmarks/string_building/`). Rust proves this is unnecessary: its `impl Add<&str> for String` consumes the lhs and reuses its buffer (documented behavior), so the identical source `s = s + "x"` is amortized O(n) — uniqueness comes from ownership, not refcounts. Ryo's ownership pass already proves the same fact statically: at a reassign concat the old binding is dead, and a reassignable `s` provably has no live views, so in-place append is sound without COW refcounts or runtime uniqueness checks. What is missing is purely allocation policy: Ryo buffers carry no growth headroom (`cap == len` always), and concat never attempts to extend the lhs allocation even when it could. -**Resolution:** Make `s = s + suffix` compile (or lower) to the amortized path when the ownership pass proves the lhs binding is consumed by the concat — i.e. route it through `__ryo_str_push`-style growth (realloc-or-extend, copy the suffix only) instead of fresh-buffer `ryo_str_concat`. Two substrate changes: (1) string buffers must be allowed `cap > len` headroom from concat/push paths (the fat-pointer layout already carries `cap`; only allocation policy changes), and (2) codegen/sema must select the push path only when the lhs is a plain local binding that dies at the concat — field reads, shared results, and any borrowed lhs keep the allocating path. Interacts with the small-string work (I-171), which redesigns the same slot layout; land them in coordination. Re-checkpoint `string_building` — the gap should collapse toward Rust parity with no source change — and `doubling_concat`. +**Files:** `ryo-backend/src/codegen/expr.rs` (slice / `ryo_str_eq` call sites and view value representation), `ryo-core/src/tir.rs` (how pair values are typed), `runtime/src/lib.rs` (`pack_pair`) +**Summary:** Slice results and literal values are packed `(ptr, len)` pairs represented as i128, so extracting one half is a 128-bit shift — which Cranelift legalizes into a ~9-instruction funnel-shift/select sequence (`lsr`/`lsl`/`orr`/`csel`) instead of the register move it already is. Disassembly of `benchmarks/string_slicing`'s `count_fox` (aarch64, 2026-09-15): two such sequences per scan iteration, one to unpack the `__ryo_slice` result and one to unpack the literal — ~18 wasted instructions × 700k iterations ≈ 12.6M instructions, on top of the extern-call overhead tracked separately. Inlining the slice/eq bodies will not remove this if the values keep flowing as i128. +**Resolution:** Stop representing small pair values as packed i128 end to end. The C ABI does not require it: on aarch64/x86-64 SysV a `u128` return and a `#[repr(C)]` two-`u64` struct return occupy the same two registers, so changing the runtime signatures (`__ryo_slice` :427, `ryo_str_from_literal` :358, both currently `-> u128` via `pack_pair` :270) to return a repr(C) pair — and modeling views as two i64 SSA values in TIR/codegen — is machine-identical at the boundary while eliminating the i128 type that triggers the legalization. Verify Cranelift maps the two-register struct return correctly on the Windows x64 target (different struct-return convention there) before committing to the signature change. --- diff --git a/benchmarks/doubling_concat/README.md b/benchmarks/doubling_concat/README.md index bd697df..5b7c1a8 100644 --- a/benchmarks/doubling_concat/README.md +++ b/benchmarks/doubling_concat/README.md @@ -15,6 +15,19 @@ Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09 | **Swift** | 6.3.3 | 4.0 ms ± 0.1 ms | 1.13x slower | 34.03 MB | | **Ryo (JIT)** | 0.1.0-dev.20260911+b3b7d25 | 4.7 ms ± 0.6 ms | 1.34x slower | 37.03 MB | +### Checkpoint: SSO + consuming concat (2026-09-14) + +Re-measured after the string-runtime rework (tagged 24-byte slot: inline ≤ 23 B, heap with growth headroom, static `.rodata`; consuming reassign-concat appends in place via the push path — see `string_building`'s checkpoint). Unchanged by design: `s = s + s` uses the lhs buffer as its own suffix, and the in-place path only fires when the suffix is a *different* owner, so every doubling keeps the fresh-buffer allocating path. + +| Candidate | Version | Mean time | vs fastest | Max RSS | +|---|---|---|---|---| +| **Ryo (AOT)** | 0.1.0-dev.20260915+c598378 | 3.7 ms ± 0.3 ms | 1.00x | 33.42 MB | +| **Rust** | 1.98.0 | 3.7 ms ± 0.2 ms | 1.01x slower | 35.64 MB | +| **Swift** | 6.3.3 | 4.2 ms ± 0.6 ms | 1.14x slower | 34.03 MB | +| **Ryo (JIT)** | 0.1.0-dev.20260915+c598378 | 4.8 ms ± 0.8 ms | 1.31x slower | 37.16 MB | + +Measurement note: all four rows come from a single full-suite hyperfine run on 2026-09-15 (same protocol for every arm, hyperfine outlier warnings present on Swift/JIT — treat the 1.01x AOT-vs-Rust margin as a tie). The Ryo rows include the scratch-slot fix that followed this checkpoint, which does not touch the doubling path; timings match the 2026-09-14 checkpoint within noise. + ## How to Run Prerequisites: `hyperfine`, `rustc`, `swiftc`, plus a release build of the compiler (`cargo build --release` from the repository root — the script runs it for you). diff --git a/benchmarks/eager_destruction/README.md b/benchmarks/eager_destruction/README.md index d1a721b..313fdb4 100644 --- a/benchmarks/eager_destruction/README.md +++ b/benchmarks/eager_destruction/README.md @@ -37,7 +37,7 @@ fn recursive(x: int): ``` Because the compiler automatically inserts the cleanup call *before* the recursive call: 1. **$O(1)$ Peak Heap Memory:** Only **one** heap-allocated string is alive in memory at any given point, regardless of the recursion depth. -2. **Infinite Stack-Safety / TCO:** The recursive call is in a true tail-call position. No cleanup remains on unwind, allowing the compiler to optimize the stack frames and execute deep recursion (e.g., 80,000 calls) without crashing. +2. **Deep-Recursion Safety:** The recursive call is in a true tail-call position — no cleanup remains on unwind, so frames stay compact and deep recursion (e.g., 80,000 calls) runs without crashing. Note tail *position* is not tail-call *optimization*: current codegen still emits a normal call + return per frame, so depth remains bounded by frame size × stack size (see the checkpoint numbers below). --- @@ -66,7 +66,7 @@ Because `fn1` is called before `fn2`, the string is freed instantly and `fn2` is To allow direct comparison and capture memory (RSS) metrics across all candidates, the benchmark is configured to run at a recursion depth of **50,000** by default (the limit before Rust's stack frame overhead causes a crash on typical OS configurations). -Measurements executed on **macOS 26.6.2 (Build 25G83) on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-08-26, at **50,000** depth: +Measurements executed on **macOS 26.6.2 (Build 25G83) on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-08-26, at **50,000** depth (pre-SSO string runtime): | Benchmark Candidate | Language | Execution Strategy | Max Resident Memory (RSS) | Memory Efficiency (vs Rust Scope-Based) | Result at 50,000 Depth | |---------------------|----------|--------------------|---------------------------|-------------------|-------------------| @@ -75,11 +75,28 @@ Measurements executed on **macOS 26.6.2 (Build 25G83) on a MacBook Pro (Apple M3 | **Rust (Manual Drop)** | Rust 1.98.0 | AOT Compiled (Manual `drop(s)`) | **6.80 MB** | 1.22x more efficient | **Succeeds** | | **Rust (Scope-Based)** | Rust 1.98.0 | AOT Compiled (Scope RAII) | **8.30 MB** | 1.00x (baseline) | **Succeeds** | +### Checkpoint: SSO string runtime (2026-09-15) + +Re-measured on the same machine after the SSO + consuming-concat string rework, at `0.1.0-dev.20260914+7be8f41` (hyperfine `--warmup 3 --shell=none`): + +| Benchmark Candidate | Max RSS | Memory Efficiency (vs Rust Scope-Based) | Mean time | vs fastest | +|---------------------|---------|------------------------------------------|-----------|------------| +| **Ryo (AOT, Eager)** | **5.11 MB** | **1.62x more efficient** | **2.0 ms ± 0.1 ms** | **1.00x (fastest)** | +| **Ryo (JIT, Eager)** | 8.56 MB | 0.97x | 3.1 ms ± 0.2 ms | 1.54x slower | +| **Rust (Manual Drop)** | 6.80 MB | 1.22x more efficient | 3.0 ms ± 0.1 ms | 1.50x slower | +| **Rust (Scope-Based)** | 8.30 MB | 1.00x (baseline) | 3.2 ms ± 0.1 ms | 1.57x slower | + +**Known tradeoff: inline storage vs deep-recursion stack footprint.** The rework changed both numbers above, in opposite directions: + +- **Wall time improved.** Strings of ≤ 22 bytes (every `int_to_str` result here is 1–5 chars) now live inline in their 24-byte slot — the per-frame malloc/free pair is gone entirely. CodSpeed's profiler reads instructions **−47%** and CPU cycles **−29%** for this benchmark, and on bare metal Ryo AOT is now the fastest arm of the suite (2.0 ms). +- **RSS grew** (2.86 → 5.11 MB). `int_to_str` is now a slot-out call, so the string slot is address-taken and cannot ride in registers; each recursion frame is ~45 bytes larger, and with 50,000 frames simultaneously live that is ≈ +2.2 MB of materialized stack. Before SSO the per-frame heap block was freed before recursing and the allocator reused one hot block; now the bytes are spread across 50,000 frames. The memory-efficiency lead over Rust scope-based RAII narrows from 2.90x to 1.62x — still ahead, and still O(1) heap. +- **CodSpeed's instrumented wall-time regression (−31%) does not reproduce on bare metal.** Under CodSpeed's memory-mode environment the first-touch cost of the larger stack (memory R/W +81%, cache misses +400% — one cold line per new frame, plus minor page faults on freshly grown stack pages) dominates; hyperfine shows the opposite sign. Both readings are the same trade: strictly less work, spread over a larger footprint, in the one workload shape (50k simultaneously live frames) where that footprint is the cost. + ### Key Takeaways -1. **Unrivaled Memory Performance:** Ryo's Ahead-Of-Time (AOT) compiled binary achieves the **lowest memory footprint** (2.86 MB), outperforming even Rust's manual `drop` version. -2. **Stack Safety under Deep Recursion:** While Rust **crashes with a stack overflow at exactly 74,556 recursive calls** (even with release-level optimizations `-O` and manual `drop` due to conservative LLVM tail call heuristics), **Ryo runs completely clean up to 260,000 recursive calls** (3.5x deeper than Rust) before reaching the OS stack limit. -3. **The Power of Compact Stack Frames:** In recursive scope-based RAII, Rust must keep active references, drop flags, and landing pads in each stack frame until the recursion unwinds. By contrast, Ryo's **Milestone 8.1 Eager Destruction** statically frees the string allocation *before* entering recursion, leaving the stack frame incredibly compact. -4. **Observing the Crash:** To observe the stack overflow in Rust and Ryo's stack-safety first-hand, edit the `main()` function in `eager_destruction.ryo` and `eager_destruction.rs` to change `50000` to `74556` (or higher), then re-run `./run_benchmarks.sh`. To see Ryo's extreme limits, increase its depth to `260000`. +1. **Fastest and leanest-on-heap:** Ryo's AOT binary is the fastest arm of the suite (2.0 ms, 1.50–1.57x over both Rust arms) and keeps O(1) heap — its RSS (5.11 MB) remains below both Rust variants, though SSO's larger stack frames narrowed the margin from 2.90x to 1.62x. +2. **Stack Safety under Deep Recursion:** Rust **crashes with a stack overflow just above 74,556 recursive calls** (re-verified 2026-09-15: depth 74,556 succeeds, 74,600 aborts — both the scope-based and manual-`drop` arms, even with release-level `-O`, due to conservative LLVM tail call heuristics). **Ryo runs completely clean up to ~208,000 recursive calls** (2.8x deeper than Rust) before reaching the OS stack limit. The pre-SSO build reached 260,000; SSO's larger address-taken frames lowered the ceiling, the same tradeoff behind the RSS growth above. The failure modes differ: Rust detects the overflow and aborts cleanly (`thread 'main' has overflowed its stack`, exit 134), while Ryo hits the guard page blind and dies with SIGSEGV (exit 139). +3. **The Power of Compact Stack Frames:** In recursive scope-based RAII, Rust must keep active references, drop flags, and landing pads in each stack frame until the recursion unwinds. By contrast, Ryo's **Milestone 8.1 Eager Destruction** statically releases the string *before* entering recursion — and with SSO, short strings (≤ 22 bytes) never touch the heap at all, so there is no allocation to free. One distinction matters: this puts the recursive call in true tail *position*, but tail position only makes the call eligible for tail-call optimization — current codegen still emits a normal `call` followed by `return` and materializes every frame (no tail-call lowering yet), which is exactly why the depth ceiling in takeaway #2 is finite. +4. **Observing the Crash:** To observe the stack overflow in Rust and Ryo's stack-safety first-hand, edit the `main()` function in `eager_destruction.ryo` and `eager_destruction.rs` to change `50000` to `74600` (or higher), then re-run `./run_benchmarks.sh`. To see Ryo's own limit, increase its depth past `208000`. --- diff --git a/benchmarks/many_small_strings/README.md b/benchmarks/many_small_strings/README.md index 1bbce88..5ab618b 100644 --- a/benchmarks/many_small_strings/README.md +++ b/benchmarks/many_small_strings/README.md @@ -15,6 +15,19 @@ Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09 | **Ryo (AOT)** | 0.1.0-dev.20260911+b3b7d25 | 19.0 ms ± 0.5 ms | 1.86x slower | 1.38 MB | | **Ryo (JIT)** | 0.1.0-dev.20260911+b3b7d25 | 20.9 ms ± 0.7 ms | 2.04x slower | 4.88 MB | +### Checkpoint: SSO + consuming concat (2026-09-14) + +Re-measured after the string-runtime rework: `str` is now a tagged 24-byte slot — inline for ≤ 23-byte strings, heap with growth headroom beyond that, static `.rodata` for literals. `int_to_str(i) + "!"` is at most 8 bytes, so the per-iteration string never touches the heap: no allocation, and its free is a no-op on the inline tag. + +| Candidate | Version | Mean time | vs fastest | Max RSS | +|---|---|---|---|---| +| **Ryo (AOT)** | 0.1.0-dev.20260914+75d0f1e | 9.6 ms ± 0.4 ms | 1.00x | 1.34 MB | +| **Rust** | 1.98.0 | 10.5 ms ± 0.3 ms | 1.10x slower | 1.50 MB | +| **Swift** | 6.3.3 | 10.6 ms ± 0.3 ms | 1.10x slower | 1.58 MB | +| **Ryo (JIT)** | 0.1.0-dev.20260914+75d0f1e | 11.4 ms ± 0.6 ms | 1.18x slower | 5.02 MB | + +Ryo AOT went from 19.0 ms (1.86x behind Rust) to 9.6 ms — now the **fastest arm**, ahead of both Rust (10.5 ms) and Swift (10.6 ms), at the lightest RSS. A same-day re-run on a busier machine confirmed the ranking (Ryo AOT 10.7 ms vs Rust 11.5 ms, Swift 11.8 ms). + ## How to Run Prerequisites: `hyperfine`, `rustc`, `swiftc`, plus a release build of the compiler (`cargo build --release` from the repository root — the script runs it for you). diff --git a/benchmarks/string_building/README.md b/benchmarks/string_building/README.md index a4e74cc..ebc70b5 100644 --- a/benchmarks/string_building/README.md +++ b/benchmarks/string_building/README.md @@ -1,16 +1,16 @@ # String Building Benchmark -**Focus:** Runtime string ABI + eager destruction. Concat over 50,000 iterations (`s = s + "x"` — now spelled identically in Rust and Ryo): every iteration allocates a fresh buffer through `ryo_str_concat` and eagerly frees the previous one at the reassign. This is the direct before/after measure for the packed-`u128` string runtime ABI (commit `7d0a047`, return-by-value replacing the per-call-site out-pointer stack slot) — the ABI decision and its rationale are recorded on `pack_pair` in `runtime/src/lib.rs` and pinned by the `clif_string_ops_use_packed_return_no_stack_slots` integration test. +**Focus:** Runtime string ABI + eager destruction. Concat over 50,000 iterations (`s = s + "x"` — now spelled identically in Rust and Ryo). Historically every iteration allocated a fresh buffer through `ryo_str_concat` and eagerly freed the previous one at the reassign; since 2026-09-14 a provably-consuming reassign-concat appends in place with growth headroom, so the loop is amortized O(n) (see the checkpoint below). This is the direct before/after measure for the packed-`u128` string runtime ABI (commit `7d0a047`, return-by-value replacing the per-call-site out-pointer stack slot) — the ABI decision and its rationale are recorded on `pack_pair` in `runtime/src/lib.rs` and pinned by the `clif_string_ops_use_packed_return_no_stack_slots` integration test. **Languages compared:** Rust, Swift, Ryo (AOT vs JIT), and Python. -## Why Ryo trails here: same source, different allocation policy +## Why Ryo trailed here: same source, different allocation policy The Rust and Ryo arms are now the *identical* program — both are `s = s + "x"` in a loop — so the ~12x gap is entirely runtime semantics, not algorithm choice. Rust's `impl Add<&str> for String` **consumes the left-hand side and reuses its buffer** (documented std behavior): ownership moves into the operator, uniqueness is proven by the type system, and the append happens in place with amortized capacity growth (~17 reallocs total, O(n)). Ryo's `s = s + "x"` calls `ryo_str_concat`, which constructs a **fresh exact-size buffer every iteration**, copies the whole current string into it, and eager destruction frees the old buffer at the reassign. Iteration *i* copies *i* bytes, so the loop copies ~1.25 GB in total — that O(n²) churn is the entire gap, not codegen quality. -The sharper learning (2026-09-11): Ryo doesn't need COW refcounts to close this. The ownership pass already proves statically what Rust's type system proves — at a reassign concat the old binding is dead, and a reassignable `s` provably has no live views — so in-place append is sound for exactly this pattern. What is missing is purely allocation policy: Ryo buffers are always exact-size (`cap == len`, no growth headroom), and concat never attempts to extend the lhs buffer. This is filed as tracked work in `ISSUES.md` (the consuming-concat in-place-append entry, complementing the small-string entry that redesigns the same slot layout): route a provably-consuming `s = s + suffix` through the `__ryo_str_push`-style growth path — realloc-or-extend, copy the suffix only — turning this loop amortized O(n) with no source change. The SSO/COW roadmap work (`docs/dev/implementation_roadmap.md` → *Standard Library Allocation Optimizations*, `docs/dev/stdlib_optimizations.md`) then generalizes the win beyond the consuming case. This benchmark is the tracking measure: the gap should collapse when the entries land. +The sharper learning (2026-09-11): Ryo doesn't need COW refcounts to close this. The ownership pass already proves statically what Rust's type system proves — at a reassign concat the old binding is dead, and a reassignable `s` provably has no live views — so in-place append is sound for exactly this pattern. What was missing was purely allocation policy: Ryo buffers were always exact-size (`cap == len`, no growth headroom), and concat never attempted to extend the lhs buffer. This landed on 2026-09-14: buffers now carry growth headroom, and a provably-consuming `s = s + suffix` routes through the `__ryo_str_push`-style growth path — realloc-or-extend, copy the suffix only — turning this loop amortized O(n) with no source change. The gap collapsed to Rust parity; see the checkpoint below. The SSO/COW roadmap work (`docs/dev/implementation_roadmap.md` → *Standard Library Allocation Optimizations*, `docs/dev/stdlib_optimizations.md`) then generalizes the win beyond the consuming case. -The amortized fast path also already exists explicitly as `str_push(&s, "x")` (capacity growth via `__ryo_str_push`, `runtime/src/lib.rs:382`); this benchmark intentionally measures the concat + eager-free path (the ABI / eager-destruction measure), not the fastest way to build a string in Ryo. +The amortized fast path also exists explicitly as `str_push(&s, "x")` (capacity growth via `__ryo_str_push`, `runtime/src/lib.rs:521`); this benchmark intentionally keeps the `s = s + "x"` spelling — it measured the concat + eager-free path (the ABI / eager-destruction measure) before 2026-09-14 and now measures the provably-consuming in-place append that the same spelling lowers to, not the explicit-push idiom. ## Benchmarks & Performance Results @@ -24,7 +24,21 @@ Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09 | **Ryo (JIT)** | 0.1.0-dev.20260911+f25e95a | 18.6 ms ± 0.4 ms | 12.95x slower | 5.73 MB | | **Python** | 3.14.7 | 36.1 ms ± 1.4 ms | 25.11x slower | 14.75 MB | -Python (CPython 3.14.7) runs the same `s += "x"` loop interpreted; its ~25x gap over Rust is interpreter overhead, and its ~2x gap over Ryo shows the interpreted baseline is slower than Ryo's compiled O(n²) concat even before any allocation-policy fix lands. +Python (CPython 3.14.7) runs the same `s += "x"` loop interpreted; its ~25x gap over Rust is interpreter overhead, and its ~2x gap over Ryo shows the interpreted baseline is slower than Ryo's compiled O(n²) concat even before any allocation-policy fix landed. + +### Checkpoint: SSO + consuming concat (2026-09-14) + +Re-measured after the string-runtime rework landed: `str` is now a tagged 24-byte slot — inline for ≤ 23-byte strings, heap with growth headroom beyond that, static `.rodata` for literals — and a provably-consuming `s = s + suffix` reassign appends in place through the push path (realloc-or-extend, copy the suffix only) instead of allocating a fresh exact-size buffer per iteration. The growing string leaves the inline range almost immediately, so the win here is the consuming-concat half: the loop is now amortized O(n) with no source change. + +| Candidate | Version | Mean time | vs fastest | Max RSS | +|---|---|---|---|---| +| **Rust** | 1.98.0 | 1.5 ms ± 0.2 ms | 1.00x | 1.61 MB | +| **Ryo (AOT)** | 0.1.0-dev.20260914+75d0f1e | 1.6 ms ± 0.6 ms | 1.03x slower | 1.48 MB | +| **Swift** | 6.3.3 | 2.4 ms ± 0.5 ms | 1.56x slower | 1.81 MB | +| **Ryo (JIT)** | 0.1.0-dev.20260914+75d0f1e | 2.6 ms ± 0.1 ms | 1.71x slower | 5.06 MB | +| **Python** | 3.14.7 | 36.4 ms ± 0.4 ms | 24.03x slower | 14.73 MB | + +The ~12x gap is closed: Ryo AOT went from 17.7 ms to 1.6 ms, within noise of Rust (1.5 ms) — parity, as predicted above. Peak RSS dropped 2.25 → 1.48 MB, the lightest arm. A same-day re-run on a busier machine confirmed the ranking (Ryo AOT 1.7 ms vs Rust 1.7 ms). ## How to Run diff --git a/benchmarks/string_slicing/README.md b/benchmarks/string_slicing/README.md index 30fe6ef..a3c738a 100644 --- a/benchmarks/string_slicing/README.md +++ b/benchmarks/string_slicing/README.md @@ -32,6 +32,23 @@ Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09 | **Ryo (AOT)** | 0.1.0-dev.20260911+490b10d | 4.9 ms ± 0.3 ms | 2.94x slower | 2.75 MB | | **Ryo (JIT)** | 0.1.0-dev.20260911+490b10d | 6.5 ms ± 0.4 ms | 3.90x slower | 6.62 MB | +### Checkpoint: SSO + consuming concat (2026-09-14) + +The string-runtime rework moved this benchmark twice, in opposite directions. (1) Promote-on-view landed with an *unconditional* runtime call: every slice of an owner-typed `str` paid a spill + extern `__ryo_str_ensure_heap` + reload to guarantee the base never moves — 4.9 → 5.7 ms across the ~700k-iteration scan loop. (2) A codegen tag-branch then recovered it: view creation now tests the base's inline tag (top byte of the cap word) and only inline bases take the promote call, while heap and static bases pass their pointer/length straight through — 5.7 → 5.1 ms. The ~0.2 ms residual over the pre-rework 4.9 ms is the per-slice tag test itself; closing it folds into the planned tiny-runtime-op inlining work named above (the same mechanism that will inline `__ryo_slice` and `ryo_str_eq`). + +| Candidate | Version | Mean time | vs fastest | Max RSS | +|---|---|---|---|---| +| **Rust** | 1.98.0 | 1.7 ms ± 0.1 ms | 1.00x | 2.88 MB | +| **Swift** | 6.3.3 | 2.7 ms ± 0.1 ms | 1.60x slower | 7.09 MB | +| **Ryo (AOT)** | 0.1.0-dev.20260914+4cef5f9 | 5.1 ms ± 0.2 ms | 3.03x slower | 2.75 MB | +| **Ryo (JIT)** | 0.1.0-dev.20260914+4cef5f9 | 7.4 ms ± 0.7 ms | 4.41x slower | 6.89 MB | + +Measurement note: the Ryo rows are quiet-window means at the tagged commit (three runs each: AOT 5.1 ms ± 0.2, JIT 7.4 ms ± 0.7; full-suite batches under machine load read 5.8–6.0 ms with every arm inflated proportionally). The Rust and Swift rows are from the same-day full-suite run and match their 2026-09-11 values. + +### Known tradeoff: growth headroom on doubling concat (2026-09-15) + +CodSpeed's memory mode flags this benchmark as a **+48.8% peak-allocation regression** (1.0 → 1.5 MB) after the string-runtime rework — with the allocation count unchanged at 14. The arithmetic is exact: the 14 doubling concats (`s = s + s` on a 43-byte seed) now route through the growth path, and `growth_cap` rounds every buffer up to the next power of two, so iteration *i* allocates 64×2^i bytes instead of exactly 43×2^i — and 64/43 = 1.488. This is the cost side of the same policy that makes `s = s + suffix` amortized O(1) in string_building; a doubling concat is the one append pattern where headroom can **never** be reused (the next iteration always needs 2×len, beyond any constant-factor slack), so the slack is pure overhead here. It does not show up in process RSS: 0.5 MB of heap slack sits under the ~2.7 MB process baseline, and Ryo AOT still measures the lowest RSS of the suite. + ## How to Run Prerequisites: `hyperfine`, `rustc`, `swiftc`, plus a release build of the compiler (`cargo build --release` from the repository root — the script runs it for you). diff --git a/benchmarks/struct_records/.gitignore b/benchmarks/struct_records/.gitignore index e2efc6b..44224f2 100644 --- a/benchmarks/struct_records/.gitignore +++ b/benchmarks/struct_records/.gitignore @@ -3,3 +3,4 @@ struct_records_rs struct_records_swift struct_records_go __pycache__/ +.pyscn/ diff --git a/benchmarks/struct_records/README.md b/benchmarks/struct_records/README.md index 27dd44e..84bca11 100644 --- a/benchmarks/struct_records/README.md +++ b/benchmarks/struct_records/README.md @@ -21,7 +21,22 @@ Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09 | **Go** | 1.27.1 | 25.6 ms ± 0.8 ms | 2.17x slower | 9.30 MB | | **Python** | 3.14.7 | 132.2 ms ± 2.1 ms | 11.21x slower | 14.59 MB | -Ryo AOT **beats both Rust and Go** here (20.6 ms vs 24.4 / 25.6 ms). The earlier Ryo arm re-derived the name per round because field-level moves are rejected (E0043); rewriting `birthday` to take the record by `move` and mutate the field in place — the idiomatic Ryo shape — removed the per-round `int_to_str` + concat + alloc/free entirely (36.9 ms → ~20 ms) and unified the checksum across all five languages. What remains is pure aggregate ABI traffic, where Ryo's eager-destruction scheduling and sret returns hold up well; Rust additionally pays `format!` machinery per round, and Go pays its GC twice over — in walltime (write barriers, allocation pacing) and most visibly in memory (9.30 MB RSS vs Ryo's 1.39 MB, the classic GC headroom tax). Swift still wins outright: its small-string optimization keeps every name inline (≤ 10 UTF-8 bytes) and its value copy is cheap — closing that is tracked as the small-string optimization work in `ISSUES.md`. Ryo AOT runs **6.4x faster than Python** with ~10x less memory, at the lightest RSS of the suite. +Ryo AOT **beats both Rust and Go** here (20.6 ms vs 24.4 / 25.6 ms). The earlier Ryo arm re-derived the name per round because field-level moves are rejected (E0043); rewriting `birthday` to take the record by `move` and mutate the field in place — the idiomatic Ryo shape — removed the per-round `int_to_str` + concat + alloc/free entirely (36.9 ms → ~20 ms) and unified the checksum across all five languages. What remains is pure aggregate ABI traffic, where Ryo's eager-destruction scheduling and sret returns hold up well; Rust additionally pays `format!` machinery per round, and Go pays its GC twice over — in walltime (write barriers, allocation pacing) and most visibly in memory (9.30 MB RSS vs Ryo's 1.39 MB, the classic GC headroom tax). Swift still wins outright at this checkpoint: its small-string optimization keeps every name inline (≤ 10 UTF-8 bytes) and its value copy is cheap — Ryo closed exactly that gap on 2026-09-14 (see the checkpoint below). Ryo AOT runs **6.4x faster than Python** with ~10x less memory, at the lightest RSS of the suite. + +### Checkpoint: SSO + consuming concat (2026-09-14) + +Re-measured after the string-runtime rework: `str` is now a tagged 24-byte slot — inline for ≤ 23-byte strings, heap with growth headroom, static `.rodata` for literals. Every `user499999`-style name is at most 10 bytes, so names now live inline inside the record — the per-round heap alloc + free attributed above to the missing small-string optimization is gone. + +| Candidate | Version | Mean time | vs fastest | Max RSS | +|---|---|---|---|---| +| **Ryo (AOT)** | 0.1.0-dev.20260914+75d0f1e | 11.4 ms ± 0.4 ms | 1.00x | 1.36 MB | +| **Swift** | 6.3.3 | 12.1 ms ± 0.5 ms | 1.06x slower | 1.56 MB | +| **Ryo (JIT)** | 0.1.0-dev.20260914+75d0f1e | 13.7 ms ± 0.4 ms | 1.21x slower | 5.30 MB | +| **Rust** | 1.98.0 | 24.8 ms ± 0.6 ms | 2.18x slower | 1.53 MB | +| **Go** | 1.27.1 | 26.0 ms ± 0.4 ms | 2.29x slower | 9.39 MB | +| **Python** | 3.14.7 | 132.1 ms ± 2.6 ms | 11.60x slower | 14.56 MB | + +Ryo AOT went from 20.6 ms (1.74x behind Swift) to 11.4 ms — now the **fastest arm**, ahead of Swift (12.1 ms), at the lightest RSS of the suite. A same-day re-run on a busier machine confirmed the ranking (Ryo AOT 12.3 ms vs Swift 13.4 ms). ## How to Run diff --git a/benchmarks/struct_records_inout/README.md b/benchmarks/struct_records_inout/README.md index aa7fcbc..1009270 100644 --- a/benchmarks/struct_records_inout/README.md +++ b/benchmarks/struct_records_inout/README.md @@ -19,7 +19,22 @@ Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09 | **Go** | 1.27.1 | 25.1 ms ± 0.6 ms | 2.15x slower | 9.92 MB | | **Python** | 3.14.7 | 110.3 ms ± 1.5 ms | 9.42x slower | 14.52 MB | -Read against the sibling suites: Ryo AOT matches its own consuming-update time (20.2 ms here vs 20.6 ms in `struct_records` — a wash, as designed) and again beats Rust and Go. The inout form also beats Ryo's keep-original time (28.7 ms in `struct_records_reuse`) by exactly the clone it avoids — the three suites together price Ryo's record-update vocabulary: in-place mutation ≈ consuming update < duplicate-and-modify. Python improves relative to the reuse suite (9.4x vs 12.4x) because in-place mutation skips its object allocation, though it remains an order of magnitude behind. Swift's lead is unchanged and remains the small-string optimization story (I-171). Ryo AOT runs **5.5x faster than Python** with ~11x less memory, at the lightest RSS of the suite. +Read against the sibling suites: Ryo AOT matches its own consuming-update time (20.2 ms here vs 20.6 ms in `struct_records` — a wash, as designed) and again beats Rust and Go. The inout form also beats Ryo's keep-original time (28.7 ms in `struct_records_reuse`), though the suites are not clone-only comparisons: the reuse arm keeps both records alive and scores twice per round, so the gap prices the avoided clone plus that extra bookkeeping — the three suites together price Ryo's record-update vocabulary: in-place mutation ≈ consuming update < duplicate-and-modify. Python improves relative to the reuse suite (9.4x vs 12.4x) because in-place mutation skips its object allocation, though it remains an order of magnitude behind. Swift's lead at this checkpoint is the small-string optimization story; that optimization shipped on 2026-09-14 (a tagged 24-byte slot: names ≤ 23 bytes live inline in the record, no heap alloc, free a no-op) — the re-checkpoint below shows Ryo AOT taking the lead. Ryo AOT runs **5.5x faster than Python** with ~11x less memory, at the lightest RSS of the suite. + +### Checkpoint: SSO + consuming concat (2026-09-14) + +Re-measured after the string-runtime rework shipped the small-string optimization: `str` is a tagged 24-byte slot — names ≤ 23 bytes live inline inside the record, so the per-round `int_to_str` + field store never touches the heap and the record's drop is a no-op on the inline tag. + +| Candidate | Version | Mean time | vs fastest | Max RSS | +|---|---|---|---|---| +| **Ryo (AOT)** | 0.1.0-dev.20260914+04588a3 | 11.1 ms ± 0.3 ms | 1.00x | 1.36 MB | +| **Swift** | 6.3.3 | 12.3 ms ± 0.8 ms | 1.11x slower | 1.56 MB | +| **Ryo (JIT)** | 0.1.0-dev.20260914+04588a3 | 13.5 ms ± 0.4 ms | 1.22x slower | 5.25 MB | +| **Rust** | 1.98.0 | 25.5 ms ± 2.0 ms | 2.29x slower | 1.53 MB | +| **Go** | 1.27.1 | 25.9 ms ± 0.5 ms | 2.32x slower | 9.23 MB | +| **Python** | 3.14.7 | 112.1 ms ± 1.9 ms | 10.07x slower | 14.55 MB | + +Ryo AOT went from 20.2 ms (1.72x behind Swift) to 11.1 ms — now the **fastest arm**, ahead of Swift (12.3 ms). The suite's design claim now holds at the new level: inout matches the consuming update's post-SSO time (11.1 ms here vs 11.4 ms in `struct_records` — still a wash), so the choice between the two idioms remains free. Ryo AOT runs **10.1x faster than Python** with ~11x less memory, again at the lightest RSS. ## How to Run diff --git a/benchmarks/struct_records_reuse/README.md b/benchmarks/struct_records_reuse/README.md index 70bc10a..b73eb2e 100644 --- a/benchmarks/struct_records_reuse/README.md +++ b/benchmarks/struct_records_reuse/README.md @@ -19,7 +19,22 @@ Measured on **macOS 26.6.2 on a MacBook Pro (Apple M3 Pro, 18 GB RAM)**, 2026-09 | **Rust** | 1.98.0 | 31.6 ms ± 0.8 ms | 2.64x slower | 1.52 MB | | **Python** | 3.14.7 | 148.9 ms ± 2.2 ms | 12.44x slower | 14.61 MB | -The ranking is the cost of sharing, read directly. Swift wins because SSO keeps every name inline — its "clone" never touches the heap. Go is second: no clone at all, just a shared header — paid for in GC headroom (9.80 MB RSS, 7x Ryo's). Ryo and Rust both pay a real per-round alloc + memcpy and land together, Ryo AOT ahead of Rust (28.7 vs 31.6 ms) on the strength of cheaper string formatting; Ryo's deficit here is ergonomic, not runtime — `p.name + ""` *is* the clone, and it performs like one. The gap to Swift/Go is what `shared[T]` (retain instead of copy) or a small-string optimization (I-171) would close. Ryo AOT runs **5.2x faster than Python** with ~11x less memory, again at the lightest RSS of the suite. +The ranking is the cost of sharing, read directly. Swift wins because SSO keeps every name inline — its "clone" never touches the heap. Go is second: no clone at all, just a shared header — paid for in GC headroom (9.80 MB RSS, 7x Ryo's). Ryo and Rust both pay a real per-round alloc + memcpy and land together, Ryo AOT ahead of Rust (28.7 vs 31.6 ms) on the strength of cheaper string formatting; Ryo's deficit here is ergonomic, not runtime — `p.name + ""` *is* the clone, and it performs like one. The gap to Swift/Go at this checkpoint is what `shared[T]` (retain instead of copy) or a small-string optimization would close; the small-string optimization shipped on 2026-09-14 (names ≤ 23 bytes live inline in the record) — the re-checkpoint below shows Ryo AOT jumping past Go and Rust to second behind Swift, so the residual gap is the clone-ergonomics/`shared[T]` story, not string allocation. Ryo AOT runs **5.2x faster than Python** with ~11x less memory, again at the lightest RSS of the suite. + +### Checkpoint: SSO + consuming concat (2026-09-14) + +Re-measured after the string-runtime rework shipped the small-string optimization (tagged 24-byte slot: names ≤ 23 bytes live inline in the record). Here it strikes the manual clone directly: `p.name + ""` on a ≤ 10-byte name is now an inline-to-inline concat that never touches the heap, so Ryo's per-round alloc + memcpy — the cost this suite isolates — is gone. + +| Candidate | Version | Mean time | vs fastest | Max RSS | +|---|---|---|---|---| +| **Swift** | 6.3.3 | 12.2 ms ± 1.4 ms | 1.00x | 1.58 MB | +| **Ryo (AOT)** | 0.1.0-dev.20260914+04588a3 | 14.7 ms ± 0.4 ms | 1.21x slower | 1.36 MB | +| **Ryo (JIT)** | 0.1.0-dev.20260914+04588a3 | 17.6 ms ± 0.4 ms | 1.44x slower | 5.34 MB | +| **Go** | 1.27.1 | 25.8 ms ± 0.4 ms | 2.12x slower | 9.44 MB | +| **Rust** | 1.98.0 | 32.3 ms ± 0.4 ms | 2.65x slower | 1.56 MB | +| **Python** | 3.14.7 | 150.3 ms ± 2.6 ms | 12.32x slower | 14.58 MB | + +Ryo AOT went from 28.7 ms (fourth, 2.40x behind Swift) to 14.7 ms — **second**, ahead of Go (25.8 ms) and Rust (32.3 ms). Swift still leads: its value copy is a plain inline copy with no concat step at all, while Ryo still runs the `p.name + ""` concat machinery (inline, but a copy with length/tag fixups). Closing that residual is the clone-ergonomics story — the `Clone` trait or a `shared[T]` field — not string allocation. Ryo AOT runs **10.2x faster than Python** with ~11x less memory, again at the lightest RSS of the suite. ## How to Run diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index f077eaa..c63c344 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -150,6 +150,99 @@ pub struct RyoStrFat { pub cap: u64, } +/// Inline capacity of the small-string optimization (SSO): strings of +/// at most this many bytes live directly inside the 24-byte slot. +/// 23 >= 20, so every `int_to_str`/`bool_to_str` output is inline. +pub(crate) const INLINE_CAP: usize = 23; + +/// Cap-word tag: the top byte (byte 23, little-endian) discriminates. +/// `0x80 | len` marks an inline string; a top byte of `0x00` is a heap +/// cap (caps stay below 2^56 by construction) or the all-zero static +/// `.rodata` sentinel. +pub(crate) fn inline_tag(len: u64) -> u64 { + debug_assert!(len <= INLINE_CAP as u64); + (0x80 | len) << 56 +} + +pub(crate) fn is_inline(cap: u64) -> bool { + (cap >> 56) & 0x80 != 0 +} + +pub(crate) fn inline_len(cap: u64) -> u64 { + debug_assert!(is_inline(cap)); + (cap >> 56) & 0x7f +} + +/// Write ONLY the tag byte (byte 23) of a slot, marking it inline with +/// the given length. The low 7 bytes of the cap word (offsets 16–22) +/// are inline DATA for strings of length 17–23 — a full-word +/// `(*out).cap = inline_tag(len)` store would zero them and corrupt the +/// string. Every inline retag goes through this helper. +/// +/// # Safety +/// `out` points to a valid 24-byte `RyoStrFat` whose inline data bytes +/// (offsets 0..len) are already initialized. +pub(crate) unsafe fn write_inline_tag(out: *mut RyoStrFat, len: u64) { + debug_assert!(len <= INLINE_CAP as u64); + // SAFETY: caller contract — out is valid for 24 bytes; we touch only + // byte 23, leaving data bytes 0..=22 intact. + unsafe { + (out as *mut u8) + .add(23) + .write((inline_tag(len) >> 56) as u8) + }; +} + +/// Heap capacity policy for producers that want push-ready headroom: +/// next power of two above `min`, floor 16. Matches `__ryo_str_push`'s +/// doubling so a produced buffer grows smoothly. +pub(crate) fn growth_cap(min: u64) -> u64 { + // checked_next_power_of_two returns exactly 2^56 for min in + // [2^55, 2^56), which would set the tag byte — cap the input one + // power lower so caps stay below 2^56 by construction. + debug_assert!(min < (1 << 55), "cap must keep the tag byte clear"); + min.checked_next_power_of_two() + .unwrap_or_else(|| overflow_abort()) + .max(16) +} + +/// Write `bytes` into `out` as a tagged slot: inline when it fits, +/// else a heap allocation with growth headroom. Producer ABI for every +/// slot-out runtime function. +/// +/// # Safety +/// `out` points to a valid, uninitialized `RyoStrFat` (24 bytes). +unsafe fn write_str_slot(out: *mut RyoStrFat, bytes: &[u8]) { + let len = bytes.len(); + if len <= INLINE_CAP { + // SAFETY: out is valid for 24 bytes; len <= 23 fits the inline + // data region (offsets 0..=22). + unsafe { + if len > 0 { + core::ptr::copy_nonoverlapping(bytes.as_ptr(), out as *mut u8, len); + } + // SAFETY: out is valid and its inline data bytes 0..len are + // initialized by the copy above. Byte-23-only tag write: a + // full cap-word store would zero data bytes 16..len when + // len > 16. + write_inline_tag(out, len as u64); + } + } else { + let cap = growth_cap(len as u64); + let buf = ryo_str_alloc(cap); + // SAFETY: buf is freshly allocated for cap >= len bytes; the + // source slice is readable for len bytes; regions do not overlap. + unsafe { + core::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, len); + *out = RyoStrFat { + ptr: buf, + len: len as u64, + cap, + }; + } + } +} + /// Return-value packing for the string-producing runtime functions /// (Phase 0 ABI modernization): `{ptr, len}` is returned as one /// `u128` (lo = ptr, hi = len). @@ -168,10 +261,11 @@ pub struct RyoStrFat { /// /// `cap` is deliberately NOT in the return value: it is derivable at /// the call site — 0 for `ryo_str_from_literal` (the static .rodata -/// sentinel) and `len` for every allocating producer below (none of -/// them over-allocates; `__ryo_str_push` manages growth capacity -/// through its unchanged slot ABI). A producer that ever needs -/// `cap != len` must change this ABI. +/// sentinel) and `len` for the remaining packed-u128 allocating +/// producers (the concats; neither over-allocates, and `__ryo_str_push` +/// manages growth capacity through its unchanged slot ABI). Producers +/// that need `cap != len` — or SSO inline results — use the slot-out +/// ABI (`write_str_slot`) instead. #[inline] fn pack_pair(ptr: *mut u8, len: u64) -> u128 { ((len as u128) << 64) | (ptr as usize as u128) @@ -197,11 +291,14 @@ pub extern "C" fn ryo_str_alloc(cap: u64) -> *mut u8 { } /// # Safety -/// `ptr` must have been returned by `ryo_str_alloc` or `ryo_str_realloc` -/// with the given `cap`, or be null. +/// `ptr` must have been returned by `ryo_str_alloc` or `ryo_str_realloc`, +/// or be null. `cap` is the tagged cap word: an inline (`0x80`-tagged) +/// cap and `cap == 0` (the static `.rodata` sentinel) are both no-ops. #[unsafe(no_mangle)] pub unsafe extern "C" fn ryo_str_free(ptr: *mut u8, cap: u64) { - if ptr.is_null() || cap == 0 { + // Tag check FIRST: for an inline string the ptr word is byte data, + // never a heap pointer — nothing to free. Then the static sentinel. + if is_inline(cap) || ptr.is_null() || cap == 0 { return; } // SAFETY: caller contract — ptr came from ryo_str_alloc/realloc. @@ -210,7 +307,9 @@ pub unsafe extern "C" fn ryo_str_free(ptr: *mut u8, cap: u64) { /// # Safety /// `ptr` must have been returned by `ryo_str_alloc` or `ryo_str_realloc` -/// with the given `old_cap`, or be null. +/// with the given `old_cap`, or be null. `old_cap` must be a heap cap +/// (tag byte clear): this function is not tag-aware and must never be +/// handed an inline slot's tagged cap word. #[unsafe(no_mangle)] pub unsafe extern "C" fn ryo_str_realloc(ptr: *mut u8, old_cap: u64, new_cap: u64) -> *mut u8 { if ptr.is_null() || old_cap == 0 { @@ -230,18 +329,6 @@ pub unsafe extern "C" fn ryo_str_realloc(ptr: *mut u8, old_cap: u64, new_cap: u6 new_ptr } -/// Helper for fixed-string results (nan, inf, etc.): heap-copy `s` and -/// return the packed pair. -fn str_pair_from_bytes(s: &[u8]) -> u128 { - let ptr = ryo_str_alloc(s.len() as u64); - // SAFETY: ptr is freshly allocated for s.len() bytes; s.as_ptr() is - // readable for the same length; the regions do not overlap. - unsafe { - core::ptr::copy_nonoverlapping(s.as_ptr(), ptr, s.len()); - } - pack_pair(ptr, s.len() as u64) -} - fn oom_abort() -> ! { let msg = b"ryo: out of memory\n"; write_all(STDERR_FD, msg.as_ptr(), msg.len()); @@ -277,29 +364,28 @@ pub unsafe fn ryo_str_from_literal(data: *const u8, len: u64) -> u128 { pack_pair(data as *mut u8, len) } -/// Materialize an owned `str` copy from a `strview` (M8.4.1.2). The -/// result owns a fresh heap buffer of exactly `len` bytes; `len == 0` -/// yields the empty `{null, 0}` pair. +/// Materialize an owned `str` copy from a `strview` (M8.4.1.2), written +/// as a tagged slot: inline when `len <= 23`, else a fresh heap buffer +/// with growth headroom. `len == 0` yields the inline-empty slot. /// /// # Safety -/// `ptr` must point to `len` readable bytes — or be null/dangling when -/// `len == 0`. +/// `out` points to a valid, uninitialized `RyoStrFat`. `ptr` must point +/// to `len` readable bytes — or be null/dangling when `len == 0`. #[unsafe(no_mangle)] -pub unsafe fn ryo_str_from_view(ptr: *const u8, len: u64) -> u128 { +pub unsafe extern "C" fn ryo_str_from_view(out: *mut RyoStrFat, ptr: *const u8, len: u64) { if len == 0 { - return pack_pair(core::ptr::null_mut(), 0); + // SAFETY: out is a valid out-slot. + unsafe { write_str_slot(out, b"") }; + return; } let n: usize = len.try_into().unwrap_or_else(|_| overflow_abort()); - let buf = ryo_str_alloc(len); if ptr.is_null() { null_abort(); } - // SAFETY: caller contract — ptr/len describe a readable byte range; - // buf is freshly allocated for len bytes. - unsafe { - core::ptr::copy_nonoverlapping(ptr, buf, n); - } - pack_pair(buf, len) + // SAFETY: caller contract — ptr/len describe a readable byte range. + let bytes = unsafe { core::slice::from_raw_parts(ptr, n) }; + // SAFETY: out is a valid out-slot; `bytes` holds `len` initialized bytes. + unsafe { write_str_slot(out, bytes) }; } fn slice_fail(msg: &str) -> ! { @@ -360,24 +446,56 @@ pub unsafe fn __ryo_slice(ptr: *const u8, len: u64, start: u64, end: u64) -> u12 } /// # Safety -/// `l_ptr` must point to `l_len` readable bytes (or be null/dangling if -/// `l_len == 0`). Same for `r_ptr`/`r_len`. +/// `out` points to a valid, uninitialized `RyoStrFat`. `l_ptr`/`r_ptr` +/// point to `l_len`/`r_len` readable bytes (or are null/dangling when +/// the len is 0). #[unsafe(no_mangle)] -pub unsafe fn ryo_str_concat(l_ptr: *const u8, l_len: u64, r_ptr: *const u8, r_len: u64) -> u128 { +pub unsafe extern "C" fn ryo_str_concat( + out: *mut RyoStrFat, + l_ptr: *const u8, + l_len: u64, + r_ptr: *const u8, + r_len: u64, +) { let total = match l_len.checked_add(r_len) { Some(t) => t, None => overflow_abort(), }; if total == 0 { - return pack_pair(core::ptr::null_mut(), 0); + // SAFETY: out is a valid out-slot. + unsafe { + *out = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + } + }; + return; } let l_sz: usize = l_len.try_into().unwrap_or_else(|_| overflow_abort()); let r_sz: usize = r_len.try_into().unwrap_or_else(|_| overflow_abort()); - let _: usize = total.try_into().unwrap_or_else(|_| overflow_abort()); - let ptr = ryo_str_alloc(total); - // SAFETY: caller contract — the input buffers are valid for reading - // and ptr is freshly allocated for total bytes; the copies do not - // overlap the destination. + if total as usize <= INLINE_CAP { + // Build inline: write both halves into the slot's data region. + // SAFETY: out is valid for 24 bytes; total <= 23 fits inline; + // inputs are readable per the caller contract. + unsafe { + let dst = out as *mut u8; + if l_sz > 0 { + debug_assert!(!l_ptr.is_null()); + core::ptr::copy_nonoverlapping(l_ptr, dst, l_sz); + } + if r_sz > 0 { + debug_assert!(!r_ptr.is_null()); + core::ptr::copy_nonoverlapping(r_ptr, dst.add(l_sz), r_sz); + } + write_inline_tag(out, total); + } + return; + } + let cap = growth_cap(total); + let ptr = ryo_str_alloc(cap); + // SAFETY: ptr is freshly allocated for cap >= total bytes; inputs + // are readable per the caller contract; regions do not overlap. unsafe { if l_sz > 0 { debug_assert!(!l_ptr.is_null()); @@ -387,8 +505,12 @@ pub unsafe fn ryo_str_concat(l_ptr: *const u8, l_len: u64, r_ptr: *const u8, r_l debug_assert!(!r_ptr.is_null()); core::ptr::copy_nonoverlapping(r_ptr, ptr.add(l_sz), r_sz); } + *out = RyoStrFat { + ptr, + len: total, + cap, + }; } - pack_pair(ptr, total) } /// Append `suffix` to the str fat-pointer at `s_ptr`, reallocating if the @@ -414,10 +536,94 @@ pub unsafe extern "C" fn __ryo_str_push( let cur_len = (*s_ptr).len; let cur_cap = (*s_ptr).cap; let add: u64 = suffix_len; + + if is_inline(cur_cap) { + // Inline source: bytes live in the slot itself. The slot's + // `ptr`/`len` words ARE byte data here — the real length + // lives only in the cap-word tag, so new_len must be + // computed from inline_len, never from the len word. + let ilen = inline_len(cur_cap); + let new_len = match ilen.checked_add(add) { + Some(l) => l, + None => overflow_abort(), + }; + if new_len <= INLINE_CAP as u64 { + // SAFETY: slot data region holds ilen bytes; appending + // add bytes stays within INLINE_CAP; suffix readable. + // Disjointness holds even for push(s, s[0:2]): the suffix + // range is within slot bytes [0, ilen) while the + // destination is [ilen, ilen + add). + if add > 0 { + debug_assert!(!suffix_ptr.is_null()); + core::ptr::copy_nonoverlapping( + suffix_ptr, + (s_ptr as *mut u8).add(ilen as usize), + add as usize, + ); + } + // SAFETY: slot data region holds new_len initialized + // bytes; retag touches byte 23 only (a full-word cap + // store would zero data bytes 16–22). + write_inline_tag(s_ptr, new_len); + return; + } + // Promote: copy inline bytes out BEFORE overwriting the slot, + // then fall into a heap buffer with growth headroom. + let mut tmp = [0u8; INLINE_CAP]; + // SAFETY: slot data region holds ilen <= INLINE_CAP bytes; + // tmp is a full INLINE_CAP stack buffer; regions disjoint. + core::ptr::copy_nonoverlapping(s_ptr as *const u8, tmp.as_mut_ptr(), ilen as usize); + let new_cap = growth_cap(new_len); + let nb = ryo_str_alloc(new_cap); + // SAFETY: nb is freshly allocated for new_cap >= new_len + // bytes; tmp holds ilen bytes; regions disjoint. + core::ptr::copy_nonoverlapping(tmp.as_ptr(), nb, ilen as usize); + if add > 0 { + debug_assert!(!suffix_ptr.is_null()); + // SAFETY: suffix readable for add bytes; nb + ilen has + // add bytes of room (new_cap >= new_len = ilen + add); + // regions disjoint per the caller contract. + core::ptr::copy_nonoverlapping(suffix_ptr, nb.add(ilen as usize), add as usize); + } + *s_ptr = RyoStrFat { + ptr: nb, + len: new_len, + cap: new_cap, + }; + return; + } + + // Non-inline sources (heap or the cap==0 static sentinel): the + // len word is a real length. let new_len = match cur_len.checked_add(add) { Some(l) => l, None => overflow_abort(), }; + if cur_cap == 0 && new_len <= INLINE_CAP as u64 { + // Static (.rodata) source, short result: copy off rodata + // into the slot as inline — no heap allocation at all. + // Read the static bytes into a temp BEFORE overwriting. + let mut tmp = [0u8; INLINE_CAP]; + if cur_len > 0 { + debug_assert!(!cur_ptr.is_null()); + core::ptr::copy_nonoverlapping(cur_ptr, tmp.as_mut_ptr(), cur_len as usize); + } + // SAFETY: tmp holds the old bytes; slot data region fits + // new_len <= INLINE_CAP bytes; suffix readable. + core::ptr::copy_nonoverlapping(tmp.as_ptr(), s_ptr as *mut u8, cur_len as usize); + if add > 0 { + debug_assert!(!suffix_ptr.is_null()); + core::ptr::copy_nonoverlapping( + suffix_ptr, + (s_ptr as *mut u8).add(cur_len as usize), + add as usize, + ); + } + // SAFETY: slot data region holds new_len initialized bytes; + // retag touches byte 23 only. + write_inline_tag(s_ptr, new_len); + return; + } // Reuse the current buffer when it already fits; otherwise grow. // Capacity policy: double the old capacity (or fit exactly when @@ -463,6 +669,50 @@ pub unsafe extern "C" fn __ryo_str_push( } } +/// Promote an inline (SSO) string to a heap buffer in place, writing +/// the heap triple back through `s_ptr`. No-op for heap and static +/// (`cap == 0`) strings. Called by codegen before any view-creating op +/// (slice, view conversion) so views always point at memory that never +/// moves — inline bytes live in the slot and would dangle. +/// +/// # Safety +/// `s_ptr` points to a valid tagged `RyoStrFat` owned by the caller. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __ryo_str_ensure_heap(s_ptr: *mut RyoStrFat) { + // SAFETY: s_ptr is a valid tagged slot per the ABI contract. + unsafe { + let cap = (*s_ptr).cap; + if !is_inline(cap) { + return; + } + let len = inline_len(cap) as usize; + // Copy the inline bytes out BEFORE overwriting the slot. + let mut tmp = [0u8; INLINE_CAP]; + core::ptr::copy_nonoverlapping(s_ptr as *const u8, tmp.as_mut_ptr(), len); + let new_cap = growth_cap(len as u64); + let buf = ryo_str_alloc(new_cap); + // SAFETY: buf is freshly allocated for new_cap >= len bytes; + // tmp holds the inline bytes; regions do not overlap. + core::ptr::copy_nonoverlapping(tmp.as_ptr(), buf, len); + *s_ptr = RyoStrFat { + ptr: buf, + len: len as u64, + cap: new_cap, + }; + } +} + +/// Bytes twin of `__ryo_str_ensure_heap` — promotion is +/// representation-only, no UTF-8 concerns. +/// +/// # Safety +/// `s_ptr` points to a valid tagged `RyoStrFat` owned by the caller. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __ryo_bytes_ensure_heap(s_ptr: *mut RyoStrFat) { + // SAFETY: forwarded contract. + unsafe { __ryo_str_ensure_heap(s_ptr) }; +} + /// # Safety /// `a_ptr` must point to `a_len` readable bytes (or be null/dangling if a_len==0). /// Same for `b_ptr`/`b_len`. @@ -485,8 +735,10 @@ pub unsafe extern "C" fn ryo_str_eq( if a_slice == b_slice { 1 } else { 0 } } +/// # Safety +/// `out` points to a valid, uninitialized `RyoStrFat`. #[unsafe(no_mangle)] -pub fn ryo_int_to_str(value: i64) -> u128 { +pub unsafe extern "C" fn ryo_int_to_str(out: *mut RyoStrFat, value: i64) { let mut buf = [0u8; 32]; let negative = value < 0; // Work with unsigned magnitude to handle i64::MIN correctly @@ -511,44 +763,47 @@ pub fn ryo_int_to_str(value: i64) -> u128 { pos -= 1; buf[pos] = b'-'; } - let len = (buf.len() - pos) as u64; - let ptr = ryo_str_alloc(len); - // SAFETY: ptr is newly allocated for len bytes; buf is readable from - // pos onward for len bytes; the regions do not overlap. - unsafe { - core::ptr::copy_nonoverlapping(buf.as_ptr().add(pos), ptr, len as usize); - } - pack_pair(ptr, len) + // SAFETY: out is a valid out-slot; buf[pos..] holds the formatted + // digits (at most 20 bytes, always inline). + unsafe { write_str_slot(out, &buf[pos..]) }; } +/// # Safety +/// `out` points to a valid, uninitialized `RyoStrFat`. #[unsafe(no_mangle)] -pub fn ryo_float_to_str(value: f64) -> u128 { +pub unsafe extern "C" fn ryo_float_to_str(out: *mut RyoStrFat, value: f64) { if value.is_nan() { - return str_pair_from_bytes(b"nan"); + // SAFETY: out is a valid out-slot. + unsafe { write_str_slot(out, b"nan") }; + return; } if value.is_infinite() { - return if value < 0.0 { - str_pair_from_bytes(b"-inf") - } else { - str_pair_from_bytes(b"inf") - }; + // SAFETY: out is a valid out-slot. + unsafe { write_str_slot(out, if value < 0.0 { b"-inf" } else { b"inf" }) }; + return; } let mut buf = ryu::Buffer::new(); - str_pair_from_bytes(buf.format(value).as_bytes()) + // SAFETY: out is a valid out-slot; the ryu buffer holds the + // formatted bytes. + unsafe { write_str_slot(out, buf.format(value).as_bytes()) }; } +/// # Safety +/// `out` points to a valid, uninitialized `RyoStrFat`. #[unsafe(no_mangle)] -pub fn ryo_bool_to_str(value: u8) -> u128 { - str_pair_from_bytes(if value != 0 { b"true" } else { b"false" }) +pub unsafe extern "C" fn ryo_bool_to_str(out: *mut RyoStrFat, value: u8) { + // SAFETY: out is a valid out-slot. + unsafe { write_str_slot(out, if value != 0 { b"true" } else { b"false" }) }; } // ---------- bytes (M8.4.2) ---------- // -// Owned `bytes` buffers mirror the `str` ABI exactly: producers return -// `{ptr, len}` packed in one `u128` (see `pack_pair`), `cap` is derived -// at the call site (0 for literals, len for allocating producers), and -// `__ryo_bytes_push` manages growth through the same 24-byte slot ABI. +// Owned `bytes` buffers mirror the `str` ABI exactly: literals still +// return `{ptr, len}` packed in one `u128` (see `pack_pair`) with `cap` +// derived at the call site, while concat, from_view, and the +// conversions write tagged slots; `__ryo_bytes_push` manages growth +// through the same 24-byte slot ABI. // No UTF-8 invariants anywhere in this family. #[unsafe(no_mangle)] @@ -586,48 +841,79 @@ pub unsafe fn ryo_bytes_from_literal(data: *const u8, len: u64) -> u128 { pack_pair(data as *mut u8, len) } -/// Materialize an owned `bytes` copy from a `bytesview` (M8.4.2). The -/// result owns a fresh heap buffer of exactly `len` bytes; `len == 0` -/// yields the empty `{null, 0}` pair. +/// Materialize an owned `bytes` copy from a `bytesview` (M8.4.2), +/// written as a tagged slot: inline when `len <= 23`, else a fresh heap +/// buffer with growth headroom. `len == 0` yields the inline-empty slot. /// /// # Safety -/// `ptr` must point to `len` readable bytes — or be null/dangling when -/// `len == 0`. +/// `out` points to a valid, uninitialized `RyoStrFat`. `ptr` must point +/// to `len` readable bytes — or be null/dangling when `len == 0`. #[unsafe(no_mangle)] -pub unsafe fn ryo_bytes_from_view(ptr: *const u8, len: u64) -> u128 { +pub unsafe extern "C" fn ryo_bytes_from_view(out: *mut RyoStrFat, ptr: *const u8, len: u64) { if len == 0 { - return pack_pair(core::ptr::null_mut(), 0); + // SAFETY: out is a valid out-slot. + unsafe { write_str_slot(out, b"") }; + return; } let n: usize = len.try_into().unwrap_or_else(|_| overflow_abort()); - let buf = ryo_bytes_alloc(len); debug_assert!(!ptr.is_null()); - // SAFETY: caller contract — ptr/len describe a readable byte range; - // buf is freshly allocated for len bytes. - unsafe { - core::ptr::copy_nonoverlapping(ptr, buf, n); - } - pack_pair(buf, len) + // SAFETY: caller contract — ptr/len describe a readable byte range. + let bytes = unsafe { core::slice::from_raw_parts(ptr, n) }; + // SAFETY: out is a valid out-slot; `bytes` holds `len` initialized bytes. + unsafe { write_str_slot(out, bytes) }; } /// # Safety -/// `l_ptr` must point to `l_len` readable bytes (or be null/dangling if -/// `l_len == 0`). Same for `r_ptr`/`r_len`. +/// `out` points to a valid, uninitialized `RyoStrFat`. `l_ptr`/`r_ptr` +/// point to `l_len`/`r_len` readable bytes (or are null/dangling when +/// the len is 0). #[unsafe(no_mangle)] -pub unsafe fn ryo_bytes_concat(l_ptr: *const u8, l_len: u64, r_ptr: *const u8, r_len: u64) -> u128 { +pub unsafe extern "C" fn ryo_bytes_concat( + out: *mut RyoStrFat, + l_ptr: *const u8, + l_len: u64, + r_ptr: *const u8, + r_len: u64, +) { let total = match l_len.checked_add(r_len) { Some(t) => t, None => overflow_abort(), }; if total == 0 { - return pack_pair(core::ptr::null_mut(), 0); + // SAFETY: out is a valid out-slot. + unsafe { + *out = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + } + }; + return; } let l_sz: usize = l_len.try_into().unwrap_or_else(|_| overflow_abort()); let r_sz: usize = r_len.try_into().unwrap_or_else(|_| overflow_abort()); - let _: usize = total.try_into().unwrap_or_else(|_| overflow_abort()); - let ptr = ryo_bytes_alloc(total); - // SAFETY: caller contract — the input buffers are valid for reading - // and ptr is freshly allocated for total bytes; the copies do not - // overlap the destination. + if total as usize <= INLINE_CAP { + // Build inline: write both halves into the slot's data region. + // SAFETY: out is valid for 24 bytes; total <= 23 fits inline; + // inputs are readable per the caller contract. + unsafe { + let dst = out as *mut u8; + if l_sz > 0 { + debug_assert!(!l_ptr.is_null()); + core::ptr::copy_nonoverlapping(l_ptr, dst, l_sz); + } + if r_sz > 0 { + debug_assert!(!r_ptr.is_null()); + core::ptr::copy_nonoverlapping(r_ptr, dst.add(l_sz), r_sz); + } + write_inline_tag(out, total); + } + return; + } + let cap = growth_cap(total); + let ptr = ryo_bytes_alloc(cap); + // SAFETY: ptr is freshly allocated for cap >= total bytes; inputs + // are readable per the caller contract; regions do not overlap. unsafe { if l_sz > 0 { debug_assert!(!l_ptr.is_null()); @@ -637,8 +923,12 @@ pub unsafe fn ryo_bytes_concat(l_ptr: *const u8, l_len: u64, r_ptr: *const u8, r debug_assert!(!r_ptr.is_null()); core::ptr::copy_nonoverlapping(r_ptr, ptr.add(l_sz), r_sz); } + *out = RyoStrFat { + ptr, + len: total, + cap, + }; } - pack_pair(ptr, total) } /// # Safety @@ -712,64 +1002,67 @@ pub unsafe extern "C" fn __ryo_bytes_index(ptr: *const u8, len: u64, idx: u64) - } /// `bytes.to_str()` backing (M8.4.2 stopgap): validates UTF-8 and -/// returns an owned `str` copy; panics (exit 101) on invalid input -/// until M13 turns the signature into `Utf8Error!str`. +/// writes an owned `str` copy as a tagged slot; panics (exit 101) on +/// invalid input until M13 turns the signature into `Utf8Error!str`. /// /// # Safety -/// `ptr` must point to `len` readable bytes (or be null/dangling when -/// `len == 0`). +/// `out` points to a valid, uninitialized `RyoStrFat`. `ptr` must point +/// to `len` readable bytes (or be null/dangling when `len == 0`). #[unsafe(no_mangle)] -pub unsafe fn __ryo_bytes_to_str(ptr: *const u8, len: u64) -> u128 { +pub unsafe extern "C" fn __ryo_bytes_to_str(out: *mut RyoStrFat, ptr: *const u8, len: u64) { if len == 0 { - return pack_pair(core::ptr::null_mut(), 0); + // SAFETY: out is a valid out-slot. + unsafe { write_str_slot(out, b"") }; + return; } debug_assert!(!ptr.is_null()); + let n: usize = len.try_into().unwrap_or_else(|_| overflow_abort()); // SAFETY: caller contract — ptr/len describe a readable byte range. - let bytes = unsafe { core::slice::from_raw_parts(ptr, len as usize) }; + let bytes = unsafe { core::slice::from_raw_parts(ptr, n) }; if core::str::from_utf8(bytes).is_err() { slice_fail("bytes are not valid UTF-8"); } - let n: usize = len.try_into().unwrap_or_else(|_| overflow_abort()); - let buf = ryo_str_alloc(len); - // SAFETY: buf is freshly allocated for len bytes; regions disjoint. - unsafe { - core::ptr::copy_nonoverlapping(ptr, buf, n); - } - pack_pair(buf, len) + // SAFETY: out is a valid out-slot; `bytes` holds `len` initialized bytes. + unsafe { write_str_slot(out, bytes) }; } -/// `str.to_bytes()` backing (M8.4.2): owned copy of the UTF-8 bytes. -/// Never fails. +/// `str.to_bytes()` backing (M8.4.2): owned copy of the UTF-8 bytes, +/// written as a tagged slot. Never fails. /// /// # Safety -/// `ptr` must point to `len` readable bytes (or be null/dangling when -/// `len == 0`). +/// `out` points to a valid, uninitialized `RyoStrFat`. `ptr` must point +/// to `len` readable bytes (or be null/dangling when `len == 0`). #[unsafe(no_mangle)] -pub unsafe fn __ryo_str_to_bytes(ptr: *const u8, len: u64) -> u128 { +pub unsafe extern "C" fn __ryo_str_to_bytes(out: *mut RyoStrFat, ptr: *const u8, len: u64) { if len == 0 { - return pack_pair(core::ptr::null_mut(), 0); + // SAFETY: out is a valid out-slot. + unsafe { write_str_slot(out, b"") }; + return; } let n: usize = len.try_into().unwrap_or_else(|_| overflow_abort()); - let buf = ryo_bytes_alloc(len); debug_assert!(!ptr.is_null()); - // SAFETY: caller contract; buf is freshly allocated for len bytes. - unsafe { - core::ptr::copy_nonoverlapping(ptr, buf, n); - } - pack_pair(buf, len) + // SAFETY: caller contract — ptr/len describe a readable byte range. + let bytes = unsafe { core::slice::from_raw_parts(ptr, n) }; + // SAFETY: out is a valid out-slot; `bytes` holds `len` initialized bytes. + unsafe { write_str_slot(out, bytes) }; } /// `print(bytes)` backing (M8.4.2): render the escaped repr as a fresh -/// owned `str`. Printable ASCII (0x20..=0x7E except `\` and `"`) is -/// shown literally; the short escapes `\n \t \r \0 \\ \"` are used -/// where they exist; every other byte renders as `\xNN` (lowercase -/// hex); the result is wrapped in `b"..."`. +/// owned `str`, written as a tagged slot. Printable ASCII (0x20..=0x7E +/// except `\` and `"`) is shown literally; the short escapes +/// `\n \t \r \0 \\ \"` are used where they exist; every other byte +/// renders as `\xNN` (lowercase hex); the result is wrapped in `b"..."`. +/// +/// The slot is verbatim heap even when the repr would fit inline: the +/// worst-case buffer is allocated up front and written in place, so the +/// slot reports the real allocation cap (`4*len+3`) rather than routing +/// the tail through a second copy. /// /// # Safety -/// `ptr` must point to `len` readable bytes (or be null/dangling when -/// `len == 0`). +/// `out` points to a valid, uninitialized `RyoStrFat`. `ptr` must point +/// to `len` readable bytes (or be null/dangling when `len == 0`). #[unsafe(no_mangle)] -pub unsafe fn __ryo_bytes_repr(ptr: *const u8, len: u64) -> u128 { +pub unsafe extern "C" fn __ryo_bytes_repr(out: *mut RyoStrFat, ptr: *const u8, len: u64) { let n: usize = len.try_into().unwrap_or_else(|_| overflow_abort()); // Worst case: 3 fixed bytes (`b"`, `"`) + 4 per input byte (`\xNN`). let cap = match len.checked_mul(4).and_then(|m| m.checked_add(3)) { @@ -827,565 +1120,18 @@ pub unsafe fn __ryo_bytes_repr(ptr: *const u8, len: u64) -> u128 { } push(buf, &mut w, b'"'); } - // `cap` is derived at the call site as `len` (LenIsCap); the actual - // allocation is larger, which is harmless — `ryo_str_free` only - // reads `cap == 0` as the static sentinel. - pack_pair(buf, w as u64) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_alloc_and_free() { - unsafe { - let ptr = ryo_str_alloc(16); - assert!(!ptr.is_null()); - ryo_str_free(ptr, 16); - } - } - - #[test] - fn test_alloc_zero_returns_null() { - let ptr = ryo_str_alloc(0); - assert!(ptr.is_null()); - } - - #[test] - fn test_free_null_is_noop() { - unsafe { ryo_str_free(core::ptr::null_mut(), 0) }; - } - - #[test] - fn test_realloc_grow() { - unsafe { - let ptr = ryo_str_alloc(8); - assert!(!ptr.is_null()); - let ptr2 = ryo_str_realloc(ptr, 8, 32); - assert!(!ptr2.is_null()); - ryo_str_free(ptr2, 32); - } - } - - #[test] - fn test_realloc_from_null() { - unsafe { - let ptr = ryo_str_realloc(core::ptr::null_mut(), 0, 16); - assert!(!ptr.is_null()); - ryo_str_free(ptr, 16); - } - } - - #[test] - fn test_realloc_to_zero() { - unsafe { - let ptr = ryo_str_alloc(16); - assert!(!ptr.is_null()); - let ptr2 = ryo_str_realloc(ptr, 16, 0); - assert!(ptr2.is_null()); - } - } - - #[test] - fn test_from_literal_nonempty() { - let data = b"hello"; - // SAFETY: data points to 5 readable bytes. - let pair = unsafe { ryo_str_from_literal(data.as_ptr(), 5) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert_eq!(out_ptr as *const u8, data.as_ptr()); - assert_eq!(out_len, 5); - // cap is 0 by ABI convention (the static sentinel never reaches - // the runtime). - // SAFETY: the pair points into the readable literal bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"hello"); - } - - #[test] - fn test_from_literal_returns_static_pointer() { - let data = b"hello"; - // SAFETY: data points to 5 readable bytes. - let pair = unsafe { ryo_str_from_literal(data.as_ptr(), 5) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert_eq!(out_ptr as *const u8, data.as_ptr()); - assert_eq!(out_len, 5); - // cap is 0 by ABI convention (the static sentinel never reaches - // the runtime). - } - - #[test] - fn test_free_static_str_is_noop() { - let data = b"hello"; - // SAFETY: data points to 5 readable bytes. - let pair = unsafe { ryo_str_from_literal(data.as_ptr(), 5) }; - let (out_ptr, _) = unpack_pair(pair); - // Static sentinel: cap = 0 by ABI convention, so free is a noop. - // SAFETY: out_ptr is a static .rodata pointer freed with cap 0. - unsafe { ryo_str_free(out_ptr, 0) }; - } - - #[test] - fn test_from_literal_empty() { - // SAFETY: len == 0, so the data pointer is never dereferenced. - let pair = unsafe { ryo_str_from_literal(b"".as_ptr(), 0) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert!(out_ptr.is_null()); - assert_eq!(out_len, 0); - // cap is 0 by ABI convention (the static sentinel never reaches - // the runtime). - } - - #[test] - fn test_concat_two_strings() { - // SAFETY: both input buffers are valid for reading. - let pair = unsafe { ryo_str_concat(b"Hello, ".as_ptr(), 7, b"World!".as_ptr(), 6) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert_eq!(out_len, 13); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"Hello, World!"); - // cap == len for allocating producers (codegen-side derivation). - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_concat_empty_left() { - // SAFETY: both input buffers are valid for reading. - let pair = unsafe { ryo_str_concat(b"".as_ptr(), 0, b"abc".as_ptr(), 3) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert_eq!(out_len, 3); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"abc"); - // cap == len for allocating producers (codegen-side derivation). - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_concat_both_empty() { - // SAFETY: len == 0 on both sides, so neither pointer is dereferenced. - let pair = unsafe { ryo_str_concat(core::ptr::null(), 0, core::ptr::null(), 0) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert!(out_ptr.is_null()); - assert_eq!(out_len, 0); - } - - #[test] - fn test_eq_same_content() { - let result = unsafe { ryo_str_eq(b"hello".as_ptr(), 5, b"hello".as_ptr(), 5) }; - assert_eq!(result, 1); - } - - #[test] - fn test_eq_different_content() { - let result = unsafe { ryo_str_eq(b"hello".as_ptr(), 5, b"world".as_ptr(), 5) }; - assert_eq!(result, 0); - } - - #[test] - fn test_eq_both_empty() { - let result = unsafe { ryo_str_eq(core::ptr::null(), 0, core::ptr::null(), 0) }; - assert_eq!(result, 1); - } - - #[test] - fn test_eq_different_lengths() { - let result = unsafe { ryo_str_eq(b"hi".as_ptr(), 2, b"hello".as_ptr(), 5) }; - assert_eq!(result, 0); - } - - #[test] - fn test_int_to_str_positive() { - let (out_ptr, out_len) = unpack_pair(ryo_int_to_str(42)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"42"); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_int_to_str_negative() { - let (out_ptr, out_len) = unpack_pair(ryo_int_to_str(-123)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"-123"); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_int_to_str_zero() { - let (out_ptr, out_len) = unpack_pair(ryo_int_to_str(0)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"0"); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_int_to_str_min() { - let (out_ptr, out_len) = unpack_pair(ryo_int_to_str(i64::MIN)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"-9223372036854775808"); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_float_to_str_nan() { - let (out_ptr, out_len) = unpack_pair(ryo_float_to_str(f64::NAN)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"nan"); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_float_to_str_inf() { - let (out_ptr, out_len) = unpack_pair(ryo_float_to_str(f64::INFINITY)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"inf"); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_float_to_str_neg_inf() { - let (out_ptr, out_len) = unpack_pair(ryo_float_to_str(f64::NEG_INFINITY)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"-inf"); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_float_to_str() { - let (out_ptr, out_len) = unpack_pair(ryo_float_to_str(2.75)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - let s = core::str::from_utf8(slice).unwrap(); - assert!(s.starts_with("2.75"), "got: {}", s); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_float_to_str_large_value() { - // Value larger than u64::MAX — old code would saturate - let (out_ptr, out_len) = unpack_pair(ryo_float_to_str(1.8e19)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - let s = core::str::from_utf8(slice).unwrap(); - let parsed: f64 = s.parse().unwrap(); - assert_eq!(parsed, 1.8e19); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_float_to_str_precision() { - let (out_ptr, out_len) = unpack_pair(ryo_float_to_str(0.1 + 0.2)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - let s = core::str::from_utf8(slice).unwrap(); - let parsed: f64 = s.parse().unwrap(); - assert_eq!(parsed, 0.1 + 0.2); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_bool_to_str_true() { - let (out_ptr, out_len) = unpack_pair(ryo_bool_to_str(1)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"true"); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_bool_to_str_false() { - let (out_ptr, out_len) = unpack_pair(ryo_bool_to_str(0)); - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"false"); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn test_concat_static_left_heap_right() { - unsafe { - // Simulate: "Hello, " + heap_string - let left = b"Hello, "; - let left_fat = RyoStrFat { - ptr: left.as_ptr() as *mut u8, - len: 7, - cap: 0, // static - }; - - // Create a heap string for the right side - let mut right_fat = RyoStrFat { - ptr: core::ptr::null_mut(), - len: 0, - cap: 0, - }; - let right_data = b"World!"; - let right_ptr = ryo_str_alloc(6); - core::ptr::copy_nonoverlapping(right_data.as_ptr(), right_ptr, 6); - right_fat.ptr = right_ptr; - right_fat.len = 6; - right_fat.cap = 6; - - let pair = ryo_str_concat(left_fat.ptr, left_fat.len, right_fat.ptr, right_fat.len); - let (out_ptr, out_len) = unpack_pair(pair); - - assert_eq!(out_len, 13); - // cap == len for allocating producers (codegen-side derivation). - let slice = core::slice::from_raw_parts(out_ptr, out_len as usize); - assert_eq!(slice, b"Hello, World!"); - - // Free: static left is safe (cap=0 → noop), heap right and result freed - ryo_str_free(left_fat.ptr, left_fat.cap); - ryo_str_free(right_fat.ptr, right_fat.cap); - ryo_str_free(out_ptr, out_len); - } - } - - #[test] - fn slice_basic() { - let s = "héllo wörld".as_bytes(); - // "héllo" is 6 bytes (é = 2 bytes) - // SAFETY: s is readable for its byte length; - // the range 0..6 is in-bounds (see above). - let pair = unsafe { __ryo_slice(s.as_ptr(), s.len() as u64, 0, 6) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert_eq!(out_len, 6); - // SAFETY: __ryo_slice returned a valid view into s for out_len bytes. - let got = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(got, "héllo".as_bytes()); - } - - #[test] - fn slice_empty_at_len_is_ok() { - let s = "abc".as_bytes(); - // SAFETY: "abc" provides three readable bytes; - // start == end == len is the empty-at-end case the ABI allows. - let pair = unsafe { __ryo_slice(s.as_ptr(), 3, 3, 3) }; - let (_, out_len) = unpack_pair(pair); - assert_eq!(out_len, 0); - } - - #[test] - fn slice_nonzero_offset() { - let s = "héllo wörld".as_bytes(); - // "wörld" starts at byte 7 (h=1, é=2, "llo "=4) and is 6 bytes - // — exercises the non-zero pointer-offset path. - // SAFETY: s is readable for its byte length; - // the range 7..13 is in-bounds (see above). - let pair = unsafe { __ryo_slice(s.as_ptr(), s.len() as u64, 7, 13) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert_eq!(out_len, 6); - // SAFETY: __ryo_slice returned a valid view into s for out_len bytes. - let got = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(got, "wörld".as_bytes()); - } - - #[test] - fn str_from_view_copies_bytes() { - let src = b"hello"; - // SAFETY: src points to 5 readable bytes. - let pair = unsafe { ryo_str_from_view(src.as_ptr(), 5) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert_eq!(out_len, 5); - // cap == len for allocating producers (codegen-side derivation). - // SAFETY: the pair points to a freshly allocated buffer of out_len bytes. - let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; - assert_eq!(slice, b"hello"); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - unsafe { ryo_str_free(out_ptr, out_len) }; - } - - #[test] - fn str_from_view_buffer_is_independent() { - unsafe { - // Heap-backed source: the copy must own a fresh buffer. - let src = ryo_str_alloc(3); - core::ptr::copy_nonoverlapping(b"abc".as_ptr(), src, 3); - let pair = ryo_str_from_view(src, 3); - let (out_ptr, out_len) = unpack_pair(pair); - assert!( - !core::ptr::eq(out_ptr, src), - "copy must not alias the source" - ); - // Overwrite and free the source; the copy is unaffected. - core::ptr::write_bytes(src, b'x', 3); - ryo_str_free(src, 3); - let slice = core::slice::from_raw_parts(out_ptr, out_len as usize); - assert_eq!(slice, b"abc"); - // SAFETY: out_ptr came from ryo_str_alloc with capacity out_len. - ryo_str_free(out_ptr, out_len); - } - } - - #[test] - fn str_from_view_empty() { - // ptr may be null/dangling when len == 0 (`ryo_str_from_view` invariant). - // SAFETY: len == 0, so the pointer is never dereferenced. - let pair = unsafe { ryo_str_from_view(core::ptr::null(), 0) }; - let (out_ptr, out_len) = unpack_pair(pair); - assert!(out_ptr.is_null()); - assert_eq!(out_len, 0); - } - - #[test] - fn print_smoke_writes_to_stdout() { - // Smoke test only: asserts no crash on the happy path and on the - // len==0 / null-ptr edge. Output bytes themselves are verified - // end-to-end by the compiler integration tests. - unsafe { ryo_print(b"ryo-print-smoke\n".as_ptr(), 16) }; - unsafe { ryo_print(core::ptr::null(), 0) }; - } - - #[test] - fn bytes_concat_combines() { - let a = [0x01u8, 0x02]; - let b = [0x03u8]; - let v = unsafe { ryo_bytes_concat(a.as_ptr(), a.len() as u64, b.as_ptr(), b.len() as u64) }; - let (p, l) = unpack_pair(v); - assert_eq!(l, 3); - let s = unsafe { core::slice::from_raw_parts(p, l as usize) }; - assert_eq!(s, &[0x01, 0x02, 0x03]); - unsafe { ryo_bytes_free(p, l) }; - } - - #[test] - fn bytes_concat_empty_is_null_pair() { - let v = unsafe { ryo_bytes_concat(core::ptr::null(), 0, core::ptr::null(), 0) }; - let (p, l) = unpack_pair(v); - assert!(p.is_null()); - assert_eq!(l, 0); - } - - #[test] - fn bytes_from_view_copies() { - let src = [0xaau8, 0xbb]; - let v = unsafe { ryo_bytes_from_view(src.as_ptr(), src.len() as u64) }; - let (p, l) = unpack_pair(v); - assert_eq!(l, 2); - assert_ne!(p, src.as_ptr() as *mut u8); // independent copy - let s = unsafe { core::slice::from_raw_parts(p, l as usize) }; - assert_eq!(s, &[0xaa, 0xbb]); - unsafe { ryo_bytes_free(p, l) }; - } - - #[test] - fn bytes_slice_returns_subrange() { - let src = [0x01u8, 0x02, 0x03, 0x04]; - let v = unsafe { __ryo_bytes_slice(src.as_ptr(), 4, 1, 3) }; - let (p, l) = unpack_pair(v); - assert_eq!(l, 2); - let s = unsafe { core::slice::from_raw_parts(p, l as usize) }; - assert_eq!(s, &[0x02, 0x03]); - // View into the source — do NOT free. - } - - #[test] - fn bytes_slice_allows_non_char_boundaries() { - // The single behavioral divergence from `__ryo_slice`: no UTF-8 - // boundary check — slicing mid-codepoint is fine for bytes. - let src = "héllo".as_bytes(); // é is two bytes at offsets 1..3 - let v = unsafe { __ryo_bytes_slice(src.as_ptr(), src.len() as u64, 1, 3) }; - let (_, l) = unpack_pair(v); - assert_eq!(l, 2); - } - - #[test] - fn bytes_push_appends_and_grows_from_static() { - let src = [0x01u8]; - let mut fat = RyoStrFat { - ptr: src.as_ptr() as *mut u8, // static cap=0: NOT heap-owned - len: 1, - cap: 0, + // Verbatim heap slot: report the real allocation cap (not `len`) so + // `ryo_str_free` and future growth see the true buffer size. + // SAFETY: out is a valid out-slot; buf is a heap allocation of `cap` + // bytes holding `w` initialized bytes. + unsafe { + *out = RyoStrFat { + ptr: buf, + len: w as u64, + cap, }; - unsafe { __ryo_bytes_push(&mut fat, 0xff) }; - assert_eq!(fat.len, 2); - assert!(fat.cap >= 2); - let s = unsafe { core::slice::from_raw_parts(fat.ptr, fat.len as usize) }; - assert_eq!(s, &[0x01, 0xff]); - unsafe { ryo_bytes_free(fat.ptr, fat.cap) }; - } - - #[test] - fn bytes_index_reads_byte() { - let src = [0x00u8, 0x7f, 0xff]; - for (i, want) in src.iter().enumerate() { - let got = unsafe { __ryo_bytes_index(src.as_ptr(), 3, i as u64) }; - assert_eq!(got, *want as u64); - } - } - - #[test] - fn bytes_eq_compares_contents() { - let a = [0x01u8, 0x02]; - let b = [0x01u8, 0x02]; - let c = [0x01u8, 0x03]; - assert_eq!(unsafe { ryo_bytes_eq(a.as_ptr(), 2, b.as_ptr(), 2) }, 1); - assert_eq!(unsafe { ryo_bytes_eq(a.as_ptr(), 2, c.as_ptr(), 2) }, 0); - assert_eq!(unsafe { ryo_bytes_eq(a.as_ptr(), 1, a.as_ptr(), 2) }, 0); - assert_eq!( - unsafe { ryo_bytes_eq(core::ptr::null(), 0, core::ptr::null(), 0) }, - 1 - ); - } - - #[test] - fn bytes_to_str_copies_valid_utf8() { - let src = "héllo".as_bytes(); - let v = unsafe { __ryo_bytes_to_str(src.as_ptr(), src.len() as u64) }; - let (p, l) = unpack_pair(v); - let s = unsafe { core::slice::from_raw_parts(p, l as usize) }; - assert_eq!(s, "héllo".as_bytes()); - unsafe { ryo_str_free(p, l) }; - } - - #[test] - fn str_to_bytes_copies() { - let src = "héllo".as_bytes(); - let v = unsafe { __ryo_str_to_bytes(src.as_ptr(), src.len() as u64) }; - let (p, l) = unpack_pair(v); - let s = unsafe { core::slice::from_raw_parts(p, l as usize) }; - assert_eq!(s, "héllo".as_bytes()); - unsafe { ryo_bytes_free(p, l) }; - } - - #[test] - fn bytes_repr_escapes() { - // A, NUL, 0xff, newline, '"', '\', '~' (0x7e printable), ESC (0x1b) - let input = [b'A', 0x00, 0xff, b'\n', b'"', b'\\', 0x7e, 0x1b]; - let v = unsafe { __ryo_bytes_repr(input.as_ptr(), input.len() as u64) }; - let (p, l) = unpack_pair(v); - let s = unsafe { core::slice::from_raw_parts(p, l as usize) }; - assert_eq!(s, b"b\"A\\0\\xff\\n\\\"\\\\~\\x1b\""); - unsafe { ryo_str_free(p, l) }; - } - - #[test] - fn bytes_repr_empty() { - let v = unsafe { __ryo_bytes_repr(core::ptr::null(), 0) }; - let (p, l) = unpack_pair(v); - let s = unsafe { core::slice::from_raw_parts(p, l as usize) }; - assert_eq!(s, b"b\"\""); - unsafe { ryo_str_free(p, l) }; } } + +#[cfg(test)] +mod tests; diff --git a/runtime/src/tests.rs b/runtime/src/tests.rs new file mode 100644 index 0000000..8e60ad8 --- /dev/null +++ b/runtime/src/tests.rs @@ -0,0 +1,1008 @@ +use super::*; + +/// Read the byte content of a tagged slot, whether inline (bytes in +/// the slot itself) or heap (bytes at `slot.ptr`). +fn slot_content(slot: &RyoStrFat) -> &[u8] { + if is_inline(slot.cap) { + let len = inline_len(slot.cap) as usize; + // SAFETY: an inline slot holds `len` initialized bytes in its + // data region (offsets 0..len). + unsafe { core::slice::from_raw_parts(slot as *const RyoStrFat as *const u8, len) } + } else { + // SAFETY: a heap slot's ptr is valid for `len` initialized + // bytes (produced by a slot-out runtime function). + unsafe { core::slice::from_raw_parts(slot.ptr, slot.len as usize) } + } +} + +#[test] +fn test_alloc_and_free() { + unsafe { + let ptr = ryo_str_alloc(16); + assert!(!ptr.is_null()); + ryo_str_free(ptr, 16); + } +} + +#[test] +fn test_alloc_zero_returns_null() { + let ptr = ryo_str_alloc(0); + assert!(ptr.is_null()); +} + +#[test] +fn test_free_null_is_noop() { + unsafe { ryo_str_free(core::ptr::null_mut(), 0) }; +} + +#[test] +fn test_realloc_grow() { + unsafe { + let ptr = ryo_str_alloc(8); + assert!(!ptr.is_null()); + let ptr2 = ryo_str_realloc(ptr, 8, 32); + assert!(!ptr2.is_null()); + ryo_str_free(ptr2, 32); + } +} + +#[test] +fn test_realloc_from_null() { + unsafe { + let ptr = ryo_str_realloc(core::ptr::null_mut(), 0, 16); + assert!(!ptr.is_null()); + ryo_str_free(ptr, 16); + } +} + +#[test] +fn test_realloc_to_zero() { + unsafe { + let ptr = ryo_str_alloc(16); + assert!(!ptr.is_null()); + let ptr2 = ryo_str_realloc(ptr, 16, 0); + assert!(ptr2.is_null()); + } +} + +#[test] +fn test_from_literal_nonempty() { + let data = b"hello"; + // SAFETY: data points to 5 readable bytes. + let pair = unsafe { ryo_str_from_literal(data.as_ptr(), 5) }; + let (out_ptr, out_len) = unpack_pair(pair); + assert_eq!(out_ptr as *const u8, data.as_ptr()); + assert_eq!(out_len, 5); + // cap is 0 by ABI convention (the static sentinel never reaches + // the runtime). + // SAFETY: the pair points into the readable literal bytes. + let slice = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; + assert_eq!(slice, b"hello"); +} + +#[test] +fn test_from_literal_returns_static_pointer() { + let data = b"hello"; + // SAFETY: data points to 5 readable bytes. + let pair = unsafe { ryo_str_from_literal(data.as_ptr(), 5) }; + let (out_ptr, out_len) = unpack_pair(pair); + assert_eq!(out_ptr as *const u8, data.as_ptr()); + assert_eq!(out_len, 5); + // cap is 0 by ABI convention (the static sentinel never reaches + // the runtime). +} + +#[test] +fn test_free_static_str_is_noop() { + let data = b"hello"; + // SAFETY: data points to 5 readable bytes. + let pair = unsafe { ryo_str_from_literal(data.as_ptr(), 5) }; + let (out_ptr, _) = unpack_pair(pair); + // Static sentinel: cap = 0 by ABI convention, so free is a noop. + // SAFETY: out_ptr is a static .rodata pointer freed with cap 0. + unsafe { ryo_str_free(out_ptr, 0) }; +} + +#[test] +fn test_from_literal_empty() { + // SAFETY: len == 0, so the data pointer is never dereferenced. + let pair = unsafe { ryo_str_from_literal(b"".as_ptr(), 0) }; + let (out_ptr, out_len) = unpack_pair(pair); + assert!(out_ptr.is_null()); + assert_eq!(out_len, 0); + // cap is 0 by ABI convention (the static sentinel never reaches + // the runtime). +} + +#[test] +fn test_concat_two_strings() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; both input buffers are valid for reading. + unsafe { ryo_str_concat(&mut slot, b"Hello, ".as_ptr(), 7, b"World!".as_ptr(), 6) }; + // 13 bytes fits inline (SSO). + assert!(is_inline(slot.cap)); + assert_eq!(inline_len(slot.cap), 13); + assert_eq!(slot_content(&slot), b"Hello, World!"); +} + +#[test] +fn test_concat_inline_result() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; literals readable for the given lens. + unsafe { ryo_str_concat(&mut slot, b"user".as_ptr(), 4, b"42".as_ptr(), 2) }; + assert!(is_inline(slot.cap)); + assert_eq!(inline_len(slot.cap), 6); + let bytes = unsafe { core::slice::from_raw_parts(&slot as *const RyoStrFat as *const u8, 6) }; + assert_eq!(bytes, b"user42"); +} + +#[test] +fn test_concat_heap_result_has_headroom() { + let l = [b'a'; 20]; + let r = [b'b'; 20]; + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; arrays readable for 20 bytes each. + unsafe { ryo_str_concat(&mut slot, l.as_ptr(), 20, r.as_ptr(), 20) }; + assert!(!is_inline(slot.cap)); + assert_eq!(slot.len, 40); + assert!(slot.cap >= 64, "growth_cap(40) == 64 headroom"); + // SAFETY: heap slot produced above. + unsafe { ryo_str_free(slot.ptr, slot.cap) }; +} + +#[test] +fn test_concat_empty_left() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; both input buffers are valid for reading. + unsafe { ryo_str_concat(&mut slot, b"".as_ptr(), 0, b"abc".as_ptr(), 3) }; + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), b"abc"); +} + +#[test] +fn test_concat_both_empty() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; len == 0 on both sides, so neither + // pointer is dereferenced. + unsafe { ryo_str_concat(&mut slot, core::ptr::null(), 0, core::ptr::null(), 0) }; + assert!(slot.ptr.is_null()); + assert_eq!(slot.len, 0); + assert_eq!(slot.cap, 0); +} + +#[test] +fn test_eq_same_content() { + let result = unsafe { ryo_str_eq(b"hello".as_ptr(), 5, b"hello".as_ptr(), 5) }; + assert_eq!(result, 1); +} + +#[test] +fn test_eq_different_content() { + let result = unsafe { ryo_str_eq(b"hello".as_ptr(), 5, b"world".as_ptr(), 5) }; + assert_eq!(result, 0); +} + +#[test] +fn test_eq_both_empty() { + let result = unsafe { ryo_str_eq(core::ptr::null(), 0, core::ptr::null(), 0) }; + assert_eq!(result, 1); +} + +#[test] +fn test_eq_different_lengths() { + let result = unsafe { ryo_str_eq(b"hi".as_ptr(), 2, b"hello".as_ptr(), 5) }; + assert_eq!(result, 0); +} + +#[test] +fn test_int_to_str_positive() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_int_to_str(&mut slot, 42) }; + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), b"42"); +} + +#[test] +fn test_int_to_str_negative() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_int_to_str(&mut slot, -123) }; + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), b"-123"); +} + +#[test] +fn test_int_to_str_zero() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_int_to_str(&mut slot, 0) }; + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), b"0"); +} + +#[test] +fn test_int_to_str_min() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_int_to_str(&mut slot, i64::MIN) }; + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), b"-9223372036854775808"); +} + +#[test] +fn test_int_to_str_inline() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_int_to_str(&mut slot, -9223372036854775808) }; // 20 chars: max + assert!(is_inline(slot.cap)); + assert_eq!(inline_len(slot.cap), 20); + // SAFETY: the inline slot data region holds 20 initialized bytes. + let bytes = unsafe { core::slice::from_raw_parts(&slot as *const RyoStrFat as *const u8, 20) }; + assert_eq!(bytes, b"-9223372036854775808"); +} + +#[test] +fn test_float_to_str_nan() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_float_to_str(&mut slot, f64::NAN) }; + assert_eq!(slot_content(&slot), b"nan"); +} + +#[test] +fn test_float_to_str_inf() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_float_to_str(&mut slot, f64::INFINITY) }; + assert_eq!(slot_content(&slot), b"inf"); +} + +#[test] +fn test_float_to_str_neg_inf() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_float_to_str(&mut slot, f64::NEG_INFINITY) }; + assert_eq!(slot_content(&slot), b"-inf"); +} + +#[test] +fn test_float_to_str() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_float_to_str(&mut slot, 2.75) }; + let s = core::str::from_utf8(slot_content(&slot)).unwrap(); + assert!(s.starts_with("2.75"), "got: {}", s); +} + +#[test] +fn test_float_to_str_large_value() { + // Value larger than u64::MAX — old code would saturate + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_float_to_str(&mut slot, 1.8e19) }; + let s = core::str::from_utf8(slot_content(&slot)).unwrap(); + let parsed: f64 = s.parse().unwrap(); + assert_eq!(parsed, 1.8e19); +} + +#[test] +fn test_float_to_str_precision() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_float_to_str(&mut slot, 0.1 + 0.2) }; + let s = core::str::from_utf8(slot_content(&slot)).unwrap(); + let parsed: f64 = s.parse().unwrap(); + assert_eq!(parsed, 0.1 + 0.2); +} + +#[test] +fn test_bool_to_str_true() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_bool_to_str(&mut slot, 1) }; + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), b"true"); +} + +#[test] +fn test_bool_to_str_false() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { ryo_bool_to_str(&mut slot, 0) }; + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), b"false"); +} + +#[test] +fn test_concat_static_left_heap_right() { + unsafe { + // Simulate: "Hello, " + heap_string + let left = b"Hello, "; + let left_fat = RyoStrFat { + ptr: left.as_ptr() as *mut u8, + len: 7, + cap: 0, // static + }; + + // Create a heap string for the right side + let mut right_fat = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + let right_data = b"World!"; + let right_ptr = ryo_str_alloc(6); + core::ptr::copy_nonoverlapping(right_data.as_ptr(), right_ptr, 6); + right_fat.ptr = right_ptr; + right_fat.len = 6; + right_fat.cap = 6; + + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + ryo_str_concat( + &mut slot, + left_fat.ptr, + left_fat.len, + right_fat.ptr, + right_fat.len, + ); + + assert_eq!(slot_content(&slot), b"Hello, World!"); + + // Free: static left is safe (cap=0 → noop), heap right freed; + // the 13-byte inline result needs no free. + ryo_str_free(left_fat.ptr, left_fat.cap); + ryo_str_free(right_fat.ptr, right_fat.cap); + } +} + +#[test] +fn slice_basic() { + let s = "héllo wörld".as_bytes(); + // "héllo" is 6 bytes (é = 2 bytes) + // SAFETY: s is readable for its byte length; + // the range 0..6 is in-bounds (see above). + let pair = unsafe { __ryo_slice(s.as_ptr(), s.len() as u64, 0, 6) }; + let (out_ptr, out_len) = unpack_pair(pair); + assert_eq!(out_len, 6); + // SAFETY: __ryo_slice returned a valid view into s for out_len bytes. + let got = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; + assert_eq!(got, "héllo".as_bytes()); +} + +#[test] +fn slice_empty_at_len_is_ok() { + let s = "abc".as_bytes(); + // SAFETY: "abc" provides three readable bytes; + // start == end == len is the empty-at-end case the ABI allows. + let pair = unsafe { __ryo_slice(s.as_ptr(), 3, 3, 3) }; + let (_, out_len) = unpack_pair(pair); + assert_eq!(out_len, 0); +} + +#[test] +fn slice_nonzero_offset() { + let s = "héllo wörld".as_bytes(); + // "wörld" starts at byte 7 (h=1, é=2, "llo "=4) and is 6 bytes + // — exercises the non-zero pointer-offset path. + // SAFETY: s is readable for its byte length; + // the range 7..13 is in-bounds (see above). + let pair = unsafe { __ryo_slice(s.as_ptr(), s.len() as u64, 7, 13) }; + let (out_ptr, out_len) = unpack_pair(pair); + assert_eq!(out_len, 6); + // SAFETY: __ryo_slice returned a valid view into s for out_len bytes. + let got = unsafe { core::slice::from_raw_parts(out_ptr, out_len as usize) }; + assert_eq!(got, "wörld".as_bytes()); +} + +#[test] +fn str_from_view_copies_bytes() { + let src = b"hello"; + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; src points to 5 readable bytes. + unsafe { ryo_str_from_view(&mut slot, src.as_ptr(), 5) }; + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), b"hello"); +} + +#[test] +fn str_from_view_buffer_is_independent() { + unsafe { + // Heap-backed source (> INLINE_CAP so the copy is heap too): + // the copy must own a fresh buffer. + let src = ryo_str_alloc(30); + core::ptr::copy_nonoverlapping(b"abcdefghijklmnopqrstuvwxyzabcd".as_ptr(), src, 30); + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; src is readable for 30 bytes. + ryo_str_from_view(&mut slot, src, 30); + assert!(!is_inline(slot.cap)); + assert!( + !core::ptr::eq(slot.ptr, src), + "copy must not alias the source" + ); + // Overwrite and free the source; the copy is unaffected. + core::ptr::write_bytes(src, b'x', 30); + ryo_str_free(src, 30); + assert_eq!(slot_content(&slot), b"abcdefghijklmnopqrstuvwxyzabcd"); + // SAFETY: heap slot produced above; cap is its allocation size. + ryo_str_free(slot.ptr, slot.cap); + } +} + +#[test] +fn str_from_view_empty() { + // ptr may be null/dangling when len == 0 (`ryo_str_from_view` invariant). + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; len == 0, so the pointer is never + // dereferenced. + unsafe { ryo_str_from_view(&mut slot, core::ptr::null(), 0) }; + assert!(is_inline(slot.cap)); + assert_eq!(inline_len(slot.cap), 0); +} + +#[test] +fn test_from_view_inline_and_heap() { + let mut small = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; source literal readable for 5 bytes. + unsafe { ryo_str_from_view(&mut small, b"hello".as_ptr(), 5) }; + assert!(is_inline(small.cap)); + let long = [b'y'; 40]; + let mut big = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; `long` readable for 40 bytes. + unsafe { ryo_str_from_view(&mut big, long.as_ptr(), 40) }; + assert!(!is_inline(big.cap)); + assert_eq!(big.len, 40); + // SAFETY: heap slot produced above. + unsafe { ryo_str_free(big.ptr, big.cap) }; +} + +#[test] +fn print_smoke_writes_to_stdout() { + // Smoke test only: asserts no crash on the happy path and on the + // len==0 / null-ptr edge. Output bytes themselves are verified + // end-to-end by the compiler integration tests. + unsafe { ryo_print(b"ryo-print-smoke\n".as_ptr(), 16) }; + unsafe { ryo_print(core::ptr::null(), 0) }; +} + +#[test] +fn bytes_concat_combines() { + let a = [0x01u8, 0x02]; + let b = [0x03u8]; + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; a/b are readable for their lengths. + unsafe { + ryo_bytes_concat( + &mut slot, + a.as_ptr(), + a.len() as u64, + b.as_ptr(), + b.len() as u64, + ) + }; + // 3 bytes fits inline (SSO). + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), &[0x01, 0x02, 0x03]); +} + +#[test] +fn bytes_concat_empty_is_empty_static() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; len == 0 on both sides, so neither + // pointer is dereferenced. + unsafe { ryo_bytes_concat(&mut slot, core::ptr::null(), 0, core::ptr::null(), 0) }; + assert!(slot.ptr.is_null()); + assert_eq!(slot.len, 0); + assert_eq!(slot.cap, 0); +} + +#[test] +fn bytes_from_view_copies() { + // Small result lands inline. + let src = [0xaau8, 0xbb]; + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; src is readable for 2 bytes. + unsafe { ryo_bytes_from_view(&mut slot, src.as_ptr(), src.len() as u64) }; + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), &[0xaa, 0xbb]); + + // Large result is an independent heap copy. + let big = [0xccu8; 30]; + let mut big_slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; `big` is readable for 30 bytes. + unsafe { ryo_bytes_from_view(&mut big_slot, big.as_ptr(), big.len() as u64) }; + assert!(!is_inline(big_slot.cap)); + assert_ne!(big_slot.ptr, big.as_ptr() as *mut u8); // independent copy + assert_eq!(slot_content(&big_slot), &big); + // SAFETY: heap slot produced above; cap is its allocation size. + unsafe { ryo_bytes_free(big_slot.ptr, big_slot.cap) }; +} + +#[test] +fn bytes_slice_returns_subrange() { + let src = [0x01u8, 0x02, 0x03, 0x04]; + let v = unsafe { __ryo_bytes_slice(src.as_ptr(), 4, 1, 3) }; + let (p, l) = unpack_pair(v); + assert_eq!(l, 2); + let s = unsafe { core::slice::from_raw_parts(p, l as usize) }; + assert_eq!(s, &[0x02, 0x03]); + // View into the source — do NOT free. +} + +#[test] +fn bytes_slice_allows_non_char_boundaries() { + // The single behavioral divergence from `__ryo_slice`: no UTF-8 + // boundary check — slicing mid-codepoint is fine for bytes. + let src = "héllo".as_bytes(); // é is two bytes at offsets 1..3 + let v = unsafe { __ryo_bytes_slice(src.as_ptr(), src.len() as u64, 1, 3) }; + let (_, l) = unpack_pair(v); + assert_eq!(l, 2); +} + +#[test] +fn test_push_inline_fits_no_alloc() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { write_str_slot(&mut slot, b"abc") }; + // SAFETY: slot is a valid tagged string; suffix readable for 3 bytes. + unsafe { __ryo_str_push(&mut slot, b"def".as_ptr(), 3) }; + assert!(is_inline(slot.cap)); + assert_eq!(inline_len(slot.cap), 6); + // SAFETY: an inline slot holds inline_len initialized bytes in + // its data region (offsets 0..6). + let bytes = unsafe { core::slice::from_raw_parts(&slot as *const RyoStrFat as *const u8, 6) }; + assert_eq!(bytes, b"abcdef"); +} + +#[test] +fn test_push_inline_promotes_on_overflow() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { write_str_slot(&mut slot, b"abcdefghijklmnopqrstuvw") }; // 23 + // SAFETY: slot valid; suffix readable for 1 byte. + unsafe { __ryo_str_push(&mut slot, b"x".as_ptr(), 1) }; + assert!(!is_inline(slot.cap)); + assert_eq!(slot.len, 24); + assert!(slot.cap >= 32); + // SAFETY: heap slot produced above; ptr valid for len bytes. + let bytes = unsafe { core::slice::from_raw_parts(slot.ptr, 24) }; + assert_eq!(bytes, b"abcdefghijklmnopqrstuvwx"); + // SAFETY: heap slot produced above. + unsafe { ryo_str_free(slot.ptr, slot.cap) }; +} + +#[test] +fn test_push_static_short_goes_inline() { + let mut slot = RyoStrFat { + ptr: b"lit" as *const u8 as *mut u8, // .rodata stand-in + len: 3, + cap: 0, + }; + // SAFETY: slot valid; suffix readable for 2 bytes. + unsafe { __ryo_str_push(&mut slot, b"!!".as_ptr(), 2) }; + assert!(is_inline(slot.cap)); + assert_eq!(inline_len(slot.cap), 5); + // SAFETY: an inline slot holds inline_len initialized bytes in + // its data region (offsets 0..5). + let bytes = unsafe { core::slice::from_raw_parts(&slot as *const RyoStrFat as *const u8, 5) }; + assert_eq!(bytes, b"lit!!"); +} + +#[test] +fn test_push_inline_high_len_word_bytes_promote_no_abort() { + // 23-byte inline bytes value with 0xFF at offsets 8..16: the + // slot's len word reads as u64::MAX, so a checked_add on it + // (instead of on the tag's inline_len) would overflow_abort a + // perfectly legal append. + let src = [0xffu8; 23]; + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; src readable for 23 bytes. + unsafe { write_str_slot(&mut slot, &src) }; + // SAFETY: slot is a valid tagged string; suffix readable for 1 byte. + unsafe { __ryo_str_push(&mut slot, b"\x01".as_ptr(), 1) }; + assert!(!is_inline(slot.cap)); + assert_eq!(slot.len, 24); + assert!(slot.cap >= 32); + // SAFETY: heap slot produced above; ptr valid for len bytes. + let bytes = unsafe { core::slice::from_raw_parts(slot.ptr, 24) }; + assert_eq!(&bytes[..23], &[0xffu8; 23]); + assert_eq!(bytes[23], 0x01); + // SAFETY: heap slot produced above; cap is its allocation size. + unsafe { ryo_str_free(slot.ptr, slot.cap) }; +} + +#[test] +fn bytes_push_appends_and_grows_from_static() { + let src = [0x01u8]; + let mut fat = RyoStrFat { + ptr: src.as_ptr() as *mut u8, // static cap=0: NOT heap-owned + len: 1, + cap: 0, + }; + // SAFETY: slot valid; byte appended rides __ryo_str_push. + unsafe { __ryo_bytes_push(&mut fat, 0xff) }; + // Short static append stays off-heap: the result goes inline. + assert!(is_inline(fat.cap)); + assert_eq!(inline_len(fat.cap), 2); + assert_eq!(slot_content(&fat), &[0x01, 0xff]); +} + +#[test] +fn bytes_push_static_overflow_goes_heap() { + // Static source whose result exceeds INLINE_CAP still takes the + // explicit-copy heap path. + let src = [0x2au8; 30]; + let mut fat = RyoStrFat { + ptr: src.as_ptr() as *mut u8, // static cap=0: NOT heap-owned + len: 30, + cap: 0, + }; + // SAFETY: slot valid; byte appended rides __ryo_str_push. + unsafe { __ryo_bytes_push(&mut fat, 0xff) }; + assert!(!is_inline(fat.cap)); + assert_eq!(fat.len, 31); + assert!(fat.cap >= 31); + // SAFETY: heap slot produced above; ptr valid for len bytes. + let s = unsafe { core::slice::from_raw_parts(fat.ptr, fat.len as usize) }; + assert_eq!(&s[..30], &[0x2au8; 30]); + assert_eq!(s[30], 0xff); + // SAFETY: heap slot produced above; cap is its allocation size. + unsafe { ryo_bytes_free(fat.ptr, fat.cap) }; +} + +#[test] +fn bytes_index_reads_byte() { + let src = [0x00u8, 0x7f, 0xff]; + for (i, want) in src.iter().enumerate() { + let got = unsafe { __ryo_bytes_index(src.as_ptr(), 3, i as u64) }; + assert_eq!(got, *want as u64); + } +} + +#[test] +fn bytes_eq_compares_contents() { + let a = [0x01u8, 0x02]; + let b = [0x01u8, 0x02]; + let c = [0x01u8, 0x03]; + assert_eq!(unsafe { ryo_bytes_eq(a.as_ptr(), 2, b.as_ptr(), 2) }, 1); + assert_eq!(unsafe { ryo_bytes_eq(a.as_ptr(), 2, c.as_ptr(), 2) }, 0); + assert_eq!(unsafe { ryo_bytes_eq(a.as_ptr(), 1, a.as_ptr(), 2) }, 0); + assert_eq!( + unsafe { ryo_bytes_eq(core::ptr::null(), 0, core::ptr::null(), 0) }, + 1 + ); +} + +#[test] +fn bytes_to_str_copies_valid_utf8() { + let src = "héllo".as_bytes(); + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; src is readable for its byte length. + unsafe { __ryo_bytes_to_str(&mut slot, src.as_ptr(), src.len() as u64) }; + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), "héllo".as_bytes()); +} + +#[test] +fn str_to_bytes_copies() { + let src = "héllo".as_bytes(); + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; src is readable for its byte length. + unsafe { __ryo_str_to_bytes(&mut slot, src.as_ptr(), src.len() as u64) }; + assert!(is_inline(slot.cap)); + assert_eq!(slot_content(&slot), "héllo".as_bytes()); +} + +#[test] +fn bytes_repr_escapes() { + // A, NUL, 0xff, newline, '"', '\', '~' (0x7e printable), ESC (0x1b) + let input = [b'A', 0x00, 0xff, b'\n', b'"', b'\\', 0x7e, 0x1b]; + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; input is readable for its byte length. + unsafe { __ryo_bytes_repr(&mut slot, input.as_ptr(), input.len() as u64) }; + assert_eq!(slot_content(&slot), b"b\"A\\0\\xff\\n\\\"\\\\~\\x1b\""); + // The verbatim-heap slot reports its real allocation cap, which + // covers the written length (fixes the old LenIsCap under-report). + assert!(!is_inline(slot.cap)); + assert!(slot.cap >= slot.len); + // SAFETY: heap slot produced above; cap is its allocation size. + unsafe { ryo_str_free(slot.ptr, slot.cap) }; +} + +#[test] +fn bytes_repr_empty() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot; len == 0, so the pointer is never + // dereferenced. + unsafe { __ryo_bytes_repr(&mut slot, core::ptr::null(), 0) }; + assert_eq!(slot_content(&slot), b"b\"\""); + assert!(slot.cap >= slot.len); + // SAFETY: heap slot produced above; cap is its allocation size. + unsafe { ryo_str_free(slot.ptr, slot.cap) }; +} + +#[test] +fn test_inline_tag_roundtrip() { + for len in 0..=INLINE_CAP as u64 { + let cap = inline_tag(len); + assert!(is_inline(cap)); + assert_eq!(inline_len(cap), len); + } + // Heap caps (top byte clear) and the static sentinel are never inline. + assert!(!is_inline(0)); + assert!(!is_inline(16)); + assert!(!is_inline(u64::MAX >> 8)); // 2^56-1: max legal heap cap +} + +#[test] +fn test_write_str_slot_inline() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + let bytes = b"hello ryo sso"; // 13 bytes + // SAFETY: slot is a valid 24-byte out-slot. + unsafe { write_str_slot(&mut slot, bytes) }; + assert!(is_inline(slot.cap)); + assert_eq!(inline_len(slot.cap), bytes.len() as u64); + // Byte content lives in the slot's first `len` bytes. + // SAFETY: the slot data region holds bytes.len() initialized bytes. + let stored = + unsafe { core::slice::from_raw_parts(&slot as *const RyoStrFat as *const u8, bytes.len()) }; + assert_eq!(stored, bytes); +} + +#[test] +fn test_write_str_slot_heap_at_boundary() { + let bytes = [b'x'; INLINE_CAP + 1]; // 24 bytes: one past inline capacity + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: slot is a valid out-slot; result is heap and freed below. + unsafe { write_str_slot(&mut slot, &bytes) }; + assert!(!is_inline(slot.cap)); + assert_eq!(slot.len, 24); + assert!(slot.cap >= 24); // headroom allowed, exact fit allowed + // SAFETY: slot.ptr points to slot.cap (>= 24) initialized bytes. + let stored = unsafe { core::slice::from_raw_parts(slot.ptr, 24) }; + assert_eq!(stored, &bytes); + // SAFETY: heap slot produced above; cap is its allocation size. + unsafe { ryo_str_free(slot.ptr, slot.cap) }; +} + +#[test] +fn test_growth_cap_policy() { + assert_eq!(growth_cap(1), 16); + assert_eq!(growth_cap(16), 16); + assert_eq!(growth_cap(17), 32); + assert_eq!(growth_cap(1000), 1024); +} + +#[test] +fn test_write_str_slot_inline_boundary_sweep() { + // Every inline length 0..=23, verifying ALL len bytes survive — + // lengths 17..=23 overlap the cap word's low bytes, which only a + // byte-23-only tag write preserves. + for len in 0..=INLINE_CAP { + let bytes = vec![b'a' + (len % 26) as u8; len]; + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: slot is a valid 24-byte out-slot. + unsafe { write_str_slot(&mut slot, &bytes) }; + assert!(is_inline(slot.cap), "len {len} must be inline"); + assert_eq!(inline_len(slot.cap), len as u64); + // SAFETY: slot data region holds len initialized bytes. + let stored = + unsafe { core::slice::from_raw_parts(&slot as *const RyoStrFat as *const u8, len) }; + assert_eq!(stored, &bytes[..], "len {len} content corrupted"); + } +} + +#[test] +fn test_ensure_heap_promotes_inline() { + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { write_str_slot(&mut slot, b"slice me please") }; + // SAFETY: slot is a valid tagged RyoStrFat. + unsafe { __ryo_str_ensure_heap(&mut slot) }; + assert!(!is_inline(slot.cap)); + assert_eq!(slot.len, 15); + assert!(slot.cap >= 16); // growth headroom + // SAFETY: slot.ptr points to slot.cap (>= 15) initialized bytes. + let bytes = unsafe { core::slice::from_raw_parts(slot.ptr, 15) }; + assert_eq!(bytes, b"slice me please"); + // SAFETY: heap slot produced above. + unsafe { ryo_str_free(slot.ptr, slot.cap) }; +} + +#[test] +fn test_ensure_heap_noop_for_heap_and_static() { + // Heap: allocated triple passes through untouched. + let p = ryo_str_alloc(32); + let mut heap = RyoStrFat { + ptr: p, + len: 5, + cap: 32, + }; + // SAFETY: heap is a valid tagged slot. + unsafe { __ryo_str_ensure_heap(&mut heap) }; + assert_eq!(heap.ptr, p); + assert_eq!(heap.cap, 32); + // Static: cap == 0 sentinel is not inline — untouched. + let mut st = RyoStrFat { + ptr: p, + len: 5, + cap: 0, + }; + // SAFETY: st is a valid tagged slot. + unsafe { __ryo_str_ensure_heap(&mut st) }; + assert_eq!(st.cap, 0); + // SAFETY: p came from ryo_str_alloc(32). + unsafe { ryo_str_free(p, 32) }; +} + +#[test] +fn test_free_inline_str_is_noop() { + // An inline slot's ptr word is byte data, NOT a heap pointer; + // free must no-op on it without dereferencing or calling c_free. + let mut slot = RyoStrFat { + ptr: core::ptr::null_mut(), + len: 0, + cap: 0, + }; + // SAFETY: valid out-slot. + unsafe { write_str_slot(&mut slot, b"short") }; + // SAFETY: tagged inline slot; free must recognize the tag. + unsafe { ryo_str_free(slot.ptr, slot.cap) }; + assert!(is_inline(slot.cap)); // slot untouched +} diff --git a/ryo-backend/src/codegen/bytes.rs b/ryo-backend/src/codegen/bytes.rs index f77bea3..e90f59e 100644 --- a/ryo-backend/src/codegen/bytes.rs +++ b/ryo-backend/src/codegen/bytes.rs @@ -1,7 +1,7 @@ //! Bytes codegen (M8.4.2) — split from `expr.rs` to keep both files //! under the 2000-line CI cap (`scripts/check_file_length.sh`). //! Everything here mirrors the `str` path: same 24-byte fat-pointer -//! ABI, same packed-u128 producer convention, `ryo_bytes_*` symbols. +//! ABI, same packed-u128 literal convention, `ryo_bytes_*` symbols. //! Also hosts the shared `.rodata` dedup helpers (`store_string` / //! `store_bytes`), displaced from `mod.rs` by the same cap. @@ -29,7 +29,6 @@ impl Codegen { let (ptr, len) = Self::emit_rv_pair_call(builder, ctx, fn_name, args)?; let cap = match cap_rule { CapRule::Static => builder.ins().iconst(types::I64, 0), - CapRule::LenIsCap => len, }; Ok(ValueRepr::Bytes { ptr, len, cap }) } diff --git a/ryo-backend/src/codegen/expr.rs b/ryo-backend/src/codegen/expr.rs index b74b9b4..d00f039 100644 --- a/ryo-backend/src/codegen/expr.rs +++ b/ryo-backend/src/codegen/expr.rs @@ -3,8 +3,8 @@ use super::arith::{DIV_OVERFLOW_MSG, DIV_ZERO_MSG, MOD_OVERFLOW_MSG, MOD_ZERO_MSG}; use super::bytes::store_string; use super::{ - Codegen, FunctionContext, OVERFLOW_MSG, STR_SLOT_SIZE, ValueRepr, cranelift_type_for, - is_fat_type, ranges, + Codegen, FunctionContext, OVERFLOW_MSG, STR_SLOT_SIZE, Terminator, ValueRepr, + cranelift_type_for, is_fat_type, ranges, }; use cranelift::codegen::ir::{ BlockArg, FuncRef, InstructionData, MemFlagsData, Opcode, StackSlot, ValueDef, @@ -21,14 +21,13 @@ use std::collections::HashMap; /// supported target (see `pack_pair` in `runtime/src/lib.rs` for why /// not a struct). The `cap` word is a codegen-side derivation: /// `Static` (cap = 0, the .rodata sentinel) for -/// `ryo_str_from_literal` / `ryo_bytes_from_literal`, `LenIsCap` -/// (cap = len) for every allocating producer — the runtime never -/// over-allocates, and `__ryo_str_push` / `__ryo_bytes_push` manage -/// growth capacity through their unchanged slot ABI. +/// `ryo_str_from_literal` / `ryo_bytes_from_literal` — the only +/// remaining packed-u128 producers. The slot-out producers report +/// their own tagged cap, and `__ryo_str_push` / `__ryo_bytes_push` +/// manage growth capacity through their unchanged slot ABI. #[derive(Clone, Copy)] pub(crate) enum CapRule { Static, - LenIsCap, } impl Codegen { @@ -521,7 +520,7 @@ impl Codegen { /// .rodata sentinel. `ryo_str_free` returns immediately for /// cap == 0, so the call is dead at the emission site and can be /// skipped; the ownership schedule itself stays untouched. - fn is_static_cap_zero(func: &cranelift::codegen::ir::Function, cap: Value) -> bool { + pub(crate) fn is_static_cap_zero(func: &cranelift::codegen::ir::Function, cap: Value) -> bool { let ValueDef::Result(inst, _) = func.dfg.value_def(cap) else { return false; }; @@ -813,11 +812,86 @@ impl Codegen { let (ptr, len) = Self::emit_rv_pair_call(builder, ctx, fn_name, args)?; let cap = match cap_rule { CapRule::Static => builder.ins().iconst(types::I64, 0), - CapRule::LenIsCap => len, }; Ok(ValueRepr::Str { ptr, len, cap }) } + /// Call a slot-out runtime producer: allocate a 24-byte slot, pass + /// its address as arg 0, then load the tagged (ptr, len, cap) + /// triple. The runtime writes the full slot (SSO tag, headroom + /// cap) — codegen never derives cap anymore. + pub(crate) fn emit_slot_out_call( + builder: &mut FunctionBuilder, + ctx: &mut FunctionContext<'_, M>, + fn_name: &str, + args: &[(Type, Value)], + ) -> Result<(Value, Value, Value), String> { + let slot = builder.create_sized_stack_slot(StackSlotData::new( + StackSlotKind::ExplicitSlot, + STR_SLOT_SIZE, + 3, + )); + let addr = builder.ins().stack_addr(ctx.int_type, slot, 0); + let mut param_tys = Vec::with_capacity(args.len() + 1); + param_tys.push(ctx.int_type); + param_tys.extend(args.iter().map(|(ty, _)| *ty)); + let func_ref = Self::declare_runtime_fn(ctx.module, builder, fn_name, ¶m_tys, &[])?; + let mut call_args = Vec::with_capacity(args.len() + 1); + call_args.push(addr); + call_args.extend(args.iter().map(|(_, v)| *v)); + builder.ins().call(func_ref, &call_args); + let ptr = builder + .ins() + .load(ctx.int_type, MemFlagsData::trusted(), addr, 0); + let len = builder + .ins() + .load(types::I64, MemFlagsData::trusted(), addr, 8); + let cap = builder + .ins() + .load(types::I64, MemFlagsData::trusted(), addr, 16); + Ok((ptr, len, cap)) + } + + /// Extract a readable `(ptr, len)` for the byte content of a fat + /// value whose words may be tagged-inline (SSO). Inline: spill the + /// three words to a fresh 24-byte scratch slot and hand back its + /// address plus the tag-encoded len. Heap/static: pass through + /// unchanged. + /// + /// TRANSIENT CONSUMERS ONLY (print, eq, concat operands, push + /// suffix, conversion args): the returned ptr for an inline value + /// addresses the scratch slot. Each call allocates a fresh slot, so + /// extractions never clobber each other — nested evaluation of the + /// next operand cannot overwrite a pointer that is still live. + /// View-creating ops (slice, ToView) must go through + /// `__ryo_*_ensure_heap` instead (promote-on-view). + pub(crate) fn emit_fat_bytes_ptr_len( + builder: &mut FunctionBuilder, + ctx: &mut FunctionContext<'_, M>, + ptr: Value, + len: Value, + cap: Value, + ) -> Result<(Value, Value), String> { + let scratch = builder.create_sized_stack_slot(StackSlotData::new( + StackSlotKind::ExplicitSlot, + STR_SLOT_SIZE, + 3, + )); + let addr = builder.ins().stack_addr(ctx.int_type, scratch, 0); + // Unconditional spill: three stores are cheaper than a branch, + // and the scratch is written before either select reads it. + builder.ins().store(MemFlagsData::trusted(), ptr, addr, 0); + builder.ins().store(MemFlagsData::trusted(), len, addr, 8); + builder.ins().store(MemFlagsData::trusted(), cap, addr, 16); + let tag = builder.ins().ushr_imm_u(cap, 56); + let tag_bit = builder.ins().band_imm_u(tag, 0x80); + let is_in = builder.ins().icmp_imm_u(IntCC::NotEqual, tag_bit, 0); + let in_len = builder.ins().band_imm_u(tag, 0x7f); + let out_ptr = builder.ins().select(is_in, addr, ptr); + let out_len = builder.ins().select(is_in, in_len, len); + Ok((out_ptr, out_len)) + } + /// Materialize a fat-typed (`str` or `bytes`, M8.4.2) TIR /// instruction, returning the `ValueRepr::Str` / `ValueRepr::Bytes` /// triple matching the inst's type. Falls back to scalar @@ -882,13 +956,13 @@ impl Codegen { else { unreachable!("__ryo_str_from_view argument must produce ValueRepr::View") }; - Self::emit_rv_str_call( + let (ptr, len, cap) = Self::emit_slot_out_call( builder, ctx, "ryo_str_from_view", &[(ctx.int_type, v_ptr), (types::I64, v_len)], - CapRule::LenIsCap, - )? + )?; + ValueRepr::Str { ptr, len, cap } } else if name_str == "__ryo_bytes_from_view" { // M8.4.2 `bytes(bview)` materialization: the // argument is a view pair via `eval_inst_view`. @@ -899,46 +973,46 @@ impl Codegen { else { unreachable!("__ryo_bytes_from_view argument must produce ValueRepr::View") }; - Self::emit_rv_bytes_call( + let (ptr, len, cap) = Self::emit_slot_out_call( builder, ctx, "ryo_bytes_from_view", &[(ctx.int_type, v_ptr), (types::I64, v_len)], - CapRule::LenIsCap, - )? + )?; + ValueRepr::Bytes { ptr, len, cap } } else if name_str == "__ryo_str_to_bytes" { // `str.to_bytes()` / `strview.to_bytes()` — only // (ptr, len) is read. let (p, l) = Self::eval_str_or_view_parts(builder, ctx, view.args[0])?; - Self::emit_rv_bytes_call( + let (ptr, len, cap) = Self::emit_slot_out_call( builder, ctx, "__ryo_str_to_bytes", &[(ctx.int_type, p), (types::I64, l)], - CapRule::LenIsCap, - )? + )?; + ValueRepr::Bytes { ptr, len, cap } } else if name_str == "__ryo_bytes_to_str" { // `bytes.to_str()` / `bytesview.to_str()` — returns // an owned str (validated copy; panics on bad UTF-8). let (p, l) = Self::eval_str_or_view_parts(builder, ctx, view.args[0])?; - Self::emit_rv_str_call( + let (ptr, len, cap) = Self::emit_slot_out_call( builder, ctx, "__ryo_bytes_to_str", &[(ctx.int_type, p), (types::I64, l)], - CapRule::LenIsCap, - )? + )?; + ValueRepr::Str { ptr, len, cap } } else if name_str == "__ryo_bytes_repr" { // print(bytes) rewrite (sema, M8.4.2) — returns the // escaped-repr str. let (p, l) = Self::eval_str_or_view_parts(builder, ctx, view.args[0])?; - Self::emit_rv_str_call( + let (ptr, len, cap) = Self::emit_slot_out_call( builder, ctx, "__ryo_bytes_repr", &[(ctx.int_type, p), (types::I64, l)], - CapRule::LenIsCap, - )? + )?; + ValueRepr::Str { ptr, len, cap } } else if name_str == "int_to_str" || name_str == "float_to_str" || name_str == "bool_to_str" @@ -950,13 +1024,9 @@ impl Codegen { "bool_to_str" => ("ryo_bool_to_str", types::I8), _ => unreachable!(), }; - Self::emit_rv_str_call( - builder, - ctx, - fn_name, - &[(param_ty, arg_val)], - CapRule::LenIsCap, - )? + let (ptr, len, cap) = + Self::emit_slot_out_call(builder, ctx, fn_name, &[(param_ty, arg_val)])?; + ValueRepr::Str { ptr, len, cap } } else { // User call — emit_call handles sret for fat-returning // calls and caches the triple. Called directly @@ -976,18 +1046,14 @@ impl Codegen { TirData::BinOp { lhs, rhs } => (lhs, rhs), _ => unreachable!(), }; - let l_repr = Self::eval_inst_fat(builder, ctx, lhs)?; - let r_repr = Self::eval_inst_fat(builder, ctx, rhs)?; - let (l_ptr, l_len) = match l_repr { - ValueRepr::Str { ptr, len, .. } => (ptr, len), - _ => unreachable!(), - }; - let (r_ptr, r_len) = match r_repr { - ValueRepr::Str { ptr, len, .. } => (ptr, len), - _ => unreachable!(), - }; + // Transient extraction (the helper inside + // eval_str_or_view_parts) is sound here: each extraction + // spills to its own fresh scratch slot and the pointers + // are consumed by the concat call itself. + let (l_ptr, l_len) = Self::eval_str_or_view_parts(builder, ctx, lhs)?; + let (r_ptr, r_len) = Self::eval_str_or_view_parts(builder, ctx, rhs)?; - Self::emit_rv_str_call( + let (ptr, len, cap) = Self::emit_slot_out_call( builder, ctx, "ryo_str_concat", @@ -997,26 +1063,19 @@ impl Codegen { (ctx.int_type, r_ptr), (types::I64, r_len), ], - CapRule::LenIsCap, - )? + )?; + ValueRepr::Str { ptr, len, cap } } TirTag::BytesConcat => { let (lhs, rhs) = match inst.data { TirData::BinOp { lhs, rhs } => (lhs, rhs), _ => unreachable!(), }; - let l_repr = Self::eval_inst_fat(builder, ctx, lhs)?; - let r_repr = Self::eval_inst_fat(builder, ctx, rhs)?; - let (l_ptr, l_len) = match l_repr { - ValueRepr::Bytes { ptr, len, .. } => (ptr, len), - _ => unreachable!(), - }; - let (r_ptr, r_len) = match r_repr { - ValueRepr::Bytes { ptr, len, .. } => (ptr, len), - _ => unreachable!(), - }; + // Transient extraction, as in StrConcat above. + let (l_ptr, l_len) = Self::eval_str_or_view_parts(builder, ctx, lhs)?; + let (r_ptr, r_len) = Self::eval_str_or_view_parts(builder, ctx, rhs)?; - Self::emit_rv_bytes_call( + let (ptr, len, cap) = Self::emit_slot_out_call( builder, ctx, "ryo_bytes_concat", @@ -1026,8 +1085,8 @@ impl Codegen { (ctx.int_type, r_ptr), (types::I64, r_len), ], - CapRule::LenIsCap, - )? + )?; + ValueRepr::Bytes { ptr, len, cap } } TirTag::FieldAccess => Self::eval_field_access_fat(builder, ctx, r)?, TirTag::ViewAsOwner => { @@ -1080,8 +1139,11 @@ impl Codegen { TirData::Slice { base, start, end } => (base, start, end), _ => unreachable!("Slice must carry TirData::Slice"), }; - // Base may be an owned str (triple) or a view (pair). - let (base_ptr, base_len) = Self::eval_str_or_view_parts(builder, ctx, base)?; + // Promote-on-view: an inline (SSO) base's bytes live in + // its slot; the view must point at memory that never + // moves, so owners go through ensure_heap first. + let (base_ptr, base_len) = + Self::emit_ensure_heap_for_view_base(builder, ctx, base)?; let start_v = match start { Some(s) => Self::eval_inst(builder, ctx, s)?, None => builder.ins().iconst(types::I64, 0), @@ -1116,13 +1178,9 @@ impl Codegen { TirData::UnOp(o) => o, _ => unreachable!("ToView must carry TirData::UnOp"), }; - // Representation conversion only: drop the cap word. - let (ptr, len) = match Self::eval_inst_fat(builder, ctx, operand)? { - ValueRepr::Str { ptr, len, .. } | ValueRepr::Bytes { ptr, len, .. } => { - (ptr, len) - } - _ => unreachable!("ToView operand must produce a fat repr"), - }; + // Promote-on-view for owner operands: the view must + // address stable memory (see emit_ensure_heap_for_view_base). + let (ptr, len) = Self::emit_ensure_heap_for_view_base(builder, ctx, operand)?; ValueRepr::View { ptr, len } } TirTag::Var => { @@ -1160,12 +1218,18 @@ impl Codegen { /// Evaluate a `str`/`bytes`/`strview`/`bytesview`-typed operand and /// hand back its `(ptr, len)` words regardless of representation — - /// owned triple or borrowed view pair (M8.4/M8.4.2). Consumers that + /// owned triple or borrowed view pair (M8.4/M8.4.2). Owned triples + /// extract through the SSO-aware `emit_fat_bytes_ptr_len`, which + /// spills a tagged-inline value's words to a fresh scratch slot and + /// passes heap/static values through unchanged. Consumers that /// only need the viewed bytes (`print`, `StrLen`, `StrCmpEq/Ne`, - /// `BytesCmpEq/Ne`, the `__ryo_str_push` suffix, the - /// `__ryo_slice`/`__ryo_bytes_slice` base, the bytes conversion - /// calls) use this; anything needing the cap must stay on - /// `eval_inst_fat`. + /// `BytesCmpEq/Ne`, the `__ryo_str_push` suffix, the bytes + /// conversion calls) use this; anything needing the cap must stay + /// on `eval_inst_fat`. + /// + /// TRANSIENT CONSUMERS ONLY: for an inline value the returned ptr + /// addresses a scratch slot private to this extraction. View- + /// creating ops (slice, ToView) must not use it. pub(super) fn eval_str_or_view_parts( builder: &mut FunctionBuilder, ctx: &mut FunctionContext<'_, M>, @@ -1179,7 +1243,9 @@ impl Codegen { return Ok((ptr, len)); } match Self::eval_inst_fat(builder, ctx, r)? { - ValueRepr::Str { ptr, len, .. } | ValueRepr::Bytes { ptr, len, .. } => Ok((ptr, len)), + ValueRepr::Str { ptr, len, cap } | ValueRepr::Bytes { ptr, len, cap } => { + Self::emit_fat_bytes_ptr_len(builder, ctx, ptr, len, cap) + } ValueRepr::View { ptr, len } => Ok((ptr, len)), ValueRepr::Scalar(_) | ValueRepr::Struct { .. } => Err(format!( "eval_str_or_view_parts: instruction at %{} is not a fat/view value", @@ -1649,6 +1715,105 @@ impl Codegen { } } + /// Consuming-concat fast path: `s = s + suffix` where the ownership + /// pass has proven the lhs binding dies at this reassign (Valid + /// owner, no live views, rhs not aliasing). Append the rhs onto the + /// lhs buffer in place via `__ryo_str_push` and reload — no fresh + /// allocation, and no free of the old buffer (it was CONSUMED: + /// `free_on_reassign` for this Assign is deliberately skipped by + /// never reaching the shared Assign code). + pub(crate) fn emit_consuming_concat_assign( + builder: &mut FunctionBuilder, + ctx: &mut FunctionContext<'_, M>, + assign_ref: TirRef, + concat_ref: TirRef, + ) -> Result { + let view = ctx.tir.assign_view(assign_ref); + let concat = ctx.tir.inst(concat_ref); + let (lhs, rhs) = match concat.data { + TirData::BinOp { lhs, rhs } => (lhs, rhs), + _ => unreachable!("consumed_concat_lhs must key a BinOp concat"), + }; + let lhs_name = match ctx.tir.inst(lhs).data { + TirData::Var(n) => n, + _ => unreachable!("sidecar guarantees a Var lhs"), + }; + debug_assert_eq!( + lhs_name, view.name, + "consuming concat must target the reassigned binding itself" + ); + // The rhs bytes are consumed by the push call — transient + // extraction is sound (eval_str_or_view_parts contract). + let (r_ptr, r_len) = Self::eval_str_or_view_parts(builder, ctx, rhs)?; + let locals = Self::read_slot(&ctx.fat_locals, lhs_name).ok_or_else(|| { + format!( + "Undefined fat variable in consuming concat: '{}'", + ctx.pool.str(lhs_name) + ) + })?; + let slot = builder.create_sized_stack_slot(StackSlotData::new( + StackSlotKind::ExplicitSlot, + STR_SLOT_SIZE, + 3, + )); + let addr = builder.ins().stack_addr(ctx.int_type, slot, 0); + let old_ptr = builder.use_var(locals.ptr); + let old_len = builder.use_var(locals.len); + let old_cap = builder.use_var(locals.cap); + builder + .ins() + .store(MemFlagsData::trusted(), old_ptr, addr, 0); + builder + .ins() + .store(MemFlagsData::trusted(), old_len, addr, 8); + builder + .ins() + .store(MemFlagsData::trusted(), old_cap, addr, 16); + // __ryo_str_push serves both families: the tagged-slot layout + // is shared, and appending valid-UTF-8 + valid-UTF-8 stays + // valid (no boundary check needed). + let push_ref = Self::declare_runtime_fn( + ctx.module, + builder, + "__ryo_str_push", + &[ctx.int_type, ctx.int_type, types::I64], + &[], + )?; + builder.ins().call(push_ref, &[addr, r_ptr, r_len]); + let np = builder + .ins() + .load(ctx.int_type, MemFlagsData::trusted(), addr, 0); + let nl = builder + .ins() + .load(types::I64, MemFlagsData::trusted(), addr, 8); + let nc = builder + .ins() + .load(types::I64, MemFlagsData::trusted(), addr, 16); + builder.def_var(locals.ptr, np); + builder.def_var(locals.len, nl); + builder.def_var(locals.cap, nc); + // The concat inst stands in for the value the binding now + // holds. Caching its repr lets the end-of-statement sweep fire + // Frees anchored on the concat (e.g. a heap rhs temp) exactly + // as the allocating path does. + let repr = if matches!(ctx.pool.kind(concat.ty), TypeKind::Bytes) { + ValueRepr::Bytes { + ptr: np, + len: nl, + cap: nc, + } + } else { + ValueRepr::Str { + ptr: np, + len: nl, + cap: nc, + } + }; + Self::cache_repr(ctx, concat_ref, repr); + Self::kill_fact(ctx, view.name); + Ok(Terminator::None) + } + /// Reload each inout slot after a call and write the updated value /// back into the caller's local. The inout arg was sema-lowered to /// its inner `Var(name)` ref, so `*arg_ref` is that `Var` inst — @@ -1701,7 +1866,7 @@ impl Codegen { /// `None`. Used to resolve an inout arg (lowered to its inner /// `Var(name)`) back to the caller local that must receive the /// reloaded value. - fn local_name_of(ctx: &FunctionContext<'_, M>, r: TirRef) -> Option { + pub(crate) fn local_name_of(ctx: &FunctionContext<'_, M>, r: TirRef) -> Option { let inst = ctx.tir.inst(r); match inst.tag { TirTag::Var => match inst.data { diff --git a/ryo-backend/src/codegen/mod.rs b/ryo-backend/src/codegen/mod.rs index dafbd5f..e7989a9 100644 --- a/ryo-backend/src/codegen/mod.rs +++ b/ryo-backend/src/codegen/mod.rs @@ -40,6 +40,7 @@ mod bytes; mod expr; mod ranges; mod structs; +mod views; /// Fat-owner triple layout (str/bytes, 24 bytes): ptr at 0, len at 8, /// cap at 16. Derived from `RyoStrFat`, not re-hardcoded. @@ -476,6 +477,14 @@ impl Codegen { ("ryo_str_alloc", ryo_runtime::ryo_str_alloc as *const u8), ("ryo_str_concat", ryo_runtime::ryo_str_concat as *const u8), ("__ryo_str_push", ryo_runtime::__ryo_str_push as *const u8), + ( + "__ryo_str_ensure_heap", + ryo_runtime::__ryo_str_ensure_heap as *const u8, + ), + ( + "__ryo_bytes_ensure_heap", + ryo_runtime::__ryo_bytes_ensure_heap as *const u8, + ), ("__ryo_slice", ryo_runtime::__ryo_slice as *const u8), ("ryo_str_eq", ryo_runtime::ryo_str_eq as *const u8), ("ryo_int_to_str", ryo_runtime::ryo_int_to_str as *const u8), @@ -1349,6 +1358,13 @@ impl Codegen { TirTag::Assign => { let view = ctx.tir.assign_view(r); if is_fat_type(inst.ty, ctx.pool) { + // Consuming reassign-concat fast path: the ownership + // pass proved the lhs binding dies at this reassign, so + // codegen appends in place and skips the + // free_on_reassign free below by never reaching it. + if let Some(concat_ref) = ctx.sidecar.consumed_concat_lhs[r.index()] { + return Self::emit_consuming_concat_assign(builder, ctx, r, concat_ref); + } let repr = Self::eval_inst_fat(builder, ctx, view.value)?; let (ptr, len, cap) = match repr { ValueRepr::Str { ptr, len, cap } | ValueRepr::Bytes { ptr, len, cap } => { diff --git a/ryo-backend/src/codegen/structs.rs b/ryo-backend/src/codegen/structs.rs index aef2f9a..f37be87 100644 --- a/ryo-backend/src/codegen/structs.rs +++ b/ryo-backend/src/codegen/structs.rs @@ -87,7 +87,7 @@ impl Codegen { /// Address + field type of a `FieldAccess` chain: the base /// struct's slot address plus the field's byte offset. - fn field_addr_of( + pub(crate) fn field_addr_of( builder: &mut FunctionBuilder, ctx: &mut FunctionContext<'_, M>, r: TirRef, diff --git a/ryo-backend/src/codegen/views.rs b/ryo-backend/src/codegen/views.rs new file mode 100644 index 0000000..c1b0f76 --- /dev/null +++ b/ryo-backend/src/codegen/views.rs @@ -0,0 +1,218 @@ +//! View-creation codegen (M8.4) — split from `expr.rs` to keep every +//! file under the 2000-line CI cap (`scripts/check_file_length.sh`). +//! +//! Central entry point: `emit_ensure_heap_for_view_base`, the single +//! choke point every view-creating op (slice, `ToView`) uses to turn an +//! owner-typed base into stable, never-moving memory. + +use cranelift::codegen::ir::{BlockArg, MemFlagsData, StackSlotData, StackSlotKind}; +use cranelift::prelude::*; +use cranelift_module::Module; +use ryo_core::tir::{TirRef, TirTag}; +use ryo_core::types::TypeKind; + +use super::{Codegen, FunctionContext, STR_SLOT_SIZE, ValueRepr}; + +impl Codegen { + /// Materialize an owner-typed (`str`/`bytes`) value for VIEW + /// CREATION: return a stable `(ptr, len)` that outlives this + /// expression. Views into `.rodata`/heap were already stable; the + /// inline case is handled by branching on the runtime tag — an + /// inline base is promoted in place via the family `ensure_heap`, + /// a heap or static base passes through with no call. + /// + /// The promotion must land where the owner's eventual free reads + /// it, or the fresh heap buffer leaks while the owner's stale + /// inline tag makes its free a no-op: + /// - Field bases promote in place at the field address — the + /// struct's own slot is the storage its drop glue reads. + /// - Named bindings spill → call → reload → `def_var` back into + /// their `FatLocals` (the str_push write-back shape; SSA-correct + /// at every later program point, including branch joins). + /// - Anonymous temporaries spill into a scratch slot and re-cache + /// the promoted triple — their scheduled Free reads `cached_repr`. + pub(crate) fn emit_ensure_heap_for_view_base( + builder: &mut FunctionBuilder, + ctx: &mut FunctionContext<'_, M>, + r: TirRef, + ) -> Result<(Value, Value), String> { + // A view-typed base (reslice, strview of a view param) already + // addresses stable memory — pass it through untouched. + if ctx.pool.is_view(ctx.tir.inst(r).ty) { + let ValueRepr::View { ptr, len } = Self::eval_inst_view(builder, ctx, r)? else { + unreachable!("eval_inst_view must produce ValueRepr::View"); + }; + return Ok((ptr, len)); + } + + // Field base: promote in place — the field's slot inside the + // struct IS the owner-side storage (its drop glue loads the + // field triple from this address). + if matches!(ctx.tir.inst(r).tag, TirTag::FieldAccess) { + let (addr, field_ty) = Self::field_addr_of(builder, ctx, r)?; + let is_bytes = matches!(ctx.pool.kind(field_ty), TypeKind::Bytes); + let callee = if is_bytes { + "__ryo_bytes_ensure_heap" + } else { + "__ryo_str_ensure_heap" + }; + let func_ref = + Self::declare_runtime_fn(ctx.module, builder, callee, &[ctx.int_type], &[])?; + builder.ins().call(func_ref, &[addr]); + let out_ptr = builder + .ins() + .load(ctx.int_type, MemFlagsData::trusted(), addr, 0); + let out_len = builder + .ins() + .load(types::I64, MemFlagsData::trusted(), addr, 8); + let out_cap = builder + .ins() + .load(types::I64, MemFlagsData::trusted(), addr, 16); + let repr = if is_bytes { + ValueRepr::Bytes { + ptr: out_ptr, + len: out_len, + cap: out_cap, + } + } else { + ValueRepr::Str { + ptr: out_ptr, + len: out_len, + cap: out_cap, + } + }; + Self::cache_repr(ctx, r, repr); + return Ok((out_ptr, out_len)); + } + + let (ptr, len, cap, is_bytes) = match Self::eval_inst_fat(builder, ctx, r)? { + ValueRepr::Str { ptr, len, cap } => (ptr, len, cap, false), + ValueRepr::Bytes { ptr, len, cap } => (ptr, len, cap, true), + _ => unreachable!("view base must be fat or view typed"), + }; + // Static .rodata bases are already stable: skip the + // spill/call/reload entirely so the cached cap stays an + // `iconst 0` and downstream dead-free elision keeps firing. + if Self::is_static_cap_zero(builder.func, cap) { + return Ok((ptr, len)); + } + // Branch on the runtime tag: only an inline base needs the + // spill/promote/reload round trip. A heap base is already + // stable, so its (ptr, len) flows straight to the merge — + // the extern call, three stores, and three loads all drop + // off that path. The tag test mirrors the runtime's + // `is_inline`: the cap word's top byte is 0x80|len for + // inline strings. + let tag = builder.ins().ushr_imm_u(cap, 56); + let tag_bit = builder.ins().band_imm_u(tag, 0x80); + let is_inline = builder.ins().icmp_imm_u(IntCC::NotEqual, tag_bit, 0); + let inline_block = builder.create_block(); + let merge_block = builder.create_block(); + // The merge carries the full triple: an anonymous temporary's + // scheduled Free reads its cached cap later, so the cap value + // must dominate both paths, not just the inline one. + builder.append_block_param(merge_block, ctx.int_type); + builder.append_block_param(merge_block, types::I64); + builder.append_block_param(merge_block, types::I64); + builder.ins().brif( + is_inline, + inline_block, + &[], + merge_block, + &[ + BlockArg::Value(ptr), + BlockArg::Value(len), + BlockArg::Value(cap), + ], + ); + // Single predecessor (the brif above) — seal immediately. + builder.seal_block(inline_block); + builder.switch_to_block(inline_block); + let slot = builder.create_sized_stack_slot(StackSlotData::new( + StackSlotKind::ExplicitSlot, + STR_SLOT_SIZE, + 3, + )); + let addr = builder.ins().stack_addr(ctx.int_type, slot, 0); + builder.ins().store(MemFlagsData::trusted(), ptr, addr, 0); + builder.ins().store(MemFlagsData::trusted(), len, addr, 8); + builder.ins().store(MemFlagsData::trusted(), cap, addr, 16); + let callee = if is_bytes { + "__ryo_bytes_ensure_heap" + } else { + "__ryo_str_ensure_heap" + }; + let func_ref = Self::declare_runtime_fn(ctx.module, builder, callee, &[ctx.int_type], &[])?; + builder.ins().call(func_ref, &[addr]); + let out_ptr = builder + .ins() + .load(ctx.int_type, MemFlagsData::trusted(), addr, 0); + let out_len = builder + .ins() + .load(types::I64, MemFlagsData::trusted(), addr, 8); + let out_cap = builder + .ins() + .load(types::I64, MemFlagsData::trusted(), addr, 16); + // Write the promoted triple back into owner-side storage so + // the owner's free releases the heap buffer. Only the inline + // path needs this — on the heap path the binding's fat locals + // already hold the identical bits. + let local_name = Self::local_name_of(ctx, r); + if let Some(name) = local_name { + // Every fat binding gets FatLocals at the param/local + // preamble, so a missing entry would be an invariant + // violation; the silent fall-through is defensive only. + if let Some(sl) = Self::read_slot(&ctx.fat_locals, name) { + builder.def_var(sl.ptr, out_ptr); + builder.def_var(sl.len, out_len); + builder.def_var(sl.cap, out_cap); + } + // Invariant: after this write-back, the cached repr of the + // binding's Var inst is STALE (it holds the pre-promotion + // inline triple) — consumers must read the binding through + // `fat_locals`, never through `cached_repr`. Latent, not + // live: TIR is tree-shaped today, so each Var inst is + // evaluated once at its own use site. + // Known leak, unrelated to the fall-through: for a + // BORROWED param the write-back lands but no free is + // ever scheduled (the callee doesn't own its params), + // so a promoted inline argument's buffer leaks. The + // free cannot simply be added — it cannot tell a + // callee-promoted buffer apart from a caller-owned heap + // buffer and would double-free; the planned resolution + // is an ownership-pass-scheduled free of the promotion + // buffer at the view's last use. + } + builder.ins().jump( + merge_block, + &[ + BlockArg::Value(out_ptr), + BlockArg::Value(out_len), + BlockArg::Value(out_cap), + ], + ); + builder.seal_block(merge_block); + builder.switch_to_block(merge_block); + let params = builder.block_params(merge_block); + let (m_ptr, m_len, m_cap) = (params[0], params[1], params[2]); + // Anonymous temporary: re-cache the merged triple (dominating + // both paths) — its scheduled Free reads `cached_repr`. + if local_name.is_none() { + let repr = if is_bytes { + ValueRepr::Bytes { + ptr: m_ptr, + len: m_len, + cap: m_cap, + } + } else { + ValueRepr::Str { + ptr: m_ptr, + len: m_len, + cap: m_cap, + } + }; + Self::cache_repr(ctx, r, repr); + } + Ok((m_ptr, m_len)) + } +} diff --git a/ryo-core/src/ownership.rs b/ryo-core/src/ownership.rs index 2546423..5528179 100644 --- a/ryo-core/src/ownership.rs +++ b/ryo-core/src/ownership.rs @@ -88,6 +88,17 @@ pub struct FunctionSidecar { /// (never param sentinels); `target` itself may be a param sentinel /// ref for `inout` params. pub free_on_reassign: Vec>, + /// In-place concat selections (consuming-concat optimization). + /// Dense side table indexed by the `Assign` instruction's + /// `TirRef::index()`, sized like `free_on_reassign`. `Some(concat)` + /// at slot `r` means: the Assign's value is the StrConcat/ + /// BytesConcat `concat` whose lhs is a plain local binding that + /// dies exactly at this reassign (Valid owner, no live views — the + /// same facts `free_on_reassign` already proves), so codegen may + /// append the rhs onto the lhs buffer in place instead of + /// allocating. The old buffer is CONSUMED, not orphaned: codegen + /// must skip the `free_on_reassign` free for this Assign. + pub consumed_concat_lhs: Vec>, /// Field-reassignment Frees (M9). Dense side table indexed by the /// `FieldAssign`/`CompoundFieldAssign` instruction's /// `TirRef::index()`, sized like `free_on_reassign`. `Some(target)` @@ -120,6 +131,7 @@ impl FunctionSidecar { name, free_schedule: Vec::new(), free_on_reassign: vec![None; arena_len], + consumed_concat_lhs: vec![None; arena_len], field_free_on_reassign: vec![None; arena_len], if_branches: vec![None; arena_len], conditional_dead_drops: Vec::new(), diff --git a/ryo-frontend/src/ownership/mod.rs b/ryo-frontend/src/ownership/mod.rs index 079a378..025418e 100644 --- a/ryo-frontend/src/ownership/mod.rs +++ b/ryo-frontend/src/ownership/mod.rs @@ -226,6 +226,18 @@ pub(crate) struct Ownership { /// deterministic. pub live_projections: HashMap>, + /// Field path (indices from the struct root, see `field_path_of`) + /// of the str/bytes field a view slices, keyed by view owner. + /// Present only for field-base projections; whole-struct + /// operations (drop/move/reassign) check `live_projections` by + /// root alone, while a `FieldAssign` target check matches the + /// assigned field's path against these. Monotone like + /// `root_owner`: a view owner's path never changes, so branch + /// arms accumulate in place and merges need no rule for it. + /// Entries for dead views are left behind harmlessly — lookups + /// only happen for views present in `live_projections`. + pub projection_fields: HashMap>, + /// Walk-constant pre-pass liveness (P4): bound view instruction → /// its last reading instruction. Views with no entry are never /// read — their projection lives to scope end. Constant per diff --git a/ryo-frontend/src/ownership/structs.rs b/ryo-frontend/src/ownership/structs.rs index 7d5b73b..f41681b 100644 --- a/ryo-frontend/src/ownership/structs.rs +++ b/ryo-frontend/src/ownership/structs.rs @@ -58,6 +58,32 @@ pub(crate) fn struct_base_name(tir: &Tir, mut r: TirRef) -> Option { } } +/// Field-index path from the struct root down to the field a +/// `FieldAccess` chain targets: `p.a.b` → `[a, b]`. `None` when `r` +/// is not a `FieldAccess` chain. The path identifies one field's +/// storage within the root, so a freeze check can tell `p.a = x` +/// (threatens slices of `p.a` only) apart from a sibling-field +/// reassign. +pub(crate) fn field_path_of(tir: &Tir, mut r: TirRef) -> Option> { + let mut path = Vec::new(); + loop { + match tir.inst(r).data { + TirData::FieldAccess { + object, + field_index, + } => { + path.push(field_index); + r = object; + } + TirData::Var(_) => { + path.reverse(); + return Some(path); + } + _ => return None, + } + } +} + /// Consume each needs-drop field value of a `StructLit` under the /// normal rules: a bound source moves (`Person{name=s}` invalidates /// `s`), a fresh temp is stamped `Moved` so the anon-temp free pass diff --git a/ryo-frontend/src/ownership/tests/concat.rs b/ryo-frontend/src/ownership/tests/concat.rs new file mode 100644 index 0000000..c7f6c4b --- /dev/null +++ b/ryo-frontend/src/ownership/tests/concat.rs @@ -0,0 +1,116 @@ +use super::super::*; +use super::common::*; + +/// Find the single `Assign` statement in `main`'s body. +fn single_assign(tir: &ryo_core::tir::Tir) -> TirRef { + tir.body_stmts() + .iter() + .find(|&&s| tir.inst(s).tag == TirTag::Assign) + .copied() + .expect("assign stmt") +} + +#[test] +fn consuming_concat_reassign_recorded() { + // s = s + "b": the concat's lhs Var resolves to the dying owner, + // the rhs is a different owner — the Assign is selected for + // in-place append. + let src = "fn main():\n\tmut s: str = \"a\"\n\ts = s + \"b\"\n\tprint(s)\n"; + let (diags, sidecar, tirs, _pool) = check_src_full(src); + assert!( + !diags + .iter() + .any(|d| d.severity == ryo_core::diag::Severity::Error), + "no errors expected; got: {diags:?}" + ); + let tir = &tirs[0]; + let assign = single_assign(tir); + let concat = tir.assign_view(assign).value; + assert_eq!(tir.inst(concat).tag, TirTag::StrConcat); + + let entries: Vec<(usize, TirRef)> = sidecar.functions[0] + .consumed_concat_lhs + .iter() + .enumerate() + .filter_map(|(i, e)| e.map(|v| (i, v))) + .collect(); + assert_eq!( + entries.len(), + 1, + "exactly one consumed_concat_lhs entry; got: {entries:?}" + ); + assert_eq!( + entries[0], + (assign.index(), concat), + "entry must be keyed at the Assign and point at the StrConcat" + ); + // free_on_reassign still records the old owner — codegen (not the + // ownership pass) is responsible for skipping that free when it + // consumes the buffer in place. + assert!( + sidecar.functions[0].free_on_reassign[assign.index()].is_some(), + "free_on_reassign must still be scheduled; got: {:?}", + sidecar.functions[0].free_on_reassign + ); +} + +#[test] +fn self_alias_concat_reassign_not_recorded() { + // s = s + s: the rhs aliases the dying owner, so in-place append + // would read the buffer being overwritten — the Assign must keep + // the allocating path. + let src = "fn main():\n\tmut s: str = \"a\"\n\ts = s + s\n\tprint(s)\n"; + let (diags, sidecar, tirs, _pool) = check_src_full(src); + assert!( + !diags + .iter() + .any(|d| d.severity == ryo_core::diag::Severity::Error), + "no errors expected; got: {diags:?}" + ); + let tir = &tirs[0]; + let assign = single_assign(tir); + assert_eq!( + tir.inst(tir.assign_view(assign).value).tag, + TirTag::StrConcat + ); + assert!( + sidecar.functions[0] + .consumed_concat_lhs + .iter() + .all(Option::is_none), + "self-aliasing concat must not be selected; got: {:?}", + sidecar.functions[0].consumed_concat_lhs + ); + assert!( + sidecar.functions[0].free_on_reassign[assign.index()].is_some(), + "the allocating path still frees the old buffer; got: {:?}", + sidecar.functions[0].free_on_reassign + ); +} + +#[test] +fn plain_reassign_not_recorded() { + // Control: a non-concat reassign never selects. + let src = "fn main():\n\tmut s: str = \"a\"\n\ts = \"b\"\n\tprint(s)\n"; + let (diags, sidecar, tirs, _pool) = check_src_full(src); + assert!( + !diags + .iter() + .any(|d| d.severity == ryo_core::diag::Severity::Error), + "no errors expected; got: {diags:?}" + ); + let tir = &tirs[0]; + let assign = single_assign(tir); + assert_ne!( + tir.inst(tir.assign_view(assign).value).tag, + TirTag::StrConcat + ); + assert!( + sidecar.functions[0] + .consumed_concat_lhs + .iter() + .all(Option::is_none), + "non-concat reassign must not be selected; got: {:?}", + sidecar.functions[0].consumed_concat_lhs + ); +} diff --git a/ryo-frontend/src/ownership/tests/mod.rs b/ryo-frontend/src/ownership/tests/mod.rs index d722268..ceaba69 100644 --- a/ryo-frontend/src/ownership/tests/mod.rs +++ b/ryo-frontend/src/ownership/tests/mod.rs @@ -1,4 +1,5 @@ mod common; +mod concat; mod frees; mod inout; mod loops; diff --git a/ryo-frontend/src/ownership/tests/structs.rs b/ryo-frontend/src/ownership/tests/structs.rs index 91e71c6..9789c6b 100644 --- a/ryo-frontend/src/ownership/tests/structs.rs +++ b/ryo-frontend/src/ownership/tests/structs.rs @@ -206,3 +206,100 @@ fn inout_field_and_nested_read_of_other_root_ok() { "no E0032 expected for different roots; got {diags:?}" ); } + +#[test] +fn field_slice_projects_struct_root_field_reassign_rejected() { + // v = p.name[0:1]; p.name = "xyz" — the reassign frees the old + // field buffer the view points into (field_free_on_reassign), so + // the P2 freeze must reject it: the slice projects the STRUCT's + // storage, keyed on the struct root. + let src = "struct Person:\n\tname: str\n\nfn main():\n\tmut p = Person{name=\"abc\"}\n\tv = p.name[0:1]\n\tp.name = \"xyz\"\n\tprint(v)\n"; + let diags = check_src(src); + assert!( + diags + .iter() + .any(|d| d.code == DiagCode::SourceProjected && d.message.contains("`p`")), + "expected SourceProjected naming `p`; got {diags:?}" + ); +} + +#[test] +fn field_slice_projects_struct_root_whole_struct_reassign_rejected() { + // v = p.name[0:1]; p = Person{...} — the whole-struct reassign + // drops the old struct (freeing the field buffer the view points + // into); the Assign path's P2 freeze sees the projection now that + // it registers on the struct root. + let src = "struct Person:\n\tname: str\n\nfn main():\n\tmut p = Person{name=\"abc\"}\n\tv = p.name[0:1]\n\tp = Person{name=\"xyz\"}\n\tprint(v)\n"; + let diags = check_src(src); + assert!( + diags + .iter() + .any(|d| d.code == DiagCode::SourceProjected && d.message.contains("`p`")), + "expected SourceProjected naming `p`; got {diags:?}" + ); +} + +#[test] +fn copy_field_reassign_allowed_while_field_view_live() { + // v = p.name[0:1]; p.age = 2 — a Copy-typed field reassign frees + // nothing, so the freeze must not fire. + let src = "struct Person:\n\tname: str\n\tage: int\n\nfn main():\n\tmut p = Person{name=\"abc\", age=1}\n\tv = p.name[0:1]\n\tp.age = 2\n\tprint(v)\n"; + let diags = check_src(src); + assert!( + !diags + .iter() + .any(|d| d.severity == ryo_core::diag::Severity::Error), + "no errors expected for a Copy-field reassign; got {diags:?}" + ); + assert!( + !diags.iter().any(|d| d.code == DiagCode::SourceProjected), + "no SourceProjected expected for a Copy-field reassign; got {diags:?}" + ); +} + +#[test] +fn field_slice_without_reassign_is_clean() { + // v = p.name[0:1]; print(v) — a plain field slice registers the + // projection on the struct root and defers the struct's drop past + // the view's last use; no diagnostics. + let src = "struct Person:\n\tname: str\n\nfn main():\n\tp = Person{name=\"abc\"}\n\tv = p.name[0:1]\n\tprint(v)\n"; + let diags = check_src(src); + assert!( + !diags + .iter() + .any(|d| d.severity == ryo_core::diag::Severity::Error), + "no errors expected; got: {diags:?}" + ); +} + +#[test] +fn sibling_field_reassign_allowed_while_field_view_live() { + // v = p.a[0:1]; p.b = "z" — the reassign frees field b's buffer, + // which the view never pointed into: only the assigned field's own + // buffer is threatened, so this must compile. + let src = "struct P:\n\ta: str\n\tb: str\n\nfn main():\n\tmut p = P{a=\"x\", b=\"y\"}\n\tv = p.a[0:1]\n\tp.b = \"z\"\n\tprint(v)\n"; + let diags = check_src(src); + assert!( + !diags + .iter() + .any(|d| d.severity == ryo_core::diag::Severity::Error), + "no errors expected for a sibling-field reassign; got {diags:?}" + ); + assert!( + !diags.iter().any(|d| d.code == DiagCode::SourceProjected), + "sibling-field reassign must not trip the freeze; got {diags:?}" + ); +} + +#[test] +fn same_field_reassign_rejected_while_field_view_live() { + // v = p.a[0:1]; p.a = "z" — frees the very buffer v points into. + let src = "struct P:\n\ta: str\n\tb: str\n\nfn main():\n\tmut p = P{a=\"x\", b=\"y\"}\n\tv = p.a[0:1]\n\tp.a = \"z\"\n\tprint(v)\n"; + let diags = check_src(src); + assert!( + diags + .iter() + .any(|d| d.code == DiagCode::SourceProjected && d.message.contains("`p`")), + "expected SourceProjected naming `p`; got {diags:?}" + ); +} diff --git a/ryo-frontend/src/ownership/views.rs b/ryo-frontend/src/ownership/views.rs index 2696d52..80ad034 100644 --- a/ryo-frontend/src/ownership/views.rs +++ b/ryo-frontend/src/ownership/views.rs @@ -1,5 +1,6 @@ //! M8.4 slice projections and view liveness — split from `mod.rs`. +use super::structs::{field_path_of, struct_root}; use super::{ LoopNesting, Owner, OwnerState, Ownership, format_binding, needs_tracking, underlying_owner, }; @@ -53,6 +54,14 @@ pub(crate) fn projection_root( return projection_root(own, tir, pool, inner); } if needs_tracking(inst.ty, pool) { + // A str/bytes field read projects the STRUCT's storage: the + // struct binding's drop frees the field buffer, so the root + // owner is the struct — not the field-access instruction. + if let TirData::FieldAccess { .. } = inst.data + && let Some(root) = struct_root(own, tir, r) + { + return Some(root); + } return Some(underlying_owner(own, r)); } if !pool.is_view(inst.ty) { @@ -79,6 +88,44 @@ pub(crate) fn projection_root( } } +/// The field path a view slices, mirroring `projection_root`'s walk: +/// a `FieldAccess` base yields its chain, `Var` copies and reslices +/// inherit the aliased view's recorded path, everything else has no +/// field identity (`None` — projections of plain str/bytes bindings). +fn projection_field_path( + own: &Ownership, + tir: &Tir, + pool: &InternPool, + r: TirRef, +) -> Option> { + let inst = *tir.inst(r); + if inst.tag == TirTag::ViewAsOwner + && let TirData::UnOp(inner) = inst.data + { + return projection_field_path(own, tir, pool, inner); + } + if needs_tracking(inst.ty, pool) { + if let TirData::FieldAccess { .. } = inst.data { + return field_path_of(tir, r); + } + return None; + } + if !pool.is_view(inst.ty) { + return None; + } + match inst.data { + TirData::Var(name) => own + .current_owner + .get(&name) + .and_then(|owner| own.projection_fields.get(owner).cloned()), + TirData::Slice { base, .. } => projection_field_path(own, tir, pool, base), + TirData::UnOp(inner) if inst.tag == TirTag::ToView => { + projection_field_path(own, tir, pool, inner) + } + _ => None, + } +} + /// P3 (final spec §3.2): register `view_owner` as a live projection of /// the root owner its initializer resolves to. Idempotent — loop /// convergence re-walks and `Var` copies re-register the same view. @@ -91,6 +138,9 @@ pub(crate) fn register_projection( ) { if let Some(root) = projection_root(own, tir, pool, init) { own.root_owner.insert(view_owner, root); + if let Some(path) = projection_field_path(own, tir, pool, init) { + own.projection_fields.insert(view_owner, path); + } let projections = own.live_projections.entry(root).or_default(); if !projections.contains(&view_owner) { projections.push(view_owner); @@ -233,6 +283,63 @@ pub(crate) fn check_source_projected( ); } +/// FieldAssign-target freeze: `p.f = v` frees the old buffer of field +/// `f` only, so it threatens exactly the live projections whose field +/// path lies at or below `target_path` — sibling fields' buffers are +/// untouched and must not trip the freeze. A projection with no +/// recorded field path cannot prove it is a sibling, so it counts as +/// threatened (conservative). +#[allow(clippy::too_many_arguments)] +pub(crate) fn check_field_target_projected( + tir: &Tir, + pool: &InternPool, + own: &Ownership, + sink: &mut DiagSink, + root: Owner, + target_path: &[u32], + span: Span, + name: Option, +) { + if matches!(own.states.get(&root), Some(OwnerState::Moved { .. })) { + return; + } + let Some(projections) = own.live_projections.get(&root) else { + return; + }; + let threatened: Vec = projections + .iter() + .copied() + .filter(|p| match own.projection_fields.get(p) { + Some(path) => path.starts_with(target_path), + None => true, + }) + .collect(); + let Some(first) = threatened.first() else { + return; + }; + let (note_span, note_msg) = match first.inst_tirref() { + Some(vi) => match Ownership::dense_get(&own.view_last_use, vi) { + Some(lu) => (tir.span(lu), "last slice use here"), + None => (tir.span(vi), "slice created here"), + }, + None => (span, "slice projection live here"), + }; + sink.emit( + Diag::error( + span, + DiagCode::SourceProjected, + format!( + "cannot mutate {} while a slice of it is live", + format_binding(name, pool) + ), + ) + .with_note(Some(note_span), note_msg) + .with_help( + "reassign the field before slicing it, or keep all slice uses before this point", + ), + ); +} + /// Pre-walk liveness for bound views (P4, final spec §3.2). See /// [`collect_view_liveness`]. `last_use` / `defer_to_loop` are dense /// per-instruction tables sized to `tir.instructions.len()` (slot 0, diff --git a/ryo-frontend/src/ownership/walk.rs b/ryo-frontend/src/ownership/walk.rs index d293385..224cd29 100644 --- a/ryo-frontend/src/ownership/walk.rs +++ b/ryo-frontend/src/ownership/walk.rs @@ -2,11 +2,12 @@ use super::{ BranchState, Owner, OwnerState, Ownership, ReseatDrop, analyze_for_range, analyze_while_loop, - check_field_move_out, check_source_projected, consume_struct_lit_fields, consumed_binding_name, - drain_dying_views, format_binding, needs_tracking, owner_name_for_diag, owner_sort_key, - param_idx, projection_root, prune_branch_dead_projections, push_unique, record_return_epilogue, + check_field_move_out, check_field_target_projected, check_source_projected, + consume_struct_lit_fields, consumed_binding_name, drain_dying_views, field_path_of, + format_binding, needs_tracking, owner_name_for_diag, owner_sort_key, param_idx, + projection_root, prune_branch_dead_projections, push_unique, record_return_epilogue, refine_view_liveness_for_arm, register_projection, resolve_view_alias, restore_view_last_use, - rule7_owner_name, struct_root, + rule7_owner_name, struct_base_name, struct_root, }; use crate::builtins::{is_borrowed_scalar_param, view_borrow_params}; use ryo_core::diag::{Diag, DiagCode, DiagSink}; @@ -70,6 +71,24 @@ pub(crate) fn analyze_stmt( if needs_tracking(inst.ty, pool) { sidecar.field_free_on_reassign[stmt.index()] = Some(view.target); let span = tir.span(stmt); + // P2 freeze on the TARGET side: the reassign frees the + // old buffer of the assigned field only, so the check + // matches the target's field path against each live + // projection's — sibling-field reassigns stay legal, + // same-field (or parent-struct-field) ones are rejected. + if let Some(root) = struct_root(own, tir, view.target) { + let target_path = field_path_of(tir, view.target).unwrap_or_default(); + check_field_target_projected( + tir, + pool, + own, + sink, + root, + &target_path, + span, + struct_base_name(tir, view.target), + ); + } let consumed_name = consumed_binding_name(tir, view.value); // P2 freeze (final spec §3.2): the consume moves the owner. check_source_projected( @@ -221,6 +240,23 @@ pub(crate) fn analyze_assign( // is a `Param`, resolved here to its virtual ref — codegen // caches that ref's repr at the prologue. sidecar.free_on_reassign[r.index()] = Some(old_owner.tirref(&own.param_index)); + // Consuming concat: `s = s + suffix` where the lhs Var + // resolves to the dying owner and the rhs is a different + // owner. Only for Valid owners (the inout-param Borrowed + // exception above is excluded: the callee does not own + // the caller's buffer). The `check_source_projected` + // call above has already proven no live views. + let old_valid = matches!(own.states.get(&old_owner), Some(OwnerState::Valid)); + let value_inst = tir.inst(view.value); + if old_valid + && matches!(value_inst.tag, TirTag::StrConcat | TirTag::BytesConcat) + && let TirData::BinOp { lhs, rhs } = value_inst.data + && matches!(tir.inst(lhs).data, TirData::Var(_)) + && underlying_owner(own, lhs) == old_owner + && underlying_owner(own, rhs) != old_owner + { + sidecar.consumed_concat_lhs[r.index()] = Some(view.value); + } // W0003 case-B support: reassignment mutates the binding's // owner — a defensive-copy hazard on it. own.owner_hazards.push((old_owner, r)); diff --git a/ryo/tests/asan_smoke.rs b/ryo/tests/asan_smoke.rs index 8b60f3d..c5dc9a6 100644 --- a/ryo/tests/asan_smoke.rs +++ b/ryo/tests/asan_smoke.rs @@ -228,6 +228,22 @@ fn asan_slice_across_blocks() { ); } +#[test] +fn asan_slice_of_struct_field_inline() { + run_asan_smoke( + common::find_fixture("slice_of_struct_field_inline"), + "slice_of_struct_field_inline", + ); +} + +#[test] +fn asan_slice_of_struct_field_heap() { + run_asan_smoke( + common::find_fixture("slice_of_struct_field_heap"), + "slice_of_struct_field_heap", + ); +} + #[test] fn asan_bytes_ops() { run_asan_smoke(common::find_fixture("bytes_ops"), "bytes_ops"); diff --git a/ryo/tests/common/mod.rs b/ryo/tests/common/mod.rs index 653f9fb..3602cc4 100644 --- a/ryo/tests/common/mod.rs +++ b/ryo/tests/common/mod.rs @@ -50,8 +50,13 @@ pub fn build_and_link( .expect("ryo build"); assert!(status.success(), "ryo build failed for {name}"); - // Step 2: relink - let obj = tmp.path().join(format!("{name}.o")); + // Step 2: relink (object extension matches the AOT pipeline: + // `.obj` on Windows, `.o` elsewhere — see pipeline.rs + // get_output_filenames) + let obj = tmp.path().join(format!( + "{name}.{}", + if cfg!(windows) { "obj" } else { "o" } + )); let exe = tmp.path().join(format!("{name}_test_binary")); let runtime_lib = runtime_lib_path(); @@ -438,6 +443,38 @@ fn main(): \tif s[0:1] == \"7\": \t\tprint(v) \tprint(s) +", + ), + ( + // Field-base slice of an inline (SSO) field: promote-on-view + // promotes the field in place (the struct's slot is the + // owner-side storage), the view reads the promoted buffer, + // and the struct drop frees it exactly once. + "slice_of_struct_field_inline", + "\ +struct Person: +\tname: str + +fn main(): +\tp = Person{name=int_to_str(42)} +\tv = p.name[0:1] +\tprint(v) +\tprint(p.name) +", + ), + ( + // Field-base slice of a heap field: the view projects the + // STRUCT's storage, so the struct outlives the view (P2 + // freeze) — a drop at the field read would dangle the view. + "slice_of_struct_field_heap", + "\ +struct Person: +\tname: str + +fn main(): +\tp = Person{name=\"the quick brown fox\" + int_to_str(7)} +\tv = p.name[0:3] +\tprint(v) ", ), ( diff --git a/ryo/tests/integration_driver.rs b/ryo/tests/integration_driver.rs index 6214d72..e23eb4d 100644 --- a/ryo/tests/integration_driver.rs +++ b/ryo/tests/integration_driver.rs @@ -417,11 +417,42 @@ fn ir_emit_default_is_ast_and_clif() { ); } +/// Slot discipline pin: every explicit stack slot is a 24-byte +/// STR_SLOT_SIZE slot, and their total count is exactly `expected` — +/// one per inline-extraction site (`emit_fat_bytes_ptr_len` allocates +/// a fresh scratch slot per extraction so nested evaluation cannot +/// clobber a live spill), one per slot-out producer call site +/// (`emit_slot_out_call`), and one per promote-on-view site +/// (`emit_ensure_heap_for_view_base`). The exact count keeps +/// unexpected slot growth from creeping in unnoticed. +fn assert_explicit_24byte_slots(clif: &str, expected: usize) { + let slot_lines: Vec<&str> = clif + .lines() + .filter(|l| l.contains("explicit_slot")) + .collect(); + assert_eq!( + slot_lines.len(), + expected, + "unexpected explicit-slot count (want {expected}): {clif}" + ); + for (i, line) in slot_lines.iter().enumerate() { + assert!( + line.contains("explicit_slot 24"), + "stack slot {i} must be 24 bytes: {clif}" + ); + } +} + #[test] -fn clif_string_ops_use_packed_return_no_stack_slots() { - // Phase 0 runtime ABI: string-producing runtime calls return - // {ptr, len} packed in one u128 — no per-call-site stack slots, - // no out-pointer, no reload (spec 2026-08-25 §2 amendment). +fn clif_string_ops_slot_out_producers() { + // Slot-out runtime ABI: string producers (`int_to_str`, from_view, + // conversions, concat) write a tagged 24-byte slot passed as arg 0 + // and return nothing; literals and slices still return {ptr, len} + // packed in one u128. Slots in this program: 5 extraction scratch + // slots (the `"a" + "b"` operands, the `s + t` operands, and the + // print arg — one fresh slot per extraction site) + 3 slot-out + // call slots (the `"a" + "b"` concat, `int_to_str`, and the + // `s + t` concat). let temp_dir = TempDir::new().expect("Failed to create temp directory"); let test_file = create_test_file( temp_dir.path(), @@ -440,27 +471,20 @@ fn clif_string_ops_use_packed_return_no_stack_slots() { assert!( stdout.contains("-> i128"), - "runtime string calls must return the packed u128 pair: {}", - stdout - ); - assert!( - !stdout.contains("explicit_slot"), - "string call paths must not allocate stack slots: {}", - stdout - ); - assert!( - !stdout.contains("stack_addr"), - "string call paths must not take stack-slot addresses: {}", + "literal runtime calls still return the packed u128 pair: {}", stdout ); + assert_explicit_24byte_slots(&stdout, 8); } #[test] -fn clif_bytes_ops_use_packed_return_no_stack_slots() { - // M8.4.2 rides the Phase 0 runtime ABI: bytes-producing runtime - // calls return {ptr, len} packed in one u128 — no per-call-site - // stack slots, no out-pointer, no reload (same pin as the str twin - // above; the bytes_push slot ABI is not exercised by this program). +fn clif_bytes_ops_slot_out_producers() { + // M8.4.2 twin of the str pin above. Slots in this program: 5 + // extraction scratch slots (the concat operands, `b.len()`, + // `c.len()`, and the print arg — one fresh slot per extraction + // site) + 1 promote-on-view slot (the `b[0:1]` slice base) + 3 + // slot-out call slots (the concat, `bytes(...)`, and + // `int_to_str(...)`). let temp_dir = TempDir::new().expect("Failed to create temp directory"); let test_file = create_test_file( temp_dir.path(), @@ -479,19 +503,10 @@ fn clif_bytes_ops_use_packed_return_no_stack_slots() { assert!( stdout.contains("-> i128"), - "runtime bytes calls must return the packed u128 pair: {}", - stdout - ); - assert!( - !stdout.contains("explicit_slot"), - "bytes call paths must not allocate stack slots: {}", - stdout - ); - assert!( - !stdout.contains("stack_addr"), - "bytes call paths must not take stack-slot addresses: {}", + "literal/slice runtime calls still return the packed u128 pair: {}", stdout ); + assert_explicit_24byte_slots(&stdout, 9); } #[test] diff --git a/ryo/tests/integration_ownership.rs b/ryo/tests/integration_ownership.rs index 7f4d614..64f30cf 100644 --- a/ryo/tests/integration_ownership.rs +++ b/ryo/tests/integration_ownership.rs @@ -1107,6 +1107,26 @@ fn heap_str_last_use_in_loop_slice_comparison() { ); } +#[test] +fn slice_then_reassign_while_view_live_rejected() { + // P2 freeze (final spec §3.2): `v`'s last use is after the + // reassign-concat, so the slice projection is live at `s = s + ...`. + // The consuming-concat fast path (in-place append) must not bypass + // this check — reassignment of a projected owner stays E0035. + let temp_dir = TempDir::new().expect("temp"); + let code = "fn main():\n\tmut s: str = int_to_str(12345)\n\tv = s[1:3]\n\ts = s + \"678901234567890123456789\"\n\tprint(v)\n"; + let test_file = create_test_file(temp_dir.path(), "freeze_reassign_concat.ryo", code); + let output = run_ryo_command(&["run", "freeze_reassign_concat.ryo"], &test_file).expect("run"); + assert!(!output.status.success(), "expected compile error"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("E0035"), "expected E0035: {}", stderr); + assert!( + stderr.contains("cannot mutate `s` while a slice of it is live"), + "expected freeze message: {}", + stderr + ); +} + #[test] fn heap_str_last_use_in_inline_assert() { // `assert(s.len() == ...)` as the last use of a concat-built string: diff --git a/ryo/tests/integration_sso.rs b/ryo/tests/integration_sso.rs new file mode 100644 index 0000000..73499e1 --- /dev/null +++ b/ryo/tests/integration_sso.rs @@ -0,0 +1,180 @@ +mod common; + +use std::process::Command; + +fn run_ryo(source: &str, name: &str) -> String { + let (_tmp, exe) = common::build_and_link(source, name, &[]); + let out = Command::new(exe).output().expect("run"); + assert!( + out.status.success(), + "{name} exited {:?}: {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("utf8 stdout") +} + +#[test] +fn string_building_loop_is_correct() { + // Consuming reassign-concat loop: the string_building benchmark shape. + let src = "\ +fn main(): +\tmut s: str = \"\" +\tfor i in range(0, 1000): +\t\ts = s + \"x\" +\tassert(s.len() == 1000, \"len must be 1000\") +\tprint(\"ok\\n\") +"; + assert_eq!(run_ryo(src, "sso_string_building"), "ok\n"); +} + +#[test] +fn doubling_concat_stays_correct() { + // Aliasing exclusion: s = s + s must keep the allocating path. + let src = "\ +fn main(): +\tmut s: str = \"a\" +\tfor i in range(0, 5): +\t\ts = s + s +\tassert(s.len() == 32, \"len must be 32\") +\tprint(s) +\tprint(\"\\n\") +"; + assert_eq!( + run_ryo(src, "sso_doubling"), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + ); +} + +#[test] +fn bytes_concat_and_push_across_representations() { + // Consuming reassign-concat + bytes_push on an inline (SSO) bytes value: + // both must read the inline data bytes, never a raw cap word. + let src = "\ +fn main(): +\tmut b: bytes = b\"ab\" +\tb = b + b\"cd\" +\tbytes_push(&b, 101) +\tprint(b) +"; + assert_eq!(run_ryo(src, "sso_bytes_building"), "b\"abcde\""); +} + +#[test] +fn bytes_slice_of_short_owner_is_stable() { + // Slicing promotes the inline (SSO) base to heap before the view is + // taken: the view reads b"bc" from stable memory, and the owner + // still grows correctly afterwards. (Growing while the view is live + // is a compile-time ownership error, so the view is consumed first.) + let src = "\ +fn main(): +\tmut b: bytes = b\"abcdef\" +\tv = b[1:3] +\tprint(v) +\tprint(\"\\n\") +\tb = b + b\"ghijklmnopqr\" +\tprint(b) +"; + assert_eq!( + run_ryo(src, "sso_bytes_slice"), + "b\"bc\"\nb\"abcdefghijklmnopqr\"" + ); +} + +#[test] +fn mixed_static_inline_heap_concat() { + // One expression mixing all three representations: "user" is a + // static literal (cap==0), int_to_str(7) is inline (SSO), and the + // 44-byte result of the second concat is heap-allocated. + let src = "\ +fn main(): +\tname: str = \"user\" + int_to_str(7) +\tlong: str = name + \"-abcdefghijklmnopqrstuvwxyz0123456789\" +\tprint(name) +\tprint(\"\\n\") +\tprint(long) +\tprint(\"\\n\") +"; + assert_eq!( + run_ryo(src, "sso_mixed_concat"), + "user7\nuser7-abcdefghijklmnopqrstuvwxyz0123456789\n" + ); +} + +#[test] +fn slice_of_inline_str_then_owner_grows() { + // Slicing promotes the inline (SSO) base to heap before the view is + // taken, so the view reads from stable memory. Growing the owner + // while the view is live is a compile-time ownership error, so the + // view is consumed (printed) before the consuming reassign-concat. + let src = "\ +fn main(): +\tmut s: str = int_to_str(12345) +\tv = s[1:3] +\tprint(v) +\tprint(\"\\n\") +\ts = s + \"678901234567890123456789\" +\tprint(s) +\tprint(\"\\n\") +"; + assert_eq!( + run_ryo(src, "sso_slice_stable"), + "23\n12345678901234567890123456789\n" + ); +} + +#[test] +fn nested_inline_concat_and_eq_read_correct_bytes() { + // Inline (SSO) operands extracted inside a nested expression: the + // outer operand's scratch spill must survive evaluating the nested + // concat. `a + (b + c)` must read a's bytes, and `x == (y + z)` + // must compare x's bytes — not whatever the nested extraction + // spilled last. + let src = "\ +fn main(): +\ta = int_to_str(1) +\tb = int_to_str(2) +\tc = int_to_str(3) +\ts = a + (b + c) +\tprint(s) +\tprint(\"\\n\") +\tx = int_to_str(12) +\ty = int_to_str(1) +\tz = int_to_str(2) +\tif x == (y + z): +\t\tprint(\"equal\\n\") +\telse: +\t\tprint(\"not equal\\n\") +"; + assert_eq!(run_ryo(src, "sso_nested_scratch"), "123\nequal\n"); +} + +#[test] +fn struct_with_short_str_fields() { + // Inline (SSO) strings embedded in an aggregate: constructed from a + // static+inline concat, moved through a function, field-reassigned + // with an inline concat, and dropped. The struct drop glue's + // (ptr@off, cap@off+16) free path must no-op on inline tags, and + // the field-reassign free-on-reassign path must not free the old + // inline value. + let src = "\ +struct Person: +\tname: str +\tage: int + +fn birthday(move p: Person) -> Person: +\tmut r = p +\tr.age += 1 +\treturn r + +fn main(): +\tp = Person{name=\"user\" + int_to_str(42), age=30} +\tmut q = birthday(p) +\tq.name = q.name + \"!\" +\tprint(q.name) +\tprint(\" \") +\tprint(int_to_str(q.age)) +\tprint(\"\\n\") +"; + assert_eq!(run_ryo(src, "sso_struct_fields"), "user42! 31\n"); +} diff --git a/ryo/tests/valgrind_smoke.rs b/ryo/tests/valgrind_smoke.rs index 15b83ec..b901e8e 100644 --- a/ryo/tests/valgrind_smoke.rs +++ b/ryo/tests/valgrind_smoke.rs @@ -288,6 +288,22 @@ fn valgrind_str_materialize_copy() { ); } +#[test] +fn valgrind_slice_of_struct_field_inline() { + run_valgrind_smoke( + common::find_fixture("slice_of_struct_field_inline"), + "slice_of_struct_field_inline", + ); +} + +#[test] +fn valgrind_slice_of_struct_field_heap() { + run_valgrind_smoke( + common::find_fixture("slice_of_struct_field_heap"), + "slice_of_struct_field_heap", + ); +} + #[test] fn valgrind_bytes_ops() { run_valgrind_smoke(common::find_fixture("bytes_ops"), "bytes_ops");