Add an .optional arm to regToValue in comptime_vm.zig: read the
has_value flag at offset sizeof(child), bridge the payload recursively
into a { payload, i1=true } aggregate when set, yield .null_val (zero
{T,i1}) when clear or the bare null sentinel. Matching serialize arm in
serializeAggregateValue (emit_llvm.zig). Pointer/?Closure/?Protocol-child
optionals and array-payload aggregates bail loudly, not silently.
Regression: examples/comptime/0643-comptime-run-optional-aggregate.sx
(present ?T, present ?i64, null ?i64). Verified by 3 adversarial reviews.
Five adversarial reviews of the issue-0160 fix surfaced three more bugs in the
touched optional-chain / optional-coercion code; all fixed here:
1. A COLD generic-instance getter through `?.` (`?*Vec(i64)` `.getter`, never
called directly first) panicked with "unresolved type reached LLVM emission":
a cold instance method is absent from resolveFuncByName, so the getter's
return type resolved to .unresolved → a ?unresolved merge type. lowerOptionalChain
and getterReturnTypeOnDeref now warm the monomorph (ensureGenericInstanceMethodLowered)
before querying its return type. (The 0907 test passed only by luck — List(i64)
is warmed by stdlib use; 0907 now also exercises a cold user generic.)
2. A real-field read through a `?*T` chain (`op?.field`, op: ?*T) reinterpreted
the pointer bits as the field (silent garbage) — the some-branch real-field
path didn't load through the pointer. It now derefs `?*T` before the field
access. (Pre-existing — the else-branch predates 0160 — but it's the same
function and a silent miscompile, so fixed here.)
3. `?[]T = array` skipped the array→slice promotion (corrupt .len/.ptr): the
lowerVarDecl optional arm wrapped the raw array. It now coerces the value to
the optional's child type (array→slice) before wrapping.
Regression examples 0906/0907 extended to cover all three. Distinct PRE-EXISTING
bugs the reviews surfaced in untouched subsystems are filed as issues 0161
(struct-literal vs scalar), 0162 (#run returning an optional aggregate), 0163
(untagged-union payload-binding match).
Two fixes for optional interactions surfaced by the #set/#get review. The
original issue 0160 mis-diagnosed (A) as an optional-chain bug; the chain works
fine for real fields. The actual bugs:
(A) A bare struct literal `.{ ... }` against an optional target `?T` was built
into the optional's {payload, has_value} layout instead of the inner T, then
re-wrapped — corrupting the value (a multi-field payload's first field clobbered
by the has_value flag, or a `?T` arg silently null) or failing LLVM
verification. lowerStructLiteral now builds the inner T, materializes it, and
wraps via coerceToType; lowerVarDecl's previously-UNCONDITIONAL optional wrap is
guarded so an already-`?T` value isn't double-wrapped. Fixed across var-decl,
arg, return, nested field, reassignment, and array-element contexts.
(B) `#get` accessors are now reachable through an optional chain (`obj?.getter`):
lowerOptionalChain dispatches the getter via a synthetic receiver, and
expr_typer types `obj?.getter` through a shared getterReturnTypeOnDeref helper
(handles `?T` and `?*T`, value and pointer optionals, and generic-instance
getters like List.len). The `#set` write side through `?.` is intentionally left
matching real-field behavior (optional-chain assignment unsupported).
Regression tests: examples/optionals/0906 (struct-literal → optional) and 0907
(accessor through chain). issues/0160 marked RESOLVED with the corrected root
cause.
A method `name :: (self: *T, value: V) #set { ... }` (or `=> expr;`) is the
write counterpart of a `#get` accessor: `obj.name = rhs` dispatches to it as
`obj.name(rhs)` when no real field matches. Plumbed parallel to `#get`:
- lexer/token `#set`; `FnDecl.is_set` + `Function.is_set`; parsed in the same
marker slot as `#get` (no return type, exactly self + one value param).
- get+set coexistence: a setter registers/mangles/dispatches under an effective
`name$set` name (`$` is illegal in sx identifiers, so unmistakable), keeping a
same-name `#get` under the plain `name`. Resolution is declaration-order-
independent: a plain read query picks the non-setter, a `name$set` write query
picks the setter (accessorEffName / accessorNameMatches / structMethodFn).
- write dispatch in lowerAssignment via tryLowerPropertyAssignment: plain assign
synthesizes `obj.name$set(rhs)`; compound `OP=` is get-modify-set and
evaluates the receiver EXACTLY ONCE (bound to a synthetic local); read-only
(#get-only) and write-only (#set-only + compound) emit clear diagnostics; a
real field of the same name still wins. Multi-assign property targets dispatch
the setter too (tryLowerPropertyStore, via a pre-lowered-Ref binding).
Payoff: List gains a `len` #set, so `xs.len = n` works; the `.items.len = N`
write workarounds in sched.sx + ui/* + platform/* revert to `xs.len = N`.
issues/0160 records an optional-chain interaction surfaced by the review (a
pre-existing `?T` value-optional read miscompile that blocks getter-through-`?.`).
items is now a []T slice whose .len IS the live element count (cap = allocated
capacity), so a List iterates directly: `for xs.items (e) { ... }`. A
`len :: (self) -> i64 #get => items.len` accessor keeps `xs.len` reads working;
`.len` WRITES become `.items.len`. List stays 24 bytes (`[]T`=16 + cap=8).
- list.sx: append/ensure_capacity/deinit rewritten for the slice backing. deinit
guards the free on `cap > 0` (true ownership) and resets via explicit
ptr=null/len=0 (a `.{}` slice assignment yields a garbage len; `.[]` is the
empty-slice literal but can't be assigned to a generic []T — both worked around).
- Compiler coupling updated: comptime_vm makeStringList/readStringList write/read
items as a {ptr,len} fat pointer at field 0 + cap at field 1; control_flow
listView views an `items: []T` slice (keeps the legacy {[*]T,len} shape too).
- Migrated List `.len` writes to `.items.len` in sched.sx + ui/{render,pipeline,
glyph_cache} + platform/{sdl3,android,uikit}.
- Snapshots: List's type-table layout changed → ~40 .ir + memory/0800 (items now
prints as a slice) regenerated; diagnostics/1183 retargeted to a genuine
many-pointer (xs.items is a slice now). Example memory/0840 locks for-each.
A method declared `name :: (self: *T) -> R #get => expr;` is invoked via
no-paren field syntax (`obj.name`) instead of `obj.name()`. It is an ordinary
method (registered `Type.method`, flagged is_get); field-access lowering and
inference dispatch to it when no real field of that name exists, by synthesizing
a no-arg `obj.name()` call routed through the normal call path (so receiver
address-of and generic binding are reused).
- Lexer/token: `#get`. Parser: parsed after the return type in parseFnDecl;
hasFnBodyAfterArrow treats it as a body marker so struct-body methods parse.
- Resolution: getAccessorFor handles a generic-struct instance and a plain
struct. A REAL field of the same name wins (a getter never shadows stored
data). An explicit postfix-deref receiver (`p.*.getter`) dispatches on the
inner pointer so it takes the working auto-deref path.
- Works on plain + generic structs (incl. getters returning the type param),
in expressions/conditions/args/loop-bounds, chained, and via a pointer
receiver. Examples: types/0196 (basic) + types/0197 (stress).
Known narrow limitations (clean errors / workarounds, not silent): a getter
RESULT used directly as a method/getter receiver (`o.gi.dbl`) errors — bind it
to a local first; a getter named `len`/`ptr` returning non-i64 mis-infers
(the .len/.ptr builtin-field shortcut).
emitSubslice handled a struct (slice/string) base and an array base, but a
many-pointer [*]T base is an LLVM pointer kind — it fell through to the else arm
that mapped the result to LLVMGetUndef(slice_ty), so a slice of a many-pointer
(mp[lo..hi]) had a garbage .len/.ptr and iterating it segfaulted.
Add a LLVMPointerTypeKind branch: the base value IS the data pointer, so GEP by
lo and len = hi - lo (the caller supplies the bound; no length is read from the
unbounded pointer). An open-ended mp[lo..] has no resolvable upper bound (a [*]T
carries no length), so lowerSliceExpr now diagnoses it instead of emitting a
.length op that yields garbage.
A List (whose items is [*]T) is now iterable with for items[0..len] (e);
applied in Scheduler.deinit. Regressions: examples/types/0195 (valid slice +
List for-each) + examples/diagnostics/1192 (open-ended rejection).
Scheduler.deinit closes the bounded leaks B1 documented: it reaps any leftover
ready fibers, frees every heap Task from go (now tracked via a task_allocs
field), frees the timers/io_waiters/task_allocs List backings, and closes the
lazily-opened kqueue fd. Terminal + idempotent; the per-spawn/go closure env
remains unfreeable (language limitation). Locked by
examples/concurrency/1820-concurrency-fiber-scheduler-deinit.sx, which exercises
every freed resource under a tracking GPA (freed by deinit: 5, kq reset to -1).
Also converts plain-struct '= ---'+field-assign init to '.{ ... }' literal init
where '---' carries no meaning: Scheduler.init, Dock.make, and the fiber
examples 1811/1813/1814/1816 (partial literals zero-fill the index-filled array
fields). Unions, '---'-feature tests, the 0154 regression, documented
generic-pack gaps, and loop/conditional inits are intentionally left on '---'.
A plain union initialized with a struct literal (b : Overlay = .{ f = 3.14 })
silently miscompiled — it fell through the generic struct-literal path
(getStructFields returns empty for a union), building a malformed structInit
whose overlapping zero-fill clobbered the named member, so it read back 0.0
(and a type-pun read segfaulted).
lowerStructLiteral now detects a plain-union target and dispatches to a new
lowerUnionLiteral, which writes each named member into a union-sized slot via
the same lvalue resolver the u.member = v assignment path uses, then loads the
union value back. Validity: the named members must share one arm — a single
direct member, or several promoted members of the same anonymous-struct variant.
Overlapping members, members from different arms, and positional union literals
are rejected with a diagnostic (no silent last-wins); an empty .{} yields an
undefined union (matching the --- form).
specs.md updated. Regressions: examples/types/0194 (valid forms) +
examples/diagnostics/1191 (overlap rejection).
UFCS generic overload resolution (issue 0157 follow-ups):
- P1-a: call planning (calls.zig) used the last-wins fn_ast_map winner
while lowering reselected by receiver, so the planned result type
could disagree with the dispatched function and misbox the result.
Both now share selectUfcsGenericByReceiver(.., fd0).
- P1-b: selection scanned module_decls globally, flagging a
transitively-hidden same-named overload as a false ambiguity. Now
two-tier: directly-visible authors first (ambiguity only among
those), global fallback for receiver-reachable namespaced methods
(e.g. Task.cancel) that defers to fd0 on a hidden tie.
- P2-b: boolean specificity tied *$T with *Box($T). Now peels pointer
layers so the structurally-narrower receiver wins.
Scheduler (sched.sx):
- P1-c: a second concurrent Task.wait overwrote the single waiter slot
-> silent deadlock. Now one-awaiter-per-task loud abort.
- P2-c: sleep(negative) rewound the monotonic virtual clock. Rejected
loudly.
(P2-a, non-generic-winner-hides-generic, did not reproduce -- the
non-generic arm already falls through.)
Regressions: examples/generics/0218 (receiver specificity +
plan/lowering agreement), examples/concurrency/1818 (negative-sleep
abort), 1819 (double-wait abort). Suite green 758/0.
1817 composes the whole colorblind pure-sx async stack: the M:1
scheduler, suspending go/wait async, and deterministic virtual-time
sleep/now_ms, over the naked swap_context on guarded mmap stacks. A
coordinator launches three async tasks (sleep 30/10/20 -> return
100/20/3), awaits all three in spawn order, and sums them; tasks
complete in DEADLINE order (task 2@10, 3@20, 1@30), sum 123, final
virtual clock 30 -- fully deterministic.
Stream B1 (fibers + Io + M:1 scheduler) is feature-complete: examples
1800-1817, suite 755/0. Checkpoint + plan marked COMPLETE; next carve
is Stream B2 (channels / cancel / async stdlib).
A fiber can block on a file descriptor and the run loop blocks on
kevent until the kernel reports it ready. Reuses the existing
std/net/kqueue.sx bindings. Scheduler gains a lazy kq fd + an
io_waiters list; block_on_fd arms a one-shot EVFILT_READ registration,
records an IoWaiter, and suspends. Run-loop Mode 2: when the ready
queue drains and no timer is pending, block on kq_wait(-1), match each
fired ident to its waiter, evict it, wake the fiber. wake evicts a
pending fd-waiter (cancel_io_waiter_for) so no stale IoWaiter outlives
a reaped fiber.
Adversarial review found two CRITICALs: (1) two fibers on the same fd
share one kqueue registration (macOS EV_ADD replaces), so one is lost
and the loop hangs -- fixed by enforcing one-waiter-per-fd with a loud
abort; (2) an fd-waiter on a never-ready fd 'hangs' -- reclassified as
correct event-loop semantics (a server idling on a socket), with the
misleading orphan-check comment corrected. UAF parity, ident width,
EINTR handling, timer/io precedence all probed safe.
Example: 1816 (pipe roundtrip -- reader blocks, writer writes, reader
wakes via kqueue). macOS only; linux epoll twin deferred. Suite green 754/0.
Add a virtual clock + sleep timers to the M:1 scheduler so fibers
schedule in reproducible simulated time. Scheduler gains clock_ms (the
virtual clock, advances only as timers fire), a timers list, now_ms(),
sleep(ms) (arm {clock_ms+ms, current} + suspend), and a timer-driven
run (drain ready -> fire earliest timer -> advance clock -> wake ->
repeat; the orphan-suspend deadlock check is preserved for a genuine
no-timer park). Wakes fire in deadline order with a FIFO tiebreak.
Adversarial review found a use-after-free: a fiber woken early (manual
or Task wake) before its sleep timer fired was reaped while its Timer
kept a dangling *Fiber, so a later fire dereferenced freed memory.
Fixed: wake evicts the fiber's pending timer (cancel_timer_for) -- every
re-ready path funnels through wake, so no stale timer outlives its fiber.
Examples: 1814 (sim-timer deadline ordering), 1815 (early-wake timer
eviction regression). Suite green 753/0.
library/modules/std/sched.sx: a generic Fiber + Scheduler over the
proven naked swap_context on guarded mmap stacks --
init/spawn/yield_now/suspend_self/wake/run (B1.5a), then Task($R) +
go/wait/cancel, a truly-suspending nullary-thunk async layer (B1.4a).
go(work) runs a thunk as a real fiber; wait() parks the caller until it
completes. Self-contained in sched.sx (io.sx importing it would
duplicate the _fib_tramp global asm).
Hardened per adversarial review: wake guarded on .suspended (FIFO
corruption), suspend_self/yield_now guard a null current, loud
mmap/mprotect/OOM/deadlock bails, cancel skips not-yet-run work.
Closure-env + heap-Task leaks documented (bounded, default-GPA-invisible).
Examples: 1811 (round-robin), 1812 (suspend/wake + spurious-wake guard),
1813 (async interleave + await-suspend + cancel). Also files issue 0155
(scalar-pointer index panics codegen -- non-blocking, found in review).
0156 Part 1: a single-type generic $R (parsed as comptime_pack_ref)
used as a type-arg in a pack-fn body (Box($R), size_of(Box($R))) hit a
missing arm in resolveTypeWithBindings -> .unresolved -> LLVM panic.
Fix: mirror resolveTypeArg's comptime_pack_ref arm (look up
type_bindings, else a loud diagnostic). Regression: examples/generics/0216.
(Part 2 -- deferred .. spread crashes -- reframed OPEN/non-blocking.)
0157: a user generic ufcs method whose name collides with a stdlib
re-export resolved via last-wins fn_ast_map with no receiver filtering,
so the wrong overload won, $R never bound, and .unresolved reached LLVM.
Fix: selectUfcsGenericByReceiver enumerates all module authors, keeps
the receiver-binding ones, picks the most receiver-specific (concrete >
bare $T), dedups re-exports, and flags a genuine tie as a deterministic
'ambiguous -- qualify' diagnostic. Regression: examples/generics/0217.
Assigning null/--- to a struct field picked up a leaked enclosing
target_type (the function's return type, set for the whole body), so
constNull/constUndef built a whole-struct-typed value. The oversized
store overran the field's slot and clobbered the saved frame pointer,
so the function returned to 0x0. Surfaced building a by-value-returned
struct whose array field precedes a pointer field (Scheduler.init()).
Fix: add null_literal/undef_literal to the needs_target switch in
lowerAssignment so the field's own type is used. Regression:
examples/types/0193-types-sret-array-before-pointer.sx.
Move examples/*.sx and their expected/ snapshots into per-category
subfolders (examples/<category>/...). Folder = leading filename token,
with ffi-objc/ffi-jni kept whole; filenames are unchanged. The corpus
runner and LSP sweep now discover each category's expected/ dir, while
issues/ stays flat. Example 1058's repo-root-relative companion import
is made file-relative. Path strings embedded in 164 snapshots were
regenerated (path-only changes). Test-layout docs in CLAUDE.md updated.
Protocol method declarations now declare their receiver explicitly as the first
parameter — 'self: *Self' (or 'self: Self') — matching the impl method signature,
instead of the old implicit-receiver form where the listed params were only the
extra args. That asymmetry repeatedly caused confusion over whether the first
param was the receiver or an argument.
The parser validates the first param is 'self' typed Self/*Self, then strips it,
so all downstream lowering and the dispatch ABI are unchanged (impl blocks and
call sites are unaffected). A protocol method missing the receiver is now a parse
error.
Migrated all 129 protocol method signatures across library + examples (+ one
inline-sx test in sema.zig) to the explicit form. Updated specs.md + readme.md.
New: examples/0418-protocols-explicit-receiver.sx (feature),
examples/1190-diagnostics-protocol-missing-receiver.sx (negative/diagnostic).
Attempted the canonicalize-path fix (realpath + cwd-relativization for display);
it fixes the e2e absolute-entry duplication but ripples path-identity changes
into ~8 import-subsystem unit tests + 2 cosmetic snapshots. Reverted as too broad
for a drive-by rework; documented a minimal repro and a recommended deliberate
approach (lexical normalize, single chokepoint, batch test updates). Issue stays
OPEN.
size_of(sel.Selection) and the other reflection builtins rejected a
module-alias-qualified type: in argument position it parses as a .field_access
expression (not the dotted .type_expr a declaration produces), and neither
isStaticTypeArg nor resolveTypeArg had a .field_access arm. Add both: a pure
namespace-decl scan in isStaticTypeArg, and resolution via namespaceAliasTarget
+ resolveNominalLeaf in the target module context in resolveTypeArg (mirroring
the value-position lowerFieldAccess path). No fabricated-stub fallback.
Regression: examples/0192-types-size-of-qualified-alias.sx
Each banner was re-verified against the current binary (repro now behaves
correctly) and cites the actual fix location in current src/** plus the covering
regression example. Closes the stale-but-fixed backlog: 0019, 0042-0056, 0131.
No compiler change.
A struct/tuple/?T with a void field crashed the compiler: the field lowered to
LLVM's unsized 'void' type, which traps getTypeSizeInBits. Lower a void field to
a SIZED zero-byte [0 x i8] (fieldLLVMType) so the enclosing aggregate stays sized
with identical element indices, and skip inserting a value for a void field in
emitStructInit (the i64 placeholder would type-mismatch the [0 x i8] slot and
corrupt the aggregate constant -> runtime bus error). Future(void) now works.
Regression: examples/0190-types-void-struct-field-zero-sized.sx
A comparison with int-vs-float (or two float widths) operands emitted cmp on
the raw operands with no promotion, unlike the arithmetic arms -- producing a
mixed-type compare the LLVM verifier rejects / mis-evaluates. lowerBinaryOp now
coerces each operand to the promoted common type (from arithResultType) via
coerceToType (SIToFP / FPExt) for the ordering/equality arms when the promoted
type is a float, so LLVM gets a well-typed fcmp.
Regression: examples/0189-types-int-float-compare-promote.sx
A *self method called directly on arr[i] (or a deref place) fell through to an
alloca+store-of-value, so the callee mutated a throwaway copy and the live slot
was never written. fixupMethodReceiver now takes the real address of
.index_expr/.deref_expr receivers via lowerExprAsPtr (normalized to *T),
mirroring the explicit-argument path. A comptime-pack index (xs[i] where xs is
a pack) is excluded -- a pack has no runtime storage to address -- so it keeps
flowing through the general copy path.
Regression: examples/0188-types-method-array-index-receiver.sx
A local 'error { ... }' set with the same name as an imported one collapsed
onto the import, losing its own tags, because registerErrorSetDecl deduped via
the flat findByName path while struct/enum/union use E6a per-decl identity.
Build the .error_set TypeInfo (new buildErrorSetInfo helper factored from
resolveInlineErrorSet) and intern via internNamedTypeDecl with shadowNominalId;
reserve a distinct shadow slot in scanDecls; consult per-decl type_decl_tids in
namedRefTid before findByName. The inline/anonymous findByName short-circuit is
preserved.
Regression: examples/1059-errors-same-name-error-set-own-wins.sx (moved from
issues/0134).
A bodiless #builtin with a $T: Type param routes through monomorphization.
When resolveBuiltin returned null for an unrecognized name, the builtin-body
branch fell through to ensureTerminator's constInt(0) -- a silent-fallback
default the CLAUDE.md REJECTED PATTERNS forbid. Emit a loud
'error: unknown #builtin <name>' diagnostic instead.
Regression: examples/1189-diagnostics-unknown-builtin.sx
A program with no 'main' reached the JIT entry-point call with a garbage
address (ORC reports lookup success but leaves main_addr degenerate), then
called it -> SIGSEGV. Add a pre-JIT entry-point check in main.zig that emits
'error: no main function found' and exits non-zero before codegen, plus a
defensive main_addr==0 guard in target.zig runJITFromObject as a backstop.
Regression: examples/1188-diagnostics-run-no-main.sx
async_void :: ufcs (io, worker: Closure() -> $R) -> Future($R) was redundant:
the variadic async :: ufcs (io, worker: Closure(..$args) -> $R, ..$args) binds
$args to the empty pack, so context.io.async(() -> $R => ...) already calls
worker() and returns Future($R). The name was also misleading — it returns
Future($R), not void (a true void form is Future(void), separate, blocked by
issue 0150).
Removed the definition (std/io.sx) + the std.sx re-export; nothing else
referenced it. Locked the nullary path in examples/1805 (prints nullary: 42) so
the coverage async_void provided is not lost. Suite green 736/0.
The context switch is now proven on a second arch/ABI pair. A Win64 swap_context
saves the complete Win64 callee-saved set: 8 GP (rbx,rbp,rdi,rsi,r12-r15) + rsp
AND xmm6-xmm15 (10 XMM, 128-bit via movups -- Win64 has callee-saved XMM, unlike
SysV/aarch64), plus a Win64 scribble_verify (264-byte frame, 32-byte shadow +
16-align at each call, COFF symbols, rsp-carried return address) driving the
2-fiber mutual scribble.
Built --target x86_64-windows-gnu --self-contained (PE32+, output via the Win32
WriteFile boundary -- the 1660 pattern) and run on a Windows 7 x64 VM (UTM):
printed '0 0 P' -- every GP + XMM callee-saved register survived the switch.
Adversarially reviewed before the VM run (worker emitted the real .s and
verified every call alignment, the frame offsets, the rsp/return-address
round-trip, swap ordering, and COFF naming against the Win64 ABI -- no
critical/minor bugs).
Locked by examples/1810-concurrency-fiber-switch-win64.sx (pinned
x86_64-windows-gnu, ir-only on this non-Windows host; the VM run is the
runtime-correctness provenance). Good-swap-only mutual scribble (self-validating
by construction; the in-process negative control was dropped to avoid an sx
fn-ptr-convention issue -- detection of this exact logic was negative-controlled
on aarch64 in 1808).
Suite green 736/0. The B1.3 switch is proven on aarch64 + x86_64/Win64. Next:
B1.4 (Io impls / M:1 scheduler).
Fiber stacks are now mmap'd with a PROT_NONE guard page at the low end: mmap a
[guard | usable] region and mprotect the low 16KB page PROT_NONE, so a stack
overflow faults at the guard boundary instead of silently corrupting a neighbor
(design 8.1.1 — fixed stacks without a guard corrupt silently on overflow).
Locked by examples/1809-concurrency-fiber-guard-stack.sx (aarch64-macos-pinned):
guard armed: 1 (mprotect -> 0) + sum: 20100 (a fiber runs real recursion on the
guarded stack and yields). The guard FIRING is validated manually (a fiber
recursing past its 128KB stack faults with Bus error at region+GUARD, exit 134
via the sx crash handler) — not corpus-pinned, since a deliberate-overflow crash
is host-fragile and a 'child faulted' fork test would not prove the boundary
catch specifically.
The x86_64 swap_context sibling is DEFERRED: sx build --target x86_64-macos
mislinks on this arm64 host (object x86_64, link step arm64) and x86_64-linux
can't run here, so it could only ship IR-only / unrun. For the highest-
corruption-risk asm, shipping un-run / un-negative-controlled code violates the
design 10.7 'correctness not existence' rule. SysV target notes (rbx/rbp/r12-r15
/rsp, no callee-saved XMM, rsp-carried return address) recorded for a future
x86_64 host. Suite green 735/0.
The design section-10.7 correctness gate the foundational switch owed: explicitly
scribble EVERY callee-saved register, switch, and verify each survived.
- Extended swap_context to the COMPLETE AAPCS64 callee-saved set: integer
x19-x28 + fp/lr + sp AND the FP regs d8-d15 (21-slot context). Per AAPCS64
6.1.2 only the low 64 bits of v8-v15 are callee-saved, so d8-d15 is exactly
sufficient; x18 is Apple's reserved platform register, untouched.
- naked scribble_verify(self_ctx, peer, base): loads a unique sentinel into all
18 callee-saved regs, bl swap_context to yield, and on resume counts the regs
that did not survive. Honors its own caller ABI via a 176-byte frame that
saves+restores the caller's callee-saved; base reloaded post-swap (x2 not
preserved); the original lr round-trips through the swap.
- The gate is a 2-fiber MUTUAL scribble (A and B scribble DISTINCT sentinels into
the same physical regs), so a value survives only if swap_context saved and
restored it. A lone fiber yielding to an idle peer would NOT exercise
preservation.
Locked by examples/1808-concurrency-fiber-switch-stress.sx (aarch64-pinned):
A/B mismatches: 0. Validity proven by negative controls: dropping the d8-d15
save/restore reports 8/8 mismatches (the FP regs); dropping x27/x28 reports 2/2.
Adversarial review (worker): no critical bugs — callee-saved set complete and
correct, all frame offsets / 16-alignment / the lr-sp dance verified against
AAPCS64. Applied its one recommendation: boot zeroes the FP ctx slots so a first
switch-to loads 0, not garbage, into d8-d15. Residual gaps (spec-correct for a
call-boundary swap, documented in the header): FPCR/FPSR/NZCV + TPIDR/TLS are not
swapped, fp=0 blocks unwind past a fiber trampoline — these matter at the N×M:1 /
signals stages, not the single-thread switch.
Suite green 734/0. Next: B1.3b (x86_64 sibling + mmap guard-page stacks).
The first piece of the B1.3 fiber runtime — the stackful context switch, pure
sx over abi(.naked). swap_context(from, to) saves the callee-saved registers +
SP/LR into *from and loads them from *to, then rets onto to's stack (SP-in !=
SP-out by design — why it must be .naked). Fibers are bootstrapped by hand: the
saved context starts with SP = top of an alloc_bytes stack, LR = a global-asm
trampoline (mov x0, x19; bl _fib_body, reaching the sx body via export), and
x19 = the *Fiber.
Locked by examples/1807-concurrency-fiber-context-switch.sx (aarch64-pinned):
- 2-fiber ping-pong (A <-> B, 3 rounds each): rounds: 6, and a per-fiber stack
canary held live across every suspend survives (canary fails: 0);
- a 64-frame deep recursive chain suspended at the bottom and resumed, verifying
every frame's stack-local on the unwind (frames verified: 64, depth fails: 0).
Scope (honest): exercises register/stack preservation INDIRECTLY (compiler-
allocated live values + the canary). The EXPLICIT every-callee-saved GP
(x19-x28) + FP (d8-d15) sentinel scribble — the full design-section-10.7 gate —
is B1.3a-2, still owed. x86_64 sibling + mmap guard-page stacks are B1.3b.
Suite green 733/0. Runs under JIT, ir-only on a non-arm host.
With the three surface blockers fixed (0151 generic inference, 0152
Atomic(bool), 0153 re-export failable channel), the M:1 async surface works
end-to-end on the blocking Io default. Landed the corpus examples:
- 1805-concurrency-io-blocking-async.sx: context.io.async(lambda, ..args)
runs the worker inline, await() or {…} yields the result; context.io.now_ms()
reads the monotonic clock. Prints sum: 42 / double: 42 / clock ok.
- 1806-concurrency-io-cancel.sx: f.cancel() marks the future canceled so a
later await() raises error.Canceled out of its (R, !IoErr) channel, caught
with or. Prints ok: 7 / canceled: -99.
B1.2 (Io capability on Context + async/await/cancel + blocking CBlockingIo) is
complete. Suite green 732/0. Next: B1.3 (fiber runtime).
inferGenericReturnType resolved a generic call's return-type AST ($R, !E) in
the CALL-SITE module context. For a re-exported fn the error-set name (LE /
IoErr, re-exported as LE :: lib.LE) resolved through the call-site alias to a
TypeId NOT tagged .error_set, so the planned result was a tuple whose last
field wasn't an error set — errorChannelOf saw a plain tuple and the value-
failable's ! channel was lost (try/or rejected it / built a malformed i1 PHI).
monomorphizeFunction already pins the source to the fn's defining module
before resolving the return type; inferGenericReturnType did not, so the
planned call-result type disagreed with the instance's real signature. Fix:
pin the source to fd.body.source_file around the return-type resolution
(binding-build stays in the call-site context — its args are typed there).
Regression test examples/1058-errors-reexport-value-failable-channel.sx
(+ companion lib.sx). Suite green 732/0.
With 0151 + 0152 fixed, the async surface is callable and Atomic(bool) works.
Building the async examples isolated the TRUE remaining blocker (the earlier
'secondary or PHI' symptom, confirmed NOT an Atomic cascade): a re-exported
generic value-failable ($R, !E) fn loses its ! error channel at the call site
— the result types as a plain tuple, so await(...) or { ... } / try ...await()
fail / build a malformed i1 PHI. await/IoErr are re-exported via std.sx, so the
async surface hits it.
Narrowed to the generic + re-export co-requirement (non-generic re-export OK;
direct generic import OK). Filed issues/0153 with a minimal co-located 2-file
repro + a single-file stdlib-await repro + investigation prompt (root cause:
the monomorphized return-type's error-set, reached via the re-export alias,
resolves to a non-.error_set TypeId, so errorChannelOf misses the channel).
Per the STOP rule, paused B1.2's async examples pending the 0153 fix.
LLVM rejects a sub-byte atomic memory access (must be byte-sized), so
Atomic(bool) — bool lowers to i1 — failed verification on load/store. The
atomic emitters in src/backend/llvm/ops.zig now perform a sub-byte access in
its byte storage type (i8) and trunc/zext the value at the boundary (new
atomicByteType helper: i8 for .bool, null otherwise). rmw/cmpxchg are left
as-is on purpose — a bool rmw/CAS is rejected at the sx level (integer-only),
so a sub-byte element never reaches those emitters.
Regression test examples/1705-atomics-bool-byte-promoted.sx. Suite green 729/0.
Unblocks Future.canceled: Atomic(bool) in the B1.2 async layer.
issue 0151 (generic $T inference through generic-struct / pointer / UFCS-pack
params) is fixed and committed, so io.sx's async/await/cancel are now callable
in every form. Building the async examples then tripped a SEPARATE codegen bug:
Atomic(bool) emits a sub-byte (i1) atomic load/store that fails LLVM
verification (must be byte-sized). Future.canceled: Atomic(bool) hits it.
Filed issues/0152 with a standalone repro + investigation prompt (codegen fix
in src/backend/llvm/ops.zig — promote sub-byte atomics to i8 storage). Per the
STOP rule, paused B1.2's async examples (1805/1806) pending the 0152 fix.
Checkpoint updated: 0151 RESOLVED, async surface BLOCKED on 0152.
The generic-inference engine could not bind a $T from a generic-struct
argument head. Four gaps, all on the inference + UFCS dispatch path:
- extractTypeParam / matchTypeParam(Static) gained a parameterized_type_expr
arm: recover the arg instance's recorded per-param bindings
(struct_instance_bindings + the template's ordered type_params via
struct_instance_author) and recurse positionally, so $T binds from
Box($T) <=> Box(i64) like it does from []$T <=> []i64. This also fixes
the pointer case — *Box($T) recurses into its Box($T) pointee.
- The pointer_type_expr arm now falls through to match the pointee against a
non-pointer arg (auto-address-of: a *Box($T) param accepts a by-value
Box($T), e.g. the UFCS receiver b.m()).
- ExprTyper.inferType gained a .lambda arm building the closure type from the
lambda's annotations, so the UFCS binder (which types args from the raw AST
before they are lowered) can bind a Closure(..) -> $R from the worker's
declared return type.
- A pack UFCS target (worker: Closure(..) -> $R, ..$args) now routes through
the same lowerPackFnCall the direct call uses, with the receiver spliced in
as args[0] (lowerPackFnCall reads only call_node.args, never the callee).
Regression tests: examples/0214 (direct + UFCS closure-return pack) and
examples/0215 (by-value / pointer / multi-param / nested / UFCS-auto-ref
generic-struct-head inference). Suite green 728/0.
Adversarial review of 45d869d: the Io infrastructure (both materializers,
push-inherit, 37 .ir regens, !-lint) is correct + landed; but await/cancel
(*Future($R)) are uncallable in EVERY form because sx can't infer a generic
$T from a pointer-wrapped arg. Widened issue 0151 to that root (repro:
unbox(b: *Box($T)) -> $T). Checkpoint: B1.2 partially landed; next = fix 0151
generic inference -> make await/cancel callable -> add 1805/1806 -> B1.3.
A generic free fn whose `$R` is inferred from a worker `Closure(..$args) -> $R`
(+ trailing `..$args`) and which returns a type built from `$R` (`-> Wrap($R)`)
monomorphizes correctly when called directly (`f(recv, worker, ..)`) but leaves
`$R` UNRESOLVED when called via UFCS dot syntax (`recv.f(worker, ..)`) — the
unresolved type reaches LLVM emission and trips the `.unresolved` tripwire
(SIGTRAP). Distinct from RESOLVED issue 0119 (UFCS `$T` from receiver/slice).
Blocks the B1.2 user-facing async idiom `context.io.async((a,b) -> R => ..., x, y)`
(a UFCS call inferring $R from the worker closure's return type). The Io/async
library + compiler plumbing are in place and correct (landed in the prior
commit); only the UFCS call form hits this inference gap. Repro depends on no
project symbols beyond modules/std.sx; unpinned (no expected/ marker) so it
does not run in the corpus.
Threads an `Io` capability onto `Context` exactly like `Allocator`: a
`protocol #inline` whose process-wide default is a stateless `CBlockingIo`
(the mirror of `CAllocator`), installed in `__sx_default_context`.
Library (library/modules/std):
- core.sx: `Io` protocol (spawn_raw / suspend_raw / ready / poll / now_ms /
arm_timer) + `SpawnOpts` / `PinTarget` / `ParkToken`; `Context` gains an
`io: Io` field LAST (allocator stays index 0, data stays index 1).
- io.sx (new): `CBlockingIo` + `impl Io` (blocking M:1 semantics — now_ms is
a real monotonic clock, the rest are no-ops/0; suspend never called);
`Future($R)` { value; state: FutureState; err: IoErr; park; task; canceled:
Atomic(bool) } with `Value :: R`; the async ergonomic layer
`async` / `async_void` / `await` (value-carrying `(R, !IoErr)`) / `cancel`.
Built with the verified `= ---` + field-assign + `Closure(..$args) -> $R` +
`..$args` idiom (NON-void $R only — Future(void) is deferred per issue 0150).
- std.sx: re-export the Io surface + the io.sx tail.
Compiler (src/ir):
- protocol.zig `emitDefaultContextGlobal` + comptime_vm.zig
`materializeDefaultContext`: both materializers of `__sx_default_context`
now build the inline CBlockingIo->Io vtable (7 words) at the new field.
- stmt.zig `lowerPush`: `push Context.{...}` now INHERITS omitted fields from
the ambient context (seed the slot from current_ctx_ref, overwrite only the
literal's named fields) — correct capability-bag semantics, so the partial
`push Context.{ allocator = X }` sites don't zero a null `io` vtable.
- protocols.zig + lower.zig + error_analysis.zig: record protocol-impl method
names so the "declared `!` but never errors" lint skips a conforming impl
whose `!` is dictated by the protocol contract (e.g. Io.suspend_raw).
37 `.ir` snapshots regenerated: layout-only (the Context type now carries the
Io field, shifting type-table numbering); no stdout/stderr/exit changes.
The blocking Io + now_ms + Future/async work when `async` is called with the
receiver passed explicitly; the user-facing UFCS form `context.io.async(...)`
is blocked on a separate UFCS generic-inference bug (filed next).
Suite: 726 ran, 0 failed.
Adds the blocking-Io async/await example with a seeded empty .exit marker.
The example fails today (Io protocol + context.io + async/await not yet
implemented); the next commit lands the Io interface + blocking impl +
both __sx_default_context materializers + push-inherit fix to turn it green.
Worker is a lambda with annotated params (the verified B1.2 idiom);
named-fn workers are deferred pending a :: callable-param feature.
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.
The B1.2 "blockers" were not real:
- Issue 0151 was INVALID: its repro used the non-idiomatic `($A) -> $R`
bare-fn-ptr form. The canonical higher-order pack idiom
`Closure(..$args) -> $R` + `..$args` (see examples/0543-packs-canonical-map)
infers $R fine and runs today with no compiler change. Removed 0151.
- The correct async idiom is verified working live (42 42 for homo + hetero
args): async :: (io, worker: Closure(..$args) -> $R, ..$args) -> Future($R)
with a lambda worker (annotated params) + a `result = ---; result.v = ...`
build form. No compiler change needed.
Issue 0150 (void struct field -> SIGTRAP exit 133) IS a real bug but is only
reached via Future(void) (void-returning worker / timeout) — deferred to B1.4;
B1.2 supports non-void workers.
Updates the PLAN/CHECKPOINT B1.2 status to UNBLOCKED with the corrected idiom
and the resume plan. No compiler/library code changed in this commit.
User correction: async's args are a variadic heterogeneous comptime pack
(..$args: []Type, specs.md:1383), not a single $A. Orthogonal to 0151
(return type-var binding). Recorded for the B1.2 resume.
The .pure->.naked rename (a7fe165) git-mv'd examples 1800-1803 to their
naked names but the perl content edit (abi(.pure) -> abi(.naked) in the
bodies/comments) was never re-staged, so HEAD carried the renamed files
with stale abi(.pure) bodies — which the compiler now rejects ("unknown
ABI"). The working tree had the correct .naked bodies uncommitted; this
commits them so HEAD parses + builds clean.