spec: heap-str-abi — amend, drop sentinel-rc-header story

hs.2's implementer-orchestrator BLOCKED with an empirical finding: the iter 18d.3 move-tracking and iter 18b non-escape lowering together prevent static-Str pointers from ever reaching ailang_rc_inc/_dec along any shipping execution path. The previously-proposed runtime sentinel guard and the i64 -1 sentinel slot in static-Str globals were both dead by construction.

Amendment is subtractive: remove the runtime/rc.c guard from §Architecture/Runtime layer + §Components/runtime/rc.c (none needed), shrink static-Str globals from <{i64, i64, [N+1 x i8]}> to <{i64, [N+1 x i8]}> (saves 8 bytes per literal), update GEP indices (i32 0, i32 1 → i32 0, i32 0), drop the §Error-handling 'Sentinel collision' subsection, drop the proposed str_field_in_adt_drops_static_str_noop safety-gate test from §Testing strategy (vacuous against natural fixtures), reframe §Architecture/Codegen-4 as 'codegen-level elision invariant, no runtime guard'. Goal/consumer-ABI claims tightened to 'unified along consumer paths, producer + RC sides differ'.

Acceptance: re-dispatched grounding-check PASS. The amendment leaves hs.1 (committed at c56498a) needing a forward-fix retrofit — that is the new hs.2's scope, planned next.
This commit is contained in:
2026-05-12 17:34:41 +02:00
parent 8ca602dc1f
commit 2a72a4ad68
+119 -91
View File
@@ -1,7 +1,7 @@
# Heap-`Str` ABI — Design Spec # Heap-`Str` ABI — Design Spec
**Date:** 2026-05-12 **Date:** 2026-05-12 (amended same day after hs.2 empirical finding)
**Status:** Draft — awaiting Step 7.5 grounding-check **Status:** Amended — awaiting re-dispatched Step 7.5 grounding-check
**Authors:** Brummel (orchestrator) + Claude **Authors:** Brummel (orchestrator) + Claude
## Goal ## Goal
@@ -9,8 +9,15 @@
Add a second realisation of `Str` values — `malloc`-backed, Add a second realisation of `Str` values — `malloc`-backed,
reference-counted heap slabs — alongside the existing static reference-counted heap slabs — alongside the existing static
`@.str_*` globals, *without* enlarging the language surface. `@.str_*` globals, *without* enlarging the language surface.
From the LLM-author's view, `Str` remains a single type; from From the LLM-author's view, `Str` remains a single type. The
the IR caller's view, a single ABI handles both realisations. two realisations share the **consumer ABI** (pointer-to-len-field,
`+8` for bytes), so `@puts` / `@ail_str_eq` / `@ail_str_compare` /
`@strcmp` work uniformly on either. The producer side differs
(static via constexpr GEP into a `.rodata` packed-struct global;
heap via `ailang_int_to_str` / `ailang_float_to_str`), and the RC
discipline applies only to heap-Str — static-Str pointers never
flow into `ailang_rc_inc` / `ailang_rc_dec` along any shipping
execution path. Codegen invariant, not runtime guard.
The milestone exists to unblock two builtins that are already The milestone exists to unblock two builtins that are already
visible at the type level but currently broken at codegen: visible at the type level but currently broken at codegen:
@@ -84,11 +91,14 @@ the length into the first 8 bytes of the payload, and returns
the payload pointer. The caller fills bytes at offset 8 and the the payload pointer. The caller fills bytes at offset 8 and the
`NUL` at offset `8 + len`. `NUL` at offset `8 + len`.
`runtime/rc.c` `ailang_rc_inc` and `ailang_rc_dec` gain a `runtime/rc.c` is **not modified** in this milestone. The
**sentinel-header fast path**: when `*header_of(payload) == UINT64_MAX`, runtime's RC primitives only ever see heap-Str pointers (real
both return immediately. This is the discriminator between `rc_header` at `payload - 8`, real refcount). Static-Str pointers
"heap-allocated, real refcount" and "static literal, treat as do not flow into `ailang_rc_inc` / `ailang_rc_dec` along any
immortal". shipping execution path — current codegen elides those calls
through two orthogonal optimisations (`emit_inlined_partial_drop`
move-tracking from iter 18d.3 + iter 18b non-escape lowering).
This is a codegen-level invariant; no runtime guard backs it up.
### Codegen layer (`crates/ailang-codegen/src/lib.rs`) ### Codegen layer (`crates/ailang-codegen/src/lib.rs`)
@@ -96,12 +106,19 @@ Four change-points.
1. **Static-Str globals** (`intern_string`, ~`crates/ailang-codegen/src/lib.rs:2487`): 1. **Static-Str globals** (`intern_string`, ~`crates/ailang-codegen/src/lib.rs:2487`):
emitted not as `[N+1 x i8] c"…\00"` but as a packed struct emitted not as `[N+1 x i8] c"…\00"` but as a packed struct
`<{i64, i64, [N+1 x i8]}>` with values `<{i64 -1, i64 N, c"…\00"}>`. `<{i64, [N+1 x i8]}>` with values `<{i64 N, c"…\00"}>`. The
The callsite pointer is `getelementptr` to the second field first field is the explicit byte length (excluding the
of this struct (= where the `len` slot starts), not to the terminating `NUL`); the array tail carries the bytes and the
slab start. The pointer that flows through the IR as a `Str` `NUL`. The callsite pointer is `getelementptr` to the first
value points at the *payload* (= len-field), exactly mirroring field of this struct (= where the `len` slot starts). The
the heap-Str case. pointer that flows through the IR as a `Str` value points at
the *len-field*; for heap-Str, the analogous pointer points at
the `len`-field of the malloc-backed payload (with the real
`rc_header` at `payload - 8`). The two layouts share the
*consumer ABI* (len at offset 0, bytes at offset 8) but
physically differ in what sits at `payload - 8` — heap-Str has
the rc_header there, static-Str has whatever the linker placed
before the global (which codegen guarantees is never read).
2. **`float_to_str` lowering** (~`crates/ailang-codegen/src/lib.rs:1829`): 2. **`float_to_str` lowering** (~`crates/ailang-codegen/src/lib.rs:1829`):
the `CodegenError::Internal` fork is replaced by the `CodegenError::Internal` fork is replaced by
@@ -111,25 +128,29 @@ Four change-points.
emits `call ptr @ailang_int_to_str(i64 %a)`. emits `call ptr @ailang_int_to_str(i64 %a)`.
4. **`drop_<m>_<T>` walks** (per-type drop fns) — no code change. 4. **`drop_<m>_<T>` walks** (per-type drop fns) — no code change.
`Str` fields are already included in the dec walk today: `Str` fields are dispatched today via `field_drop_call` at
`field_drop_call` at `crates/ailang-codegen/src/drop.rs:372` `crates/ailang-codegen/src/drop.rs:372`, which routes
maps `Type::Con { name: "Str", .. }` to bare `ailang_rc_dec`. `Type::Con { name: "Str", .. }` to bare `ailang_rc_dec`. The
The comment at that site explicitly justifies the routing on comment at that site justifies the routing on the (now stale)
the assumption that *"Str payloads are NUL-terminated bytes in assumption that *"Str payloads are NUL-terminated bytes in
static memory; nothing to recurse into"* — exactly the static memory; nothing to recurse into"* — true for static-Str,
assumption that breaks once heap-Str exists. With the current correct-by-coincidence for heap-Str (heap-Str's rc_header is
static-only path, dropping an ADT field of type `Str` therefore exactly the `rc_header` shape `ailang_rc_dec` expects).
already calls `ailang_rc_dec(static_str_ptr)`, which reads Empirically the dispatch site is **dead code along all
`header_of(payload) = payload - 8` — bytes that fall *outside* shipping execution paths** due to two orthogonal codegen
the static `[N x i8]` global. This is latent undefined optimisations: `emit_inlined_partial_drop` (iter 18d.3) marks
behaviour today; no shipping test exercises an ADT-with-Str- match-arm-extracted `Str` slots as "already moved" and skips
field under `--alloc=rc`, so it has not surfaced. The their decs; non-escape lowering (iter 18b) stack-allocates
sentinel-header check proposed in (item 1 above implicitly + ADTs that never leave their fn frame, eliminating the
the `runtime/rc.c` change below) turns that latent UB into a per-type drop call entirely. We rely on these elisions as the
defined no-op: with the new packed-struct global, `payload - 8` *codegen-level* invariant that static-Str payload pointers
lands on a valid `i64` slot whose value is `UINT64_MAX`, and never reach `ailang_rc_dec`. If either elision were lost, the
`ailang_rc_dec` short-circuits. **The change here is in call would read undefined bytes at `payload - 8` (the static
correctness, not in code: no edit to `drop.rs` is required.** global has no `rc_header` slot at that offset; the linker
places arbitrary bytes there) and likely segfault — a loud,
locatable failure mode the testing
discipline would catch immediately. No runtime guard backs up
the invariant; the codegen-level guarantee is the protection.
The existing extern declarations `@strcmp`, `@ail_str_eq`, The existing extern declarations `@strcmp`, `@ail_str_eq`,
`@ail_str_compare`, `@puts`, `@printf` stay. They still expect a `@ail_str_compare`, `@puts`, `@printf` stay. They still expect a
@@ -186,9 +207,10 @@ Add:
### `runtime/rc.c` ### `runtime/rc.c`
Modify `ailang_rc_inc` and `ailang_rc_dec`: after the existing Unchanged. The runtime's RC primitives only see heap-Str
`NULL` guard, add `if (*header_of(payload) == UINT64_MAX) return;` pointers along shipping execution paths; static-Str pointers
to short-circuit on the sentinel. are gated out at codegen via the elisions described in
§Architecture / Codegen layer item 4.
### `crates/ailang-codegen/src/lib.rs` ### `crates/ailang-codegen/src/lib.rs`
@@ -196,10 +218,11 @@ Modify:
- `intern_string` (~`:2487`) — switch the LLVM-IR emission of - `intern_string` (~`:2487`) — switch the LLVM-IR emission of
static-Str globals from `[N+1 x i8] c"…\00"` to static-Str globals from `[N+1 x i8] c"…\00"` to
`<{i64, i64, [N+1 x i8]}> <{i64 -1, i64 N, c"…\00"}>`. The `<{i64, [N+1 x i8]}> <{i64 N, c"…\00"}>` (single `i64` length
global name stays the same; the way callsites materialise a prefix; bytes + `NUL` in the array tail). The global name
pointer to it changes to a `getelementptr` landing on the stays the same; the way callsites materialise a pointer to it
`len`-field. changes to a `getelementptr` landing on the `len`-field
(= field index 0 of the struct).
- IR-header preamble (~`:455``:498`): add - IR-header preamble (~`:455``:498`): add
`declare ptr @ailang_int_to_str(i64)` and `declare ptr @ailang_int_to_str(i64)` and
`declare ptr @ailang_float_to_str(double)`. `declare ptr @ailang_float_to_str(double)`.
@@ -238,12 +261,15 @@ Add an `int_to_str` arm parallel to the `float_to_str` arm at
"What **is** supported": add `int_to_str : (Int) -> Str` next "What **is** supported": add `int_to_str : (Int) -> Str` next
to `float_to_str`. to `float_to_str`.
- New top-level subsection "Str ABI" (or under an appropriate - New top-level subsection "Str ABI" (or under an appropriate
Decision-10 / runtime-contracts anchor): document the unified Decision-10 / runtime-contracts anchor): document the heap-Str
slab layout `{rc_header, len, bytes…, NUL}`, the sentinel- slab layout `{rc_header, len, bytes…, NUL}` and the static-Str
header convention (`UINT64_MAX` = static / immortal), and the global layout `<{len, bytes…, NUL}>` (no rc_header, since the
codegen-level elision invariant guarantees the runtime's RC
primitives never see static-Str pointers). Both layouts share
the consumer ABI "pointer-at-len-field, +8 for bytes". The
inherited limitation that `==` / `<` use `strcmp` semantics inherited limitation that `==` / `<` use `strcmp` semantics
and do not support embedded `\0` bytes (this is a future- and do not support embedded `\0` bytes is noted as a future-
milestone enhancement, not a regression). milestone enhancement, not a regression.
## Data flow ## Data flow
@@ -260,11 +286,11 @@ Three representative IR cases pin the after-state.
After the milestone: After the milestone:
```llvm ```llvm
@.str_M_hello_0 = private unnamed_addr constant <{i64, i64, [6 x i8]}> @.str_M_hello_0 = private unnamed_addr constant <{i64, [6 x i8]}>
<{ i64 -1, i64 5, [6 x i8] c"hello\00" }> <{ i64 5, [6 x i8] c"hello\00" }>
%payload = getelementptr inbounds <{i64, i64, [6 x i8]}>, %payload = getelementptr inbounds <{i64, [6 x i8]}>,
ptr @.str_M_hello_0, i32 0, i32 1 ptr @.str_M_hello_0, i32 0, i32 0
%bytes = getelementptr inbounds i8, ptr %payload, i64 8 %bytes = getelementptr inbounds i8, ptr %payload, i64 8
%1 = call i32 @puts(ptr %bytes) %1 = call i32 @puts(ptr %bytes)
``` ```
@@ -297,29 +323,35 @@ define i1 @eq__Str(ptr %a, ptr %b) {
} }
``` ```
Either operand may be static (sentinel header) or heap (real Either operand may be a static-Str pointer (into a `.rodata`
header); the body does not distinguish. `@ail_str_eq` keeps its packed-struct global) or a heap-Str pointer (into a malloc'd
`strcmp`-based body unchanged. slab); the body does not distinguish, because the consumer ABI
(len at offset 0, bytes at offset 8) is identical and
`@ail_str_eq` only reads the bytes via `strcmp`.
**ADT drop with a `Str` field.** For `type Boxed = | Box(Str)`, **ADT drop with a `Str` field.** For `type Boxed = | Box(Str)`,
the per-type `drop_M_Boxed(ptr %cell)` already emits a the per-type `drop_M_Boxed(ptr %cell)` body in
`call void @ailang_rc_dec(ptr %str_field)` today — `Str` fields `crates/ailang-codegen/src/drop.rs` dispatches the `Str` field
are dispatched to `ailang_rc_dec` by `field_drop_call` regardless through `field_drop_call` to `ailang_rc_dec`. Empirically the
of whether the value originated from a static or a heap source. emitted call is **dead code along all shipping execution paths**
What changes is the *meaning* of that call on a static-Str due to two orthogonal codegen optimisations:
pointer: pre-milestone it reads bytes outside the `[N x i8]` `emit_inlined_partial_drop` (iter 18d.3) marks match-arm-extracted
global (latent UB; no shipping test exercises the path); `Str` slots as moved-out and skips their decs, and non-escape
post-milestone it reads the new global's leading `i64 -1` slot, lowering (iter 18b) stack-allocates ADTs that never leave the
hits the sentinel short-circuit in `ailang_rc_dec`, and returns fn frame, eliminating the per-type drop call entirely. The
without modifying anything. For a heap-Str field, the normal codegen never produces an execution path that hands a static-Str
refcount drop runs unchanged. The single behavioural shift — pointer to `ailang_rc_dec`. We rely on this as a codegen-level
from latent UB to defined no-op on static fields — is gated by invariant — no runtime guard backs it up. If a future codegen
the new `str_field_in_adt_drops_static_str_noop` E2E test (see change ever loses one of the two elisions, the resulting
§Testing strategy). `ailang_rc_dec(static_str_ptr)` call would read undefined memory
at `payload - 8` (the static-Str global has no `rc_header` slot)
and likely segfault — a loud failure that the test suite would
flag immediately, leading the implementer of that codegen change
to inspect the dispatch site.
## Error handling ## Error handling
Four failure modes; most are inherited or structurally impossible. Three failure modes; most are inherited or structurally impossible.
**Out-of-memory at heap-Str allocation.** `ailang_rc_alloc` **Out-of-memory at heap-Str allocation.** `ailang_rc_alloc`
already `abort()`s on OOM, matching Boehm. `str_alloc`, already `abort()`s on OOM, matching Boehm. `str_alloc`,
@@ -336,13 +368,6 @@ the buffer). The defensive check survives format-string changes
that might widen the output without anyone noticing the buffer that might widen the output without anyone noticing the buffer
is too small. is too small.
**Sentinel collision.** A real refcount can never reach
`UINT64_MAX``2^64` references would require more memory than
exists on any machine the program can run on. The convention
`rc_header == UINT64_MAX` ⇒ static is structurally safe; no
runtime check beyond the equality test in `inc`/`dec` is needed.
DESIGN.md §"Str ABI" records this as an invariant.
**Embedded `\0` bytes in static-Str literals from JSON.** Status **Embedded `\0` bytes in static-Str literals from JSON.** Status
quo: a literal containing `""` ends up with a `NUL` in the quo: a literal containing `""` ends up with a `NUL` in the
middle of the slab; `strcmp`-based comparison treats it as the middle of the slab; `strcmp`-based comparison treats it as the
@@ -369,8 +394,9 @@ Three layers.
Pin the structural guarantees of §"Data flow" with substring Pin the structural guarantees of §"Data flow" with substring
matches against emitted IR: matches against emitted IR:
- `static_str_global_uses_packed_struct_with_sentinel_and_len` - `static_str_global_uses_packed_struct_with_len` pins the
pins the new slab format. new slab format (single `i64` length prefix, bytes + `NUL` in
the array tail).
- `static_str_callsite_pointer_is_payload_not_slab` — pins the - `static_str_callsite_pointer_is_payload_not_slab` — pins the
`getelementptr`-to-second-field convention. `getelementptr`-to-second-field convention.
- `eq_str_calls_ail_str_eq_with_bytes_pointer`, - `eq_str_calls_ail_str_eq_with_bytes_pointer`,
@@ -417,14 +443,15 @@ Catch leaks and double-frees:
- `str_field_in_adt_drops_heap_str_correctly` — a value of - `str_field_in_adt_drops_heap_str_correctly` — a value of
`type Boxed = | Box(Str)` is instantiated with a heap-Str `type Boxed = | Box(Str)` is instantiated with a heap-Str
(from `int_to_str`) and dropped; `live=0`. (from `int_to_str`) and dropped; `live=0`.
- `str_field_in_adt_drops_static_str_noop` — same ADT No `str_field_in_adt_drops_static_str_noop` test ships. An
instantiated with a static literal under `--alloc=rc`; `live=0`, earlier draft of this spec proposed it as a load-bearing safety
no crash. **Load-bearing:** no precursor test today exercises gate for a runtime sentinel short-circuit; empirically the
this path (the existing `alloc_rc_*` tests do not instantiate codegen elides the static-Str-field-drop path entirely via
an ADT with a `Str` field), so today's behaviour on this path move-tracking and non-escape lowering, so the test could not be
is undefined. The test gates the milestone-level safety made non-vacuous against any natural fixture shape. The
property that static-Str pointers in ADT fields drop safely elision-based invariant is documented in §Architecture /
via the sentinel short-circuit. Codegen layer item 4 and stands or falls with the elision
itself, not with a runtime test.
### Bench gates ### Bench gates
@@ -466,13 +493,14 @@ The milestone closes when all the following are observable.
- `runtime/str.c` exports `ailang_int_to_str(int64_t) -> char*` - `runtime/str.c` exports `ailang_int_to_str(int64_t) -> char*`
and `ailang_float_to_str(double) -> char*`. The private and `ailang_float_to_str(double) -> char*`. The private
`str_alloc(uint64_t)` is *not* in the export symbol set. `str_alloc(uint64_t)` is *not* in the export symbol set.
- `runtime/rc.c` `ailang_rc_inc`/`ailang_rc_dec` short-circuit - `runtime/rc.c` is unchanged from pre-milestone.
on `*header == UINT64_MAX`. - Static-Str globals are emitted as `<{i64, [N+1 x i8]}>` with
- Static-Str globals are emitted as values `{i64 N, c"…\00"}`.
`<{i64, i64, [N+1 x i8]}>` with values `{i64 -1, i64 N, c"…\00"}`.
- DESIGN.md has a "Str ABI" anchor (or equivalent) documenting - DESIGN.md has a "Str ABI" anchor (or equivalent) documenting
the unified layout, sentinel convention, and inherited the heap-Str and static-Str layouts, the shared consumer ABI
`strcmp`-based comparison semantics. (len at offset 0, bytes at offset 8), the codegen-level
elision invariant for static-Str-in-RC-paths, and the
inherited `strcmp`-based comparison semantics.
**Observable program behaviour.** **Observable program behaviour.**