User decision: ship B1.2 async with lambda workers (works today, zero compiler change); defer named-fn workers, which need a new :: callable- parameter language feature (3 failed worker attempts; partial WIP saved at .sx-tmp/wip-callable-params/). Records the resolved lambda async idiom + resume plan; no compiler/library code changed.
21 KiB
CHECKPOINT-FIBERS — Stream B1 (fibers + Io + M:1 scheduler)
Companion to PLAN-FIBERS.md. Update after every step (one step at a time,
per the cadence rule). New corpus category: 18xx concurrency.
Last completed step
B1.1 (per-fiber context root) — DONE. Zero compiler change (confirmed by probe). The
fiber-spawn context convention works end-to-end with ordinary language features:
snap := contextcaptures the spawner'sContextas a value;- the snapshot is stored in a struct (the stand-in
Fiber); - a trampoline running under a different ambient context installs the fiber's stored root
with
push f.root { … }, and the body reads the snapshot — not the trampoline's ambient context — becausecontextis an implicit slot-0*Contextparam (call-carried, rides the callee's own stack) andpushallocates on the caller frame (no global, no TLS). - Locked by
examples/1804-concurrency-context-snapshot.sx: printsfiber root: 42(the installed snapshot wins over ambient 99) +ambient after: 99(thepushscope restores the ambient context on exit). No fiber runtime yet (that's B1.3) — this proves the plumbing it will build on. No.buildpin (pure sx, host-independent). - Probe result: the design doc's "lower as swappable indirection, never raw TLS" guarded
a non-problem — context was already param-carried, never TLS. No path re-reads
__sx_default_contextmid-stack, so there is no compiler obligation here. zig build && zig build testgreen: 726 ran, 0 failed.
Earlier — B1.0 (abi(.naked) codegen) — complete
Replaced the emit bail with real LLVM naked emission:
emit_llvmdeclaration pass: forfunc.is_naked, add the LLVMnaked+noinline+nounwindattributes and skip theframe-pointer=allattribute (incompatible with a frameless function). Pass 2 now emits the.nakedbody normally —nakedmakes the backend emit it verbatim (the inline asm + its ownret) with no prologue/epilogue.- IR shape (verified):
; Function Attrs: naked noinline nounwind/define internal i64 @answer() #0 { entry: call void asm sideeffect "…ret…", ""() unreachable }/attributes #0 = { naked noinline nounwind }. The caller invokes it as an ordinary() -> i64call (.nakediscall_conv == .default). examples/1800-concurrency-naked-asm.sx— now GREEN, aarch64-pinned (.build {"target": "macos"}): runs end-to-end → exit 42 on this host, ir-only on a mismatch;.irsnapshot captured.examples/1801-concurrency-naked-generic.sx(renamed from-bail) — the generic.nakednow emits a correct nakedanswer__i64(exit 42), proving generic.zig produces a naked body, not a framed one. aarch64-pinned.examples/1802-concurrency-naked-asm-x86.sx— x86_64 cross sibling (.build {"target": "x86_64-linux"}, ir-only here):.irlocksnaked+movl $42, %eax/ret.- Unit test
emit: abi(.naked) function gets the naked attribute (no frame-pointer)inemit_llvm.test.zig(assertsnakedpresent,frame-pointerabsent). - B1.0c (review-hardening): a param-bearing
.nakedfn emitted invalid LLVM (loud verifier error "cannot use argument of naked function") because the param-alloca loop wasn't gated. Fixed forward (this enables the B1.3 context-switch use case rather than rejecting it): gated the param-alloca loop onfd.abi != .nakedin decl.zig (both paths) + generic.zig; a naked fn's args stay in registers (read by asm), declared-but-unused in LLVM. Locked byexamples/1803-concurrency-naked-asm-param.sx(add(a,b)→ x0+x1 → 42). zig build && zig build testgreen: 725 ran, 0 failed + unit tests.
Earlier — B1.0a (lock + review hardening)
Plumbed Function.is_naked (set from fd.abi == .naked at both decl sites + generic.zig +
pack.zig); funcWantsImplicitCtx skips .naked (no synthetic ctx, like .c); all
body-lowering paths bypass lowerValueBody for .naked (asm body + unreachable cap — no sx
return); emit_llvm Pass 2 bailed loudly (since flipped to real emission). Adversarial
review caught the generic/pack is_naked gap (a generic .naked silently shipped a framed
body); closed + locked. The review's .naked-lambda CRITICAL was a false positive
(unparseable — isLambda breaks on the abi keyword).
Current state
B1.2 is UNBLOCKED. Master GREEN (726/0), installed sx clean. The earlier "blockers"
were NOT real: issue 0151 was INVALID (its repro used the non-idiomatic ($A)->$R
bare-fn-ptr form) — removed. The correct async idiom works today with no compiler
change (verified live): spawn :: (worker: Closure(..$args) -> $R, ..$args) -> Wrap($R)
with a lambda worker + the w : Wrap($R) = ---; w.v = worker(..args); build form —
mirrors the canonical examples/0543-packs-canonical-map.sx. Ran 42 42 for homogeneous +
heterogeneous args. Caveats (work within them, not "bugs"): lambda params must be annotated
((a: i64, b: i64) -> i64 => …); a bare named fn passed as the worker is non-idiomatic —
use a lambda; build the result struct with = --- + field-assign, not a struct-literal in
return. Issue 0150 (void struct field → SIGTRAP exit 133) is a real bug but only
reached via Future(void) (void-returning worker / timeout) — DEFERRED: B1.2 supports
non-void workers; revisit Future(void) in B1.4 (or fix 0150 standalone). The B1.2 design
(Io protocol on Context, blocking CBlockingIo, context.io.now_ms()) was validated live;
WIP at .sx-tmp/b12-wip/ has the working Io/Context/materializer parts — reuse those, rewrite
the async layer to the pack-lambda idiom above.
B1.2 attempt (BLOCKED — design proven, two compiler bugs filed)
What was built + verified WORKING (then reverted to keep master green):
Io :: protocol #inline { spawn_raw; suspend_raw -> !; ready; poll; now_ms; arm_timer; }incore.sxnext toAllocator, withSpawnOpts{ pin: PinTarget }+ParkToken{ handle }. Six methods, each justified by a downstream consumer (B1.3-B1.5).Context :: struct { allocator; data; io: Io; }—ioappended LAST soallocatorstays index 0 (thecall.zig:1229hardcode) anddatakeeps index 1 (minimal VM-fallback churn).- Both
__sx_default_contextmaterializers updated in lockstep + verified:protocol.zigemitDefaultContextGlobal(extendedctx_fields2→3, built theCBlockingIo→Ioinline 7-word vtable{null-ctx, fn0..fn5}viagetOrCreateThunks("Io","CBlockingIo")) andcomptime_vm.zigmaterializeDefaultContextfallback (wrote the 6 thunk func-refs atio_base = addr + 4*ps, offset+ (i+1)*ps). The global path auto-followed the 3-field Context type.context.io.now_ms()printedclock oklive — the capability threads + the vtable dispatches correctly. - Stateless
CBlockingIo :: struct {}+impl Io for CBlockingIo(mirror ofCAllocator): blocking semantics —spawn_raw/ready/poll/arm_timerno-op/0,now_ms→time.mono_ms(). - push-inherit-omitted fix (
stmt.ziglowerPush): apush Context.{...}now SEEDS the new slot from the ambient context (load+store), then overwrites ONLY the literal's named fields — so omitted fields (now incl.io) are INHERITED, never zero-inited to a null vtable. Eliminates the omitted-field footgun globally (zero per-site churn across the 17 partial-literal sites). This is the correct capability-bag semantics; it compiled clean. !-protocol-method warning fix (error_analysis.zig+ a newLowering.impl_method_namesset populated inprotocols.zigregisterImplBlock): a protocol impl method may be declared!by contract (e.g.Io.suspend_raw) yet never raise; the "declared!but never errors — drop the!" hint is a false positive for impl methods, now suppressed for them.
Where it BROKE (the two blockers — both INDEPENDENT of the Io design, both repro standalone):
- issue 0150 —
Future(void)(fortimeout -> Future(void)) makes aresult: voidfield; avoidstruct field crashes the compiler with an unsized-type SIGTRAP in LLVMgetTypeSizeInBits(a barestruct { v: void; }repros it).timeoutwas DEFERRED (it is a B1.4 stub needingarm_timeranyway) rather than routed around with a non-void shape. - issue 0151 —
async(io, worker: ($A) -> $R, arg: $A) -> Future($R):$Rinferred from a fn-pointer parameter's RETURN type type-checks the call but is NOT bound as a usable type in the body, soFuture(R)errorsunknown type 'R'. A directarg: $Abinds fine — the gap is specific to type-vars nested in a fn-ptr/closure param signature. This blocks the centralasync/awaitfree-fns. (Manifested as the "unresolved type reached LLVM emission" panic — the same one another session filed against my dirty binary as issue 0149, now moot after the revert.)
Per the IMPASSABLE STOP rule: filed 0150 + 0151, reverted all B1.2 working changes (master
green again, photo project unbroken), STOPPED. Resume B1.2 once 0150 + 0151 land — the WIP in
.sx-tmp/b12-wip/ makes it ~mechanical (the design is proven).
Earlier — B1.0 + B1.1 complete
Stream A (atomics) is feature-complete (✅). Stream B1: B1.0 + B1.1 complete. The two
compiler-floor preconditions for the fiber runtime are in place: (1) abi(.naked) emits a
real LLVM naked function end-to-end (decl, generic, pack paths) — the context-switch
substrate; (2) per-fiber context root needs no compiler change — the spawn convention
(snapshot context, store, push it from the trampoline) is pure library sx. No
fibers/Io/scheduler code yet. Grounded floor facts:
contextis an implicit slot-0*Contextparam +push Contextis a stackalloca⇒ fiber-local for free (confirmed by the B1.1 probe — never TLS, never re-read from the__sx_default_contextglobal mid-stack). A spawn passes the snapshot as the fiber-entry fn's slot-0 ctx viapush f.root { entry(args) }. Locked by1804-...-context-snapshot.- Inline asm works end-to-end (lower→emit→JIT, aarch64 + x86_64) — the
.nakedbody reuses it. .nakedwith PARAMS works (B1.0c, the B1.3 substrate): the param-alloca loop is gated onfd.abi != .nakedin decl.zig (both paths) + generic.zig — a naked fn's args stay in ABI registers (read by the asm body), declared-but-unused in LLVM (verifier-legal). Example1803-concurrency-naked-asm-param.sx(add(a,b)reads x0/x1). Unsupported (loud, not silent): a.nakedvariadic-pack fn (pack.zig's param loop is intertwined with comptime-param/#inserthandling, and a naked fn can't read a runtime-sized pack from registers anyway) → loud LLVM-verifier error for that nonsensical construct. Acceptable boundary; a sharper sx diagnostic for it is a candidate polish, not a blocker.
Next step
DECISION (user, 2026-06-20): B1.2 async = LAMBDA workers only for now; named-fn workers
(async(read_a, conn)) are DEFERRED. Named-fn support needs a new :: callable-parameter
language feature (fn :: (..$args) -> $R param) that doesn't exist yet — it failed 3 worker
attempts; the partial WIP (parser+scaffolding done, type-pack/return inference INCOMPLETE —
panics on every form) is saved at .sx-tmp/wip-callable-params/patch.diff for a dedicated
future effort. So B1.2 ships with the lambda idiom below; call sites use
context.io.async((c) => read_a(c), conn) until the :: feature lands.
B1.2 — resume now (UNBLOCKED, no compiler fix needed). Re-land from the saved WIP
(.sx-tmp/b12-wip/): keep the verified-working parts — the Io protocol on Context, both
__sx_default_context materializers (protocol.zig + comptime_vm.zig), the push-inherit-omitted
fix (stmt.zig lowerPush — omitted push Context.{...} fields inherit the ambient ctx; the
correct fix, NOT per-site io = context.io edits across the 17 sites), and the
!-impl-warning fix. Rewrite the async/await layer to the CORRECT idiom (verified live):
async :: (io: Io, worker: Closure(..$args) -> $R, ..$args) -> Future($R) {
f : Future($R) = ---; // `= ---` + field-assign, NOT a struct-literal return
f.value = worker(..args); // blocking impl: run to completion
f.state = .ready;
return f;
}
Worker is a lambda with annotated params ((a: i64, b: i64) -> i64 => …); name it
async (NOT run — run collides with process.run re-exported by std.sx and is silently
shadowed). Future($R) is a parameterized struct($T) (so the bare--> $R-return inference
gap is auto-avoided). Avoid Future(void) (issue 0150 SIGTRAP) — B1.2 supports non-void
workers; timeout/Future(void) defer to B1.4. Add examples/1805-concurrency-io-blocking- async.sx (lock→green) + 1806-concurrency-io-cancel.sx. Regen .ir ONLY after green
(-Dupdate-goldens) — adding Io to the prelude shifts many .ir type tables; confirm the
diff is ONLY layout/numbering + the new vtable, NO error text. Context layout settled:
{ allocator; data; io; } (allocator index 0 fixed by call.zig:1229, io last).
Known issues / capability gaps
- 🔴 B1.2 BLOCKERS (both filed, both standalone-reproducible, both independent of the Io
design):
- issue 0150 — a
voidstruct field crashes the compiler (unsized-type SIGTRAP in LLVMgetTypeSizeInBits). BlocksFuture(void)→timeout. Repro:issues/0150-.... - issue 0151 — a type-var inferred from a fn-pointer parameter's RETURN type is not bound
in the function body (
unknown type 'R'). Blocksasync(io, worker: ($A)->$R, arg)'sFuture(R). Repro:issues/0151-.... - (Note: issue 0149, filed by another session against the dirty in-progress binary, was a manifestation of 0151 — "unresolved type reached LLVM emission". Moot after the revert; its real root cause is 0151.)
- issue 0150 — a
- Orthogonal (not a B1 blocker): default VALUES for comptime params don't bind on generic-struct methods (free-fn defaults DO work) — inherited from Stream A. Only matters if a B2 lib type wants a defaulted comptime param; atomics/fibers require explicit, so unaffected.
- Issue 0144 (open, independent): calling an unrecognized bodiless
#builtinsilently returns 0 / exit 0 — a silent-fallback footgun in the generic builtin-call path. Filed; leave for its own fix session unless prioritized. Not a B1 blocker. - Deferred design gap (documented): the B1.4 event-loop
Iodoes not yet cooperate with a platform UI run loop (CFRunLoop/NSRunLoop/ALooper); pinning gives thread-affinity, not run-loop integration — a §6 app-target concern, out of B1 scope.
Decisions (Stream B1 specifics; surface locked in design §4 / §4.6)
- The async runtime is sx LIBRARY code. The compiler provides only: the general
primitives (inline asm ✅,
abi(.naked)naked [B1.0], atomics ✅) + fiber-safe codegen (contextalready fiber-local — B1.1). Schedulers, fibers, channels, futures,Iovtables,mmapstacks are all sx. abi(.naked)is the real spelling of the design'scallconv(.naked)— postfix slot,name :: (sig) -> Ret abi(.naked) { asm { … }; }. B1.0 = carry it into IR + emit LLVMnaked+ skip prologue/ctx (mirror the existing.cskip), NOT extend the enum (it's already there, just inert)..naked≠.c: a.cepilogue would restore SP from the wrong stack across a context switch (SP-in ≠ SP-out by design)..naked= no prologue/epilogue/frame; the asm emits its ownret. This is why the switch must be.naked.- Naming: sx-facing name is
naked(keywordabi(.naked), fieldis_naked, the diagnostic), matching LLVM'snakedattribute and the industry term (Zig/Rust/GCC/Clang). The ABI variant was renamed.pure → .naked(user direction): "pure" universally means side-effect-free, the opposite of a register-clobbering context switch. - B1.0 snapshot scope: a
.nakedbody is raw per-arch asm; LLVM'snakedattr text is arch-invariant. B1.0a = one host example locked to the emit bail (host-independent — fires before instruction selection; no.buildpin). B1.0b = pin aarch64 + add an x86_64 cross sibling (.buildtarget-gated, ir-only on mismatch), like the asm corpus split. The.irproves thenakedattr + asm emitted, NOT register-save correctness (that's B1.3's stress harness). - B1.1 — per-fiber context is library-only (CONFIRMED by probe): push frames are
stack-
alloca'd and the implicit ctx rides slot 0, so the spawn convention — snapshotcontext, store it,push f.root { entry(args) }from the trampoline — installs the fiber's root with no compiler change. Verified: the body reads the snapshot over a different ambient context, andpushrestores ambient on exit (1804-...-context-snapshot). The design doc's "never raw TLS" guarded a non-problem (context was never TLS). - Test keystones (design §10): the B1.3 switch-stress harness gates the
context-switch (the one piece the deterministic
Iocan't test — §8.1.1, §10.7); the B1.4 deterministic-simIo(calibrated against blockingIo— §8.1.3) gates all scheduling tests. Both must exist + be calibrated before the async tests they gate are trusted.18xxasserts program-emitted ordering contracts, not raw interleaving.
Log
- carve — wrote PLAN-FIBERS.md + CHECKPOINT-FIBERS.md. Grounded the B1 compiler floor:
ABI.nakedinert (type_resolver.zig:237), IRFunctionhas no naked flag (inst.zig:605), attribute API pattern (emit_llvm.zig:1339 nounwind),.cctx-skip precedent (decl.zig:515),push Contextstack-alloca + slot-0 implicit ctx (stmt.zig:1263, lower.zig:259),__sx_default_contextroot (decl.zig:2667/2815), inline-asm corpus (1645/1651). Corrected the design'scallconv(.naked)→ realabi(.naked)spelling and the B1.0 snapshot story. B1.1 grounded as likely library-only. Baseline green (721/0). - B1.0a — plumbed
Function.is_naked(set fromfd.abi == .nakedat both decl sites);funcWantsImplicitCtxskips.naked(no implicit ctx, like.c); both body-lowering paths bypasslowerValueBodyfor.naked(asm body +unreachablecap — no sx return);emit_llvmPass 2 bails loudly onfunc.is_naked.examples/1800-concurrency-naked-asm.sxlocked to the bail (exit 1 + diagnostic). Suite green (722/0). (ABI variant later renamed.pure → .naked— see the Naming decision above — so allis_*/abi(.*)/example names here readnaked.) - B1.0a review-hardening — adversarial review found generic/pack Function-creation paths
left
is_nakedfalse (silent framed body for a generic.nakedinstance — returned 42 but corrupted the stack). Fixed generic.zig + pack.zig (setis_naked+ asm-onlyunreachablecap); locked byexamples/1801-concurrency-naked-generic-bail.sx. The review's.naked- lambda CRITICAL was a false positive (unparseable —isLambdabreaks onabi). Suite green (723/0). - B1.0b — real
nakedemission: emit_llvm declaration pass adds LLVMnaked/noinline/nounwind+ skipsframe-pointerforfunc.is_naked; Pass 2 emits the body verbatim (no prologue).1800green aarch64-pinned (exit 42 +.ir); renamed1801→-generic(generic.nakedemits a naked body, exit 42); added x86_64 sibling1802(ir-only,.irlocksnaked+movl $42, %eax). Unit test assertsnakedpresent +frame-pointerabsent. Suite green (724/0). - B1.0c — review-hardening: param-bearing
.nakedemitted invalid LLVM (loud verifier error). Gated the param-alloca loop onfd.abi != .naked(decl.zig both paths + generic.zig) — naked args stay in registers, read by the asm body (the B1.3 context-switch shape). Locked byexamples/1803-concurrency-naked-asm-param.sx. Pack.nakedleft unsupported (loud, nonsensical). B1.0 complete. Suite green (725/0). - rename — ABI variant
.pure → .naked(keyword,Function.is_naked, diagnostics, examples 1800-1803*-pure-* → *-naked-*, docs). "pure" universally means side-effect-free — wrong for a register-clobbering switch; "naked" matches LLVM/Zig/Rust/GCC/Clang. Pure cosmetics, no semantic change. Suite green (725/0). - B1.1 — per-fiber
contextroot: zero compiler change (probe-confirmed). The spawn convention (snapshotcontext→ store in a struct →push f.root { entry() }from the trampoline) installs the fiber's root via the implicit slot-0*Contextparam; the body reads the snapshot, not the trampoline's ambient ctx, and thepushscope restores ambient on exit. Locked byexamples/1804-concurrency-context-snapshot.sx(printsfiber root: 42/ambient after: 99). Suite green (726/0). Next: B1.2 (Io interface + context.io). - B1.2 (BLOCKED) — built the full
Iocapability (protocol onContext, statelessCBlockingIoblocking default, both__sx_default_contextmaterializers, push-inherit-omitted fix,!-impl-method warning fix) and VERIFIED the core works live (context.io.now_ms()→clock ok). Two independent compiler bugs blocked theasync/await/timeoutlayer: 0150 (voidstruct field → unsized SIGTRAP, blocksFuture(void)) and 0151 (type-var from a fn-ptr param's return type not bound in the body, blocksasync'sFuture(R)). Both filed with standalone repros + investigation prompts. Per the STOP rule: reverted ALL B1.2 working changes (master green again, 726/0; the dirty binary had broken the photo project — see the now-moot 0149), saved WIP to.sx-tmp/b12-wip/, STOPPED. Resume after 0150 + 0151.