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:
@@ -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 `" | |||||||