plan: hs.2 — rewrite for static-Str sentinel-slot retrofit, 3 tasks
Supersedes the defunct hs.2 plan at8ca602d(runtime-sentinel + Str-in-ADT drop safety). After the spec amendment at2a72a4adropped the sentinel-rc-header story, the iter scope is now a surgical retrofit of hs.1's emission: drop the leading i64 sentinel slot from static-Str globals, land the IR-Str pointer on the (now first) len-field via GEP (i32 0, i32 0). The four +8 consumer GEPs are invariant across the switch. Three tasks: (1) update the two IR-shape tests to the new layout (RED); (2) flip three format-string sites + two doc-comments in lib.rs (GREEN); (3) regenerate hello.ll snapshot + workspace + cross_lang + compile_check + check.
This commit is contained in:
+422
-360
@@ -1,418 +1,480 @@
|
||||
# hs.2 — Heap-Str ABI: runtime sentinel + Str-in-ADT drop safety — Implementation Plan
|
||||
# hs.2 — Static-Str sentinel-slot retrofit — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/2026-05-12-heap-str-abi.md`
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
|
||||
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Make `ailang_rc_inc` and `ailang_rc_dec` short-circuit on
|
||||
a `UINT64_MAX` sentinel header, so the static-`Str` slabs emitted
|
||||
by hs.1's packed-struct layout (whose first slot is `i64 -1`) flow
|
||||
safely through generic RC paths instead of attempting to write
|
||||
back to `.rodata` memory. Add the load-bearing safety test
|
||||
`str_field_in_adt_drops_static_str_noop`, which is the first test
|
||||
in the project to exercise the path "ADT with a `Str` field,
|
||||
instantiated with a static literal, dropped under `--alloc=rc`".
|
||||
**Goal:** Retrofit hs.1's emitted static-`Str` LLVM globals from the
|
||||
sentinel-rc-header layout `<{i64, i64, [N x i8]}> <{i64 -1, i64 N, c"…\0"}>`
|
||||
to the amended-spec layout `<{i64, [N x i8]}> <{i64 N, c"…\0"}>`. The
|
||||
sentinel rc-header slot is removed; the explicit `len` field becomes
|
||||
the first (and only non-byte) field of the packed struct, on which the
|
||||
IR-`Str` pointer lands. The `+8` consumer-side GEPs at the four C-API
|
||||
callsites do **not** change.
|
||||
|
||||
**Architecture:** Pure runtime-side change plus one new fixture
|
||||
and one new e2e test. The hs.1 codegen change emits static-Str
|
||||
globals whose first `i64` slot is `-1` (= `UINT64_MAX` when read
|
||||
as `uint64_t`). The hs.1 IR-Str-pointer convention places the
|
||||
flowing pointer at the `len`-field of the struct, so
|
||||
`header_of(payload) = payload - 8` lands on that sentinel slot.
|
||||
Pre-hs.2, `ailang_rc_dec(static_str_field_ptr)` reads
|
||||
`*hdr = UINT64_MAX` (passes the underflow check at `runtime/rc.c:166`
|
||||
since `UINT64_MAX != 0`), then `*hdr -= 1` attempts to write
|
||||
`UINT64_MAX - 1` to the read-only data section → SIGSEGV. hs.2
|
||||
inserts a sentinel check that short-circuits both functions
|
||||
before either reads or writes the header value any further, making
|
||||
the static-`Str` case a defined no-op. `ailang_rc_alloc`'s counters
|
||||
are untouched on the short-circuit path (`g_rc_alloc_count` only
|
||||
fires inside `ailang_rc_alloc` itself; `g_rc_free_count` only fires
|
||||
on the to-zero branch in `ailang_rc_dec`, after the sentinel check)
|
||||
— so `AILANG_RC_STATS`-based tests continue to report accurate
|
||||
allocs/frees for the heap path while seeing zero traffic from
|
||||
static `Str` literals.
|
||||
**Architecture:** Three format-string sites in `crates/ailang-codegen/src/lib.rs`
|
||||
(one global emission, two `Literal::Str` callsite GEPs) flip from the
|
||||
3-field struct shape to the 2-field shape and from GEP-index `(0,1)` to
|
||||
`(0,0)`. Two IR-shape tests in the same crate's test module update
|
||||
their asserts (one also renames). The `crates/ail/tests/snapshots/hello.ll`
|
||||
snapshot regenerates via `UPDATE_SNAPSHOTS=1`. No runtime changes, no
|
||||
checker changes, no new fixtures.
|
||||
|
||||
**Tech Stack:** C runtime (`runtime/rc.c`), AILang fixture
|
||||
(`examples/`), Rust e2e harness (`crates/ail/tests/e2e.rs`).
|
||||
**Tech Stack:** `crates/ailang-codegen` (Rust, LLVM-IR string emission),
|
||||
`crates/ail` (snapshot test harness). Pure Rust; no C runtime touched.
|
||||
|
||||
**Recon note:** Recon agent bypassed per `CLAUDE.md` "When NOT to
|
||||
delegate" — the change surface is five string-literal sites in one
|
||||
file plus one snapshot file, and all five sites were located and
|
||||
quoted verbatim from disk during plan-writing.
|
||||
|
||||
---
|
||||
|
||||
## Files this plan creates or modifies
|
||||
|
||||
- **Create:** `examples/rc_str_field_in_adt_static.ail.json` — new
|
||||
fixture mirroring `examples/rc_box_drop.ail.json` shape with a
|
||||
`Str` field in place of an `Int` field. Exercises the
|
||||
static-`Str`-in-ADT drop path under `--alloc=rc`.
|
||||
- **Modify:** `runtime/rc.c:132-145` — add sentinel-header
|
||||
short-circuit to `ailang_rc_inc`.
|
||||
- **Modify:** `runtime/rc.c:161-177` — add sentinel-header
|
||||
short-circuit to `ailang_rc_dec`.
|
||||
- **Test:** `crates/ail/tests/e2e.rs` (append at end of file, after
|
||||
current line 2561) — new test `str_field_in_adt_drops_static_str_noop`
|
||||
using the existing `build_and_run_with_rc_stats` helper at
|
||||
`crates/ail/tests/e2e.rs:2093-2150`.
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:462-475` — global-emission
|
||||
loop (the `all_str_literals` aggregation that writes
|
||||
`@<name> = private unnamed_addr constant <{ i64, i64, [...] }> ...`)
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:924-937` — first
|
||||
`Literal::Str` arm in `emit_const_def`, constexpr-GEP format string
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1246-1259` — second
|
||||
`Literal::Str` arm in `lower_term`, constexpr-GEP format string
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:561-565` — doc-comment on
|
||||
the `str_literals` field describing the layout (mentions
|
||||
`UINT64_MAX` sentinel rc-header; that detail must go)
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:2596-2600` — doc-comment on
|
||||
`intern_str_literal` describing the layout (same correction)
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:3808-3849` — IR-shape test
|
||||
`static_str_global_uses_packed_struct_with_sentinel_and_len`: rename
|
||||
+ doc-comment update + assertion-substring update
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:3851-3885` — IR-shape test
|
||||
`static_str_callsite_pointer_is_payload_via_constexpr_gep`:
|
||||
doc-comment update + assertion-substring update
|
||||
- Modify: `crates/ail/tests/snapshots/hello.ll` — regenerate via
|
||||
`UPDATE_SNAPSHOTS=1`; expected bytes flip from sentinel-prefixed
|
||||
to len-prefixed shape
|
||||
- Untouched: the four `+8 GEP` pinning tests
|
||||
(`print_str_calls_puts_with_bytes_pointer`,
|
||||
`eq_str_calls_ail_str_eq_with_bytes_pointer`,
|
||||
`compare_str_calls_ail_str_compare_with_bytes_pointer`,
|
||||
`lower_eq_str_calls_strcmp_with_bytes_pointer`) — consumer ABI is
|
||||
invariant across the layout switch
|
||||
- Untouched: any `runtime/*.c` — no runtime change
|
||||
- Untouched: any test fixture under `examples/` — observable program
|
||||
output is invariant
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Author the static-Str-in-ADT fixture and confirm it builds
|
||||
|
||||
The new test in Task 2 requires a `.ail.json` fixture exercising
|
||||
`type StrBox = | MkStrBox(Str)` with a static-`Str` payload under
|
||||
`--alloc=rc`. This task creates that fixture and confirms it
|
||||
typechecks and builds without errors (running the binary is
|
||||
deferred to Task 2 — pre-hs.2 the binary would crash under
|
||||
`--alloc=rc`).
|
||||
## Task 1 — Update IR-shape tests to the amended-spec layout (RED)
|
||||
|
||||
**Files:**
|
||||
- Create: `examples/rc_str_field_in_adt_static.ail.json`
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:3808-3885`
|
||||
|
||||
- [ ] **Step 1: Create the fixture.**
|
||||
- [ ] **Step 1: Rename and rewrite assert in
|
||||
`static_str_global_uses_packed_struct_with_sentinel_and_len`**
|
||||
|
||||
Write the file at `examples/rc_str_field_in_adt_static.ail.json`
|
||||
with this exact content, modelled verbatim on
|
||||
`examples/rc_box_drop.ail.json` with `Int` → `Str` and
|
||||
`io/print_int` → `io/print_str`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "ailang/v0",
|
||||
"name": "rc_str_field_in_adt_static",
|
||||
"imports": [],
|
||||
"defs": [
|
||||
{
|
||||
"kind": "type",
|
||||
"name": "StrBox",
|
||||
"vars": [],
|
||||
"doc": "Single-cell ADT around a Str. Iter hs.2 RC fixture: exercises the static-Str-in-ADT drop path under --alloc=rc. Pre-hs.2 the binary segfaults at drop time because `ailang_rc_dec(static_str_payload_ptr)` reads UINT64_MAX from the sentinel header slot and attempts to write UINT64_MAX-1 to .rodata. Post-hs.2 `ailang_rc_dec` short-circuits on the sentinel; the drop is a defined no-op and the outer StrBox cell frees normally.",
|
||||
"ctors": [
|
||||
{
|
||||
"name": "MkStrBox",
|
||||
"fields": [{ "k": "con", "name": "Str" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "fn",
|
||||
"name": "main",
|
||||
"type": {
|
||||
"k": "fn",
|
||||
"params": [],
|
||||
"ret": { "k": "con", "name": "Unit" },
|
||||
"effects": ["IO"]
|
||||
},
|
||||
"params": [],
|
||||
"doc": "Bind a heap-allocated MkStrBox(\"hello\") to `b`, read its Str via match, print it. Under --alloc=rc the let-scope close calls drop_t_StrBox(b), which in turn calls ailang_rc_dec on the Str field — that pointer is the static-Str literal global from hs.1, so the sentinel-header short-circuit must make this safe.",
|
||||
"body": {
|
||||
"t": "let",
|
||||
"name": "b",
|
||||
"value": {
|
||||
"t": "ctor",
|
||||
"type": "StrBox",
|
||||
"ctor": "MkStrBox",
|
||||
"args": [{ "t": "lit", "lit": { "kind": "str", "value": "hello" } }]
|
||||
},
|
||||
"body": {
|
||||
"t": "match",
|
||||
"scrutinee": { "t": "var", "name": "b" },
|
||||
"arms": [
|
||||
{
|
||||
"pat": {
|
||||
"p": "ctor",
|
||||
"ctor": "MkStrBox",
|
||||
"fields": [{ "p": "var", "name": "s" }]
|
||||
},
|
||||
"body": {
|
||||
"t": "do",
|
||||
"op": "io/print_str",
|
||||
"args": [{ "t": "var", "name": "s" }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm the fixture typechecks.**
|
||||
|
||||
Run: `cargo run --bin ail --quiet -- check examples/rc_str_field_in_adt_static.ail.json`
|
||||
Expected: exit 0, no diagnostics on stderr. The fixture follows
|
||||
the canonical-form schema verbatim; any diagnostic would indicate
|
||||
a copy-paste error.
|
||||
|
||||
- [ ] **Step 3: Confirm the fixture builds under `--alloc=rc`.**
|
||||
|
||||
Run: `cargo run --bin ail --quiet -- build examples/rc_str_field_in_adt_static.ail.json --alloc=rc -o /tmp/hs2_fixture_build_check`
|
||||
Expected: exit 0; a binary appears at `/tmp/hs2_fixture_build_check`.
|
||||
**Do NOT execute the binary in this step** — pre-hs.2 it segfaults
|
||||
on the static-Str field drop. The Task 2 RED-test step runs it.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Sentinel-header short-circuit in `ailang_rc_inc` and `ailang_rc_dec` (TDD)
|
||||
|
||||
This is the heart of the iter. The RED test is the
|
||||
spec's load-bearing `str_field_in_adt_drops_static_str_noop`
|
||||
safety gate — until hs.2's runtime change lands, no test in the
|
||||
project exercises this path, and the path is latent UB.
|
||||
|
||||
**Files:**
|
||||
- Modify: `runtime/rc.c:132-145, 161-177`
|
||||
- Test: `crates/ail/tests/e2e.rs` (append after current line 2561)
|
||||
|
||||
- [ ] **Step 1: Write the failing test `str_field_in_adt_drops_static_str_noop`.**
|
||||
|
||||
Append to `crates/ail/tests/e2e.rs` at the end of the file:
|
||||
Locate the test at `crates/ailang-codegen/src/lib.rs:3816`. Replace the
|
||||
test function name, its doc-comment immediately above (lines
|
||||
3810-3814), and the inner `assert!`-substring. After the edit the test
|
||||
reads:
|
||||
|
||||
```rust
|
||||
/// Iter hs.2 load-bearing safety gate. The hs.1 layout migration
|
||||
/// turned the path "ADT-with-Str-field instantiated with a static
|
||||
/// literal, dropped under --alloc=rc" into latent UB: the
|
||||
/// per-type drop fn calls `ailang_rc_dec(field_ptr)` on the
|
||||
/// static-Str payload pointer, which under hs.1 lands on the
|
||||
/// `len`-field of the packed-struct global; `header_of(ptr) =
|
||||
/// ptr - 8` reads the sentinel slot (= UINT64_MAX); the
|
||||
/// underflow check `*hdr == 0` passes (UINT64_MAX != 0); then
|
||||
/// `*hdr -= 1` attempts to write to the read-only data section.
|
||||
/// hs.2 short-circuits both `ailang_rc_inc` and `ailang_rc_dec`
|
||||
/// on the sentinel before the header is mutated, making the
|
||||
/// path a defined no-op. This test is the first in the project
|
||||
/// to exercise it: pre-hs.2 the binary segfaults; post-hs.2 it
|
||||
/// reports `live=0` and exits cleanly.
|
||||
/// Iter hs.2: language `Str` literals emit as packed-struct globals
|
||||
/// carrying an explicit `len` field followed by the bytes + trailing
|
||||
/// NUL. (The hs.1-era sentinel rc-header slot was removed per the
|
||||
/// amended spec; static-Str pointers are kept out of `ailang_rc_dec`
|
||||
/// at the codegen level via move-tracking and non-escape lowering,
|
||||
/// so no header slot is needed.) This test pins the layout shape
|
||||
/// against a tiny single-`Literal::Str` fixture.
|
||||
#[test]
|
||||
fn str_field_in_adt_drops_static_str_noop() {
|
||||
let (stdout, allocs, frees, live) =
|
||||
build_and_run_with_rc_stats("rc_str_field_in_adt_static.ail.json");
|
||||
assert_eq!(
|
||||
stdout.trim(),
|
||||
"hello",
|
||||
"fixture must print the matched Str payload"
|
||||
);
|
||||
assert_eq!(
|
||||
live, 0,
|
||||
"static-Str-in-ADT drop must not leak; live={live} \
|
||||
(allocs={allocs} frees={frees}). The sentinel short-circuit \
|
||||
in ailang_rc_dec for static-Str field pointers must not \
|
||||
increment g_rc_free_count, and the outer StrBox cell must \
|
||||
drop normally."
|
||||
fn static_str_global_uses_packed_struct_with_len() {
|
||||
let m = Module {
|
||||
schema: SCHEMA.into(),
|
||||
name: "t".into(),
|
||||
imports: vec![],
|
||||
defs: vec![
|
||||
Def::Const(ConstDef {
|
||||
name: "greeting".into(),
|
||||
ty: Type::str_(),
|
||||
value: Term::Lit { lit: Literal::Str { value: "hello".into() } },
|
||||
doc: None,
|
||||
}),
|
||||
Def::Fn(FnDef {
|
||||
name: "main".into(),
|
||||
ty: Type::Fn {
|
||||
params: vec![],
|
||||
ret: Box::new(Type::unit()),
|
||||
effects: vec![],
|
||||
param_modes: vec![],
|
||||
ret_mode: ParamMode::Implicit,
|
||||
},
|
||||
params: vec![],
|
||||
body: Term::Lit { lit: Literal::Unit },
|
||||
suppress: vec![],
|
||||
doc: None,
|
||||
}),
|
||||
],
|
||||
};
|
||||
let ir = emit_ir(&m).unwrap();
|
||||
assert!(
|
||||
ir.contains(r#"<{ i64, [6 x i8] }> <{ i64 5, [6 x i8] c"hello\00" }>"#),
|
||||
"expected packed-struct global with len + bytes + NUL; ir was:\n{ir}"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails.**
|
||||
The two changes versus hs.1: (a) function name drops the
|
||||
`_with_sentinel_and_len` qualifier and gains `_with_len`; (b) the
|
||||
assert-substring drops the leading `i64, ` from the struct type and
|
||||
the leading `i64 -1, ` from the initialiser.
|
||||
|
||||
Run: `cargo test --workspace -p ail str_field_in_adt_drops_static_str_noop`
|
||||
Expected: FAIL. The most likely failure mode is the assertion
|
||||
`output.status.success()` inside `build_and_run_with_rc_stats`
|
||||
at `crates/ail/tests/e2e.rs:2119-2123` panicking with
|
||||
`binary <path> (--alloc=rc, AILANG_RC_STATS=1) exited non-zero`,
|
||||
because the binary either SIGSEGVs writing to `.rodata` or
|
||||
gets caught by `ailang_rc_dec`'s `abort()` on a write attempt.
|
||||
A secondary possible failure mode is the rc-stats line being
|
||||
absent from stderr because the program crashed before the
|
||||
`atexit` handler ran — in that case the panic is at
|
||||
`crates/ail/tests/e2e.rs:2129-2131`
|
||||
(`missing ailang_rc_stats line in stderr`). Either failure
|
||||
shape is the expected RED state; both go GREEN once Step 3
|
||||
lands.
|
||||
- [ ] **Step 2: Update assert in
|
||||
`static_str_callsite_pointer_is_payload_via_constexpr_gep`**
|
||||
|
||||
- [ ] **Step 3: Add the sentinel-header short-circuit to `ailang_rc_inc`.**
|
||||
Locate the test at `crates/ailang-codegen/src/lib.rs:3856`. The
|
||||
function name stays — "payload" remains correct since the IR-Str
|
||||
pointer still lands on the `len`-field slot, which is the payload
|
||||
start. Replace its doc-comment (lines 3851-3854) and the inner
|
||||
`assert!`-substring:
|
||||
|
||||
Modify `runtime/rc.c:132-145`. The current body is:
|
||||
|
||||
```c
|
||||
void ailang_rc_inc(void *payload) {
|
||||
if (payload == NULL) {
|
||||
return;
|
||||
}
|
||||
/* Static closure-pair env pointers (Iter 8b) live in the LLVM data
|
||||
* segment, not in heap memory we allocated. Codegen elides inc/dec
|
||||
* for known-static pointers (the `@`-prefix gate added in 18c.3),
|
||||
* so this path is not reached for them in practice. The runtime
|
||||
* itself has no header-bit flag distinguishing static from heap;
|
||||
* if codegen ever loses the elision, inc on a static pointer is
|
||||
* undefined behaviour. */
|
||||
ailang_rc_header_t *hdr = header_of(payload);
|
||||
*hdr += 1;
|
||||
```rust
|
||||
/// Iter hs.2: a `Literal::Str` at a callsite materialises a constexpr
|
||||
/// `getelementptr` landing on the `len`-field of the packed-struct
|
||||
/// global (now the *first* field, since the hs.1-era sentinel
|
||||
/// rc-header slot was removed per the amended spec). This pins the
|
||||
/// IR-Str-pointer convention used uniformly by all consumers.
|
||||
#[test]
|
||||
fn static_str_callsite_pointer_is_payload_via_constexpr_gep() {
|
||||
// ... body unchanged ...
|
||||
assert!(
|
||||
ir.contains(r#"getelementptr inbounds (<{ i64, [3 x i8] }>, ptr @.str_t_str_0, i32 0, i32 0)"#),
|
||||
"expected constexpr-GEP-to-len-field at callsite; ir was:\n{ir}"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
The two changes versus hs.1: (a) struct type drops the leading `i64, `;
|
||||
(b) GEP indices change from `i32 0, i32 1` to `i32 0, i32 0`.
|
||||
|
||||
```c
|
||||
void ailang_rc_inc(void *payload) {
|
||||
if (payload == NULL) {
|
||||
return;
|
||||
}
|
||||
/* Static closure-pair env pointers (Iter 8b) live in the LLVM data
|
||||
* segment, not in heap memory we allocated. Codegen elides inc/dec
|
||||
* for known-static pointers (the `@`-prefix gate added in 18c.3),
|
||||
* so this path is not reached for them in practice. The runtime
|
||||
* itself has no header-bit flag distinguishing static from heap;
|
||||
* if codegen ever loses the elision, inc on a static pointer is
|
||||
* undefined behaviour. */
|
||||
ailang_rc_header_t *hdr = header_of(payload);
|
||||
/* Iter hs.2: static-Str literal slabs (emitted by the codegen as
|
||||
* packed-struct globals in iter hs.1) carry the sentinel header
|
||||
* value UINT64_MAX in the slot at `payload - 8`. Such slabs live
|
||||
* in the `.rodata` section; their refcount must never be mutated.
|
||||
* Codegen does not elide inc/dec on static-Str field pointers
|
||||
* (the `@`-prefix elision in 18c.3 covered only closure-pair
|
||||
* env pointers, not Str payload pointers materialised via
|
||||
* constexpr GEP). The sentinel-header short-circuit makes inc
|
||||
* a defined no-op for such pointers. The same convention
|
||||
* applies to `ailang_rc_dec` below. */
|
||||
if (*hdr == UINT64_MAX) {
|
||||
return;
|
||||
}
|
||||
*hdr += 1;
|
||||
}
|
||||
- [ ] **Step 3: Run both updated tests and confirm they fail RED**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test -p ailang-codegen --lib static_str_
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the sentinel-header short-circuit to `ailang_rc_dec`.**
|
||||
Expected: both tests **FAIL** with assertion-mismatch messages — the
|
||||
emitted IR still carries the hs.1 sentinel-prefixed shape; the new
|
||||
asserts demand the hs.2 len-only shape. The failure messages will
|
||||
print the actual IR so the substring mismatch is visible.
|
||||
|
||||
Modify `runtime/rc.c:161-177`. The current body is:
|
||||
- [ ] **Step 4: Confirm the four `+8 GEP` tests still pass under hs.1
|
||||
emission**
|
||||
|
||||
```c
|
||||
void ailang_rc_dec(void *payload) {
|
||||
if (payload == NULL) {
|
||||
return;
|
||||
}
|
||||
ailang_rc_header_t *hdr = header_of(payload);
|
||||
if (*hdr == 0) {
|
||||
fprintf(stderr,
|
||||
"ailang_rc_dec: refcount underflow at %p (already zero)\n",
|
||||
payload);
|
||||
abort();
|
||||
}
|
||||
*hdr -= 1;
|
||||
if (*hdr == 0) {
|
||||
free(hdr);
|
||||
g_rc_free_count++;
|
||||
}
|
||||
}
|
||||
Run:
|
||||
```
|
||||
cargo test -p ailang-codegen --lib _calls_
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```c
|
||||
void ailang_rc_dec(void *payload) {
|
||||
if (payload == NULL) {
|
||||
return;
|
||||
}
|
||||
ailang_rc_header_t *hdr = header_of(payload);
|
||||
/* Iter hs.2: static-Str literal slabs carry the sentinel
|
||||
* UINT64_MAX in the header slot and live in `.rodata`.
|
||||
* Short-circuit before the underflow check (UINT64_MAX != 0
|
||||
* would otherwise pass the underflow guard and reach the
|
||||
* read-modify-write at `*hdr -= 1`, segfaulting on `.rodata`).
|
||||
* See the matching comment in `ailang_rc_inc` above. */
|
||||
if (*hdr == UINT64_MAX) {
|
||||
return;
|
||||
}
|
||||
if (*hdr == 0) {
|
||||
fprintf(stderr,
|
||||
"ailang_rc_dec: refcount underflow at %p (already zero)\n",
|
||||
payload);
|
||||
abort();
|
||||
}
|
||||
*hdr -= 1;
|
||||
if (*hdr == 0) {
|
||||
free(hdr);
|
||||
g_rc_free_count++;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Confirm `UINT64_MAX` is reachable in `runtime/rc.c`.**
|
||||
|
||||
Run: `grep -n '#include <stdint.h>' runtime/rc.c`
|
||||
Expected: a single line `48:#include <stdint.h>` (or equivalent).
|
||||
`UINT64_MAX` is provided by `<stdint.h>`, which is already
|
||||
included by `runtime/rc.c:48`. No new include is needed.
|
||||
If for some reason the include is missing, add `#include <stdint.h>`
|
||||
to the include block at the top of the file.
|
||||
|
||||
- [ ] **Step 6: Run the test to verify it passes.**
|
||||
|
||||
Run: `cargo test --workspace -p ail str_field_in_adt_drops_static_str_noop`
|
||||
Expected: PASS. The fixture's binary runs cleanly, prints
|
||||
`hello`, the atexit handler reports `allocs=N frees=N live=0`
|
||||
for the heap-side StrBox cell (one alloc for the StrBox outer,
|
||||
one matching free at let-scope close), and the test's
|
||||
`assert_eq!(live, 0, ...)` succeeds.
|
||||
Expected: `print_str_calls_puts_with_bytes_pointer`,
|
||||
`eq_str_calls_ail_str_eq_with_bytes_pointer`,
|
||||
`compare_str_calls_ail_str_compare_with_bytes_pointer`,
|
||||
`lower_eq_str_calls_strcmp_with_bytes_pointer` all **PASS**. These tests
|
||||
assert only on the `i64 8` byte offset and the consumed-symbol name;
|
||||
they do not depend on the struct shape and must remain green.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Full regression sweep
|
||||
|
||||
Confirm the runtime change broke nothing else. The sentinel
|
||||
short-circuit adds one early-return branch in two hot-path
|
||||
functions; in principle the only behavioural effect is on
|
||||
pointers whose header equals `UINT64_MAX`, which today is only
|
||||
the static-Str case. Belt-and-braces verify against every other
|
||||
RC-discipline test and against the cross-language reference
|
||||
corpus.
|
||||
## Task 2 — Switch emission and callsite GEPs to the amended-spec layout (GREEN)
|
||||
|
||||
**Files:**
|
||||
- (none — read-only verification)
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:462-475`
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:924-937`
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1246-1259`
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:561-565`
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:2596-2600`
|
||||
|
||||
- [ ] **Step 1: Run the full cargo test workspace.**
|
||||
- [ ] **Step 1: Switch the global-emission loop (single source of the
|
||||
`i64 -1` sentinel literal)**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: PASS, all tests green. Specifically, the existing
|
||||
`alloc_rc_*` tests (12 fixtures exercising heap allocations
|
||||
of various ADTs under `--alloc=rc`) continue to report
|
||||
matching allocs/frees because none of them carry a `Str`
|
||||
field and therefore none of them ever calls
|
||||
`ailang_rc_dec(static_str_ptr)`.
|
||||
Locate the loop at `crates/ailang-codegen/src/lib.rs:466-475`. Replace
|
||||
the comment (lines 462-465) and the format string (line 472):
|
||||
|
||||
- [ ] **Step 2: Run `bench/compile_check.py`.**
|
||||
```rust
|
||||
// Iter hs.2: packed-struct globals for language `Str` literals.
|
||||
// First `i64` is the byte length (excluding the trailing NUL); the
|
||||
// `[N+1 x i8]` carries the bytes followed by the terminating NUL.
|
||||
// The IR-`Str` pointer that flows through the rest of codegen lands
|
||||
// on the `len`-field via constexpr-GEP at the `Literal::Str` arms
|
||||
// (see emit_const_def / lower_term). Static-Str pointers are kept
|
||||
// out of `ailang_rc_dec` by codegen-level elision (move-tracking
|
||||
// from iter 18d.3 + non-escape lowering from iter 18b), so no
|
||||
// sentinel rc-header slot is needed at the global.
|
||||
for entries in all_str_literals.values() {
|
||||
for (name, content) in entries {
|
||||
let escaped = ll_string_literal(content);
|
||||
let total = c_byte_len(content); // bytes + NUL
|
||||
let bytes_len = total - 1; // bytes only
|
||||
out.push_str(&format!(
|
||||
"@{name} = private unnamed_addr constant <{{ i64, [{total} x i8] }}> <{{ i64 {bytes_len}, [{total} x i8] c\"{escaped}\" }}>, align 8\n",
|
||||
));
|
||||
emitted_global = true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `python3 bench/compile_check.py`
|
||||
Expected: every `examples/` program — including the new
|
||||
`rc_str_field_in_adt_static.ail.json` — compiles without
|
||||
errors or warnings; the script exits 0.
|
||||
Diff versus hs.1: comment loses the `i64 -1`/sentinel paragraph and
|
||||
gains the codegen-elision justification; format string loses the
|
||||
leading `i64, ` from the struct type and the leading `i64 -1, ` from
|
||||
the initialiser.
|
||||
|
||||
- [ ] **Step 3: Run `bench/cross_lang.py`.**
|
||||
- [ ] **Step 2: Switch the first `Literal::Str` callsite GEP (in
|
||||
`emit_const_def`)**
|
||||
|
||||
Run: `python3 bench/cross_lang.py`
|
||||
Expected: every fixture in `bench/reference/` produces
|
||||
byte-identical stdout from its AILang and C implementations;
|
||||
the script exits 0. None of these fixtures carries an
|
||||
ADT-with-`Str`-field today, so the sentinel short-circuit
|
||||
is not exercised; the test confirms no orthogonal regression
|
||||
crept in.
|
||||
Locate the arm at `crates/ailang-codegen/src/lib.rs:924-937`. Replace
|
||||
the comment and the format string:
|
||||
|
||||
```rust
|
||||
Literal::Str { value } => {
|
||||
// Iter hs.2: emit a packed-struct global and return a
|
||||
// constexpr-GEP pointer landing on the `len`-field (now
|
||||
// the first field of the packed struct, since the
|
||||
// hs.1-era sentinel rc-header slot was removed). Every
|
||||
// IR-Str pointer in this codegen pipeline has the
|
||||
// shape len at offset 0, bytes at offset 8.
|
||||
let g = self.intern_str_literal("str", value);
|
||||
let total = c_byte_len(value); // bytes + NUL
|
||||
(
|
||||
"ptr".to_string(),
|
||||
format!(
|
||||
"getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)",
|
||||
),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Diff versus hs.1: comment drops the "header at -8" phrase; struct type
|
||||
in the GEP loses the leading `i64, `; GEP indices change from
|
||||
`i32 0, i32 1` to `i32 0, i32 0`.
|
||||
|
||||
- [ ] **Step 3: Switch the second `Literal::Str` callsite GEP (in
|
||||
`lower_term`)**
|
||||
|
||||
Locate the arm at `crates/ailang-codegen/src/lib.rs:1246-1259`. Replace
|
||||
the comment and the format string (the two arms are intentionally
|
||||
parallel; the edits are symmetric):
|
||||
|
||||
```rust
|
||||
Literal::Str { value } => {
|
||||
// Iter hs.2: language `Str` literals materialise as
|
||||
// a constexpr-GEP into the packed-struct global,
|
||||
// landing on the `len`-field (now the first field,
|
||||
// since the hs.1-era sentinel rc-header slot was
|
||||
// removed). IR-Str pointer carries len at 0, bytes
|
||||
// at +8.
|
||||
let g = self.intern_str_literal("str", value);
|
||||
let total = c_byte_len(value); // bytes + NUL
|
||||
(
|
||||
format!(
|
||||
"getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)",
|
||||
),
|
||||
"ptr".into(),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Same diff shape as Step 2.
|
||||
|
||||
- [ ] **Step 4: Update the two doc-comments that describe the layout**
|
||||
|
||||
Two doc-comments still describe the hs.1 layout. Update both:
|
||||
|
||||
`crates/ailang-codegen/src/lib.rs:561-565` (on the `str_literals`
|
||||
field):
|
||||
```rust
|
||||
/// Iter hs.1 (amended hs.2): language `Str` literals interned as
|
||||
/// packed-struct globals (`<{ i64, [N+1 x i8] }>`) carrying an
|
||||
/// explicit `len` field and the bytes + trailing NUL. Parallel to
|
||||
/// `strings` (which still serves runtime-internal format strings
|
||||
```
|
||||
|
||||
`crates/ailang-codegen/src/lib.rs:2596-2600` (on
|
||||
`intern_str_literal`):
|
||||
```rust
|
||||
/// Iter hs.1 (amended hs.2): parallel to `intern_string`, but for
|
||||
/// language `Str` literals emitted as packed-struct globals
|
||||
/// (len + bytes + NUL). Shares the same monotonic `str_counter`
|
||||
/// so the produced global names remain alphabetically orderable
|
||||
/// alongside format-string globals.
|
||||
```
|
||||
|
||||
Both edits remove only the "sentinel + " phrase and (in the second
|
||||
case) the "UINT64_MAX sentinel rc-header" phrase; the structural
|
||||
description is otherwise unchanged.
|
||||
|
||||
- [ ] **Step 5: Run both updated tests and confirm they pass GREEN**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test -p ailang-codegen --lib static_str_
|
||||
```
|
||||
|
||||
Expected: both `static_str_global_uses_packed_struct_with_len` and
|
||||
`static_str_callsite_pointer_is_payload_via_constexpr_gep` **PASS**.
|
||||
|
||||
- [ ] **Step 6: Re-run the four `+8 GEP` tests and confirm they still
|
||||
pass**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test -p ailang-codegen --lib _calls_
|
||||
```
|
||||
|
||||
Expected: all four `_calls_*_with_bytes_pointer` tests **PASS** — the
|
||||
+8 offset is invariant across the layout switch (the bytes still sit
|
||||
8 bytes past the len-field; the only thing the layout-switch moved is
|
||||
where the IR-Str pointer was previously landing, and the IR-Str pointer
|
||||
still lands on the `len`-field — just at struct-index 0 instead of 1).
|
||||
|
||||
- [ ] **Step 7: Run the full ailang-codegen test suite for any
|
||||
collateral**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test -p ailang-codegen
|
||||
```
|
||||
|
||||
Expected: all tests **PASS**. Tests not touched by Tasks 1-2 (drop
|
||||
walks, ADT lowering, match, eq, compare) do not reference the static-
|
||||
Str global shape and must remain green. If any test fails, it likely
|
||||
contains a literal substring assert that quotes the hs.1 struct shape;
|
||||
patch the assert in place using the same shape transformation
|
||||
(`i64, i64, [...]` → `i64, [...]`; `i64 -1, i64 N` → `i64 N`;
|
||||
`i32 0, i32 1` → `i32 0, i32 0`).
|
||||
|
||||
---
|
||||
|
||||
## Notes for the implementer
|
||||
## Task 3 — Regenerate hello.ll snapshot + workspace regression sweep
|
||||
|
||||
- The hs.1 packed-struct globals emit `i64 -1` for the sentinel
|
||||
slot. When read as `uint64_t` this is bit-for-bit equivalent to
|
||||
`UINT64_MAX` (`0xFFFFFFFFFFFFFFFF` in both signed twos-complement
|
||||
-1 and unsigned UINT64_MAX representations). The codegen-side
|
||||
emits `-1`; the runtime-side checks against `UINT64_MAX`. Both
|
||||
refer to the same 64-bit pattern.
|
||||
**Files:**
|
||||
- Modify: `crates/ail/tests/snapshots/hello.ll`
|
||||
|
||||
- Beyond the sentinel-Str case, the only way a real refcount could
|
||||
reach `UINT64_MAX` is via `2^64` independent references — which
|
||||
would consume more memory than exists on any machine. The
|
||||
collision-with-real-refcount case is structurally impossible,
|
||||
not just unlikely.
|
||||
- [ ] **Step 1: Inspect the current snapshot**
|
||||
|
||||
- The block-comment at `runtime/rc.c:136-142` describes a different
|
||||
static-pointer case (closure-pair env pointers in the `.data`
|
||||
segment, which have no header at all and rely entirely on
|
||||
codegen-side elision). It is NOT outdated by hs.2 — the
|
||||
closure-pair case still relies on codegen-side elision, and the
|
||||
sentinel guard is orthogonal additional protection for a
|
||||
different kind of static pointer (Str literals). Do not edit
|
||||
that comment.
|
||||
Run:
|
||||
```
|
||||
grep -n -E "i64 -1|i32 0, i32 1|<\{ i64, i64," crates/ail/tests/snapshots/hello.ll
|
||||
```
|
||||
|
||||
- No `git commit` step. The Boss commits the working-tree diff at
|
||||
iter end after audit-level inspection.
|
||||
Expected output (line numbers may vary slightly):
|
||||
```
|
||||
5:@.str_hello_str_0 = private unnamed_addr constant <{ i64, i64, [15 x i8] }> <{ i64 -1, i64 14, [15 x i8] c"Hello, AILang.\00" }>, align 8
|
||||
18: %v1 = getelementptr inbounds i8, ptr getelementptr inbounds (<{ i64, i64, [15 x i8] }>, ptr @.str_hello_str_0, i32 0, i32 1), i64 8
|
||||
```
|
||||
|
||||
Confirms two lines need to flip.
|
||||
|
||||
- [ ] **Step 2: Regenerate the snapshot under UPDATE_SNAPSHOTS=1**
|
||||
|
||||
Run:
|
||||
```
|
||||
UPDATE_SNAPSHOTS=1 cargo test -p ail --test e2e hello
|
||||
```
|
||||
|
||||
(If the test harness name differs, locate it with:
|
||||
`grep -rn "UPDATE_SNAPSHOTS" crates/ail/tests/` and run the matched
|
||||
test.)
|
||||
|
||||
Expected: the snapshot file is rewritten in place. The two flipped
|
||||
lines now read:
|
||||
```
|
||||
@.str_hello_str_0 = private unnamed_addr constant <{ i64, [15 x i8] }> <{ i64 14, [15 x i8] c"Hello, AILang.\00" }>, align 8
|
||||
...
|
||||
%v1 = getelementptr inbounds i8, ptr getelementptr inbounds (<{ i64, [15 x i8] }>, ptr @.str_hello_str_0, i32 0, i32 0), i64 8
|
||||
```
|
||||
|
||||
(The `i64 -1` literal is gone; the struct type loses one `i64,`; the
|
||||
GEP-index pair changes from `i32 0, i32 1` to `i32 0, i32 0`. The
|
||||
`i64 8` byte-offset in the outer `getelementptr i8` is unchanged.)
|
||||
|
||||
- [ ] **Step 3: Re-run the snapshot test without UPDATE_SNAPSHOTS=1
|
||||
and confirm it passes**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test -p ail --test e2e hello
|
||||
```
|
||||
|
||||
Expected: **PASS**. The regenerated snapshot now matches the live
|
||||
emission.
|
||||
|
||||
- [ ] **Step 4: Full workspace test sweep**
|
||||
|
||||
Run:
|
||||
```
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Expected: every test **PASSes**. Special attention to any test that
|
||||
greps for `i64 -1` or for the 3-field struct type in emitted IR
|
||||
(unlikely outside the two tests Task 1 already covers; the
|
||||
ailang-prose / ailang-check / ailang-surface crates do not look at
|
||||
codegen output). If any test fails, decide whether to: (a) patch the
|
||||
test in place using the same shape transformation if it pinned hs.1
|
||||
emission, or (b) treat it as a real regression and stop.
|
||||
|
||||
- [ ] **Step 5: Cross-language stdout regression sweep**
|
||||
|
||||
Run:
|
||||
```
|
||||
bench/cross_lang.py
|
||||
```
|
||||
|
||||
Expected: every entry **green** (byte-identical stdout vs. the C
|
||||
reference corpus in `bench/reference/`). This is the strongest gate:
|
||||
it verifies that the layout switch did not change observable program
|
||||
output for any program in `examples/`. Any divergence is a real
|
||||
regression — re-inspect the four `+8 GEP` callsites first, since
|
||||
those are the consumer-side ABI and most likely to misread the new
|
||||
layout.
|
||||
|
||||
- [ ] **Step 6: Workspace-compile regression sweep**
|
||||
|
||||
Run:
|
||||
```
|
||||
bench/compile_check.py
|
||||
```
|
||||
|
||||
Expected: every entry **green** (compile succeeds for the full
|
||||
`examples/` corpus). This catches cases where a program compiled
|
||||
under hs.1 but fails under hs.2 codegen for reasons orthogonal to
|
||||
stdout output.
|
||||
|
||||
- [ ] **Step 7: Latency baselines check**
|
||||
|
||||
Run:
|
||||
```
|
||||
bench/check.py
|
||||
```
|
||||
|
||||
Expected: green within the known `latency.explicit_at_rc.*`
|
||||
nondeterminism tolerance documented in recent audits. The hs.2 retrofit
|
||||
is a struct-shape change, not a code-path-count change; no latency
|
||||
delta is expected.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance for this iteration
|
||||
|
||||
- Both updated IR-shape tests pass.
|
||||
- All four +8 GEP tests pass unchanged.
|
||||
- `crates/ail/tests/snapshots/hello.ll` reflects the new layout.
|
||||
- `cargo test --workspace` green.
|
||||
- `bench/cross_lang.py`, `bench/compile_check.py`, `bench/check.py`
|
||||
all green.
|
||||
- The hs.1 sentinel-rc-header story is gone from both the runtime
|
||||
emission and the test asserts; static-Str globals are now 8 bytes
|
||||
smaller per literal.
|
||||
- No runtime-side change, no checker-side change, no new fixtures or
|
||||
test files.
|
||||
|
||||
Reference in New Issue
Block a user