Repository navigation
fix(hy-check): E3006 reaches closure bodies, const copies, and delete (fixes #123, stacked on #110/#117) - #126
Conversation
…imitlessprojects#109) A closure value holds live references to every reference-bearing outer binding its body captures, but record_may_share never linked the closure binding to those captures. A later `ch <- move a` froze only the plain alias chain, so `f()` kept writing the moved value: ACCEPT with 0 diagnostics while `go run -race` reported a write/write DATA RACE (receiver vs closure). Link the closure binding to each captured binding with a may-share edge at definition (params shadowed by the closure are excluded; locals inside the body are deliberately not excluded - an extra edge is conservative in the safe direction). The existing freeze machinery then rejects the post-move call as HYB-E3003 (A1b alias-read), the twin of corpus 20's alias-write. - hy-check/tests/capture_freeze.rs: 4 reject forms (channel send, spawn, nested capture, closure rebind) + 4 accept/precision forms (use only before the move, primitive capture, shadowing closure param, capture with no move) - tests/checker-corpus/28-closure-capture-move.hy: EXPECTED-VERDICT REJECT A1b; VERDICTS table 27 -> 28, README and parse_all counts updated Verified: cargo test --workspace green (incl. 8 new tests), corpus harness 28/28, differential FULL AGREEMENT (accept 10/10, reject 18/18), corpus 10 (A5 capture) still ACCEPT. Fixes limitlessprojects#109
…sites (limitlessprojects#111) An insert/delete that invalidates a live `&m[k]` slot loan was detected only when spelled lexically in the loaned function's own statement list, keyed on the loaned binding's exact path string. The same runtime event one frame / one alias / one closure deeper was accepted with 0 diagnostics while the direct twins rejected HYB-E3102 (corpus 08, and the one-line direct-delete twin). Issue limitlessprojects#111 proved the damage: a helper that DELETES the loaned key passes `hyb check`, and the loan's store-back resurrects the deleted entry at runtime (`len after = 2`). Three event sources now reach `emit_a3_if_live`: - peer-aware matching: an event path conflicts with a loan when the roots are the same binding or connected through the may-share graph or the single-parent alias chain (`alias := m` form); - callee frames: a top-fn call inside a live window whose argument is the loaned storage invalidates when the callee mutates that parameter - bounded interprocedural scan (depth 3, fail-closed past the bound and on unknown callees; read-only fns/packages stay accepted); - closure call sites: at define/rebind a local closure records the base paths its body mutates (own params excluded as fresh bindings, transitively via calls and nested closures); a call inside a live window whose recorded roots reach the loaned storage is the event. Out of scope (unchanged, documented on the issue): Stmt::Defer windows (limitlessprojects#98), plain-struct mutating methods (A2's boundary-handle scope). - hy-check/tests/a3_interproc.rs: 5 reject forms (helper insert/delete, alias, closure, two-frame) + 4 accept/precision forms (after-window, read-only helper, closure called after, shadowing closure param) - tests/checker-corpus/29-a3-interproc-window.hy: EXPECTED-VERDICT REJECT A3 (VERDICTS 28 -> 29, README, parse_all counts) Verified: cargo test --workspace green (incl. 9 new tests), corpus harness 29/29, differential FULL AGREEMENT (accept 10/10, reject 19/19), hunt probes h1-h23 verdicts unchanged, python lanes baseline unchanged. Stacked on fix/capture-move-freeze (PR limitlessprojects#110) — retargets to main once limitlessprojects#110 merges. Fixes limitlessprojects#111
…limitlessprojects#123) T22 deep immutability had three blind spots after limitlessprojects#37's fix. Closure bodies sit outside Pass B (the A5 perimeter: flatten, uses, and calls never descend into an FnLit), so process_assign never saw a write inside a closure; const_write_root only tested the place's own root binding, so a copy of a const (`alias := cells`, `let copy = cells`) resolved to the mutable copy; and delete() never routed through E3006 at all. Every form was ACCEPT with 0 diagnostics on main — including the dual-closure race: both writers live in FnLit bodies, nothing crosses the spawn site, the emitter lowers the const to `var cells = []int{0}`, and `go run -race` reported WARNING: DATA RACE while `hyb check` said OK (issue limitlessprojects#123). Three additions: * check_fn now scans every FnLit reachable from the function body at fn end, with the completed env: assign-target roots and delete() first args that resolve to an immutable binding emit HYB-E3006 at the write span. Each closure level collects only its own params/body-locals as the shadow set, so a shadowing fresh binding stays writable. * const_write_root follows alias parents and may-share peers to the immutable root, so writes through copies name the const. * process_call routes `delete(root, ...)` through the same root check — a delete mutates the collection exactly like an index write. Verification: hy-check const_write 20/20 (10 new accept/reject pins), workspace 317/0, corpus 30/30 (row 30-const-closure-alias-write-reject, REJECT IMMUT), differential FULL (accept 10/10, reject 20/20); probe set c1-c9/r3 all reject, the shadowing accepts from limitlessprojects#37 stay accepted.
Formal payout request — cycle 2026-10 (cycle 1 of the $500 ladder)Hi! This is the formal request for cycle registration and payment, per BOUNTY.md. Everything below is merged, CI-green, and reproducible. Soundness bugs reported, reproduced, and merged (4/4):
Why these qualify under the ladder contract:
Request:
No rush on my side — whenever the cycle closes. Thank you for running this program; the Wildcard submission (#193) is my next piece of work while waiting. |
|
Update — 2 more fixes ready before this cycle closes Adding two more merged-bound soundness fixes to the same cycle registration request:
Both are CI-green, corpus 44/44, differential reject-side 31/31. This brings the cycle's soundness landings to 6. Happy to fold them into the same payout request — just say the word and I will re-post the consolidated table. Separately: could a maintainer/owner apply the |
Fixes #123.
The gap
T22 deep immutability (paid #37's fix) fires
HYB-E3006on direct writes — but three paths never reach it:deleteinside a closure bodyalias := cells,let copy = cells)delete(const_map, k)anywhereRoot causes:
walk::flatten/own_uses/own_callsall skipExpr::FnLitbodies (the A5 perimeter), soprocess_assign— the only E3006 site — never runs inside closures (method/fn bodies are walked: thec4/r2controls reject).const_write_rootonly tested the place's own root binding (lookup_binding(..).filter(immutable)), never the alias/share parent behind a copy.delete()routes throughcheck_delete_conflict(A3) only — no const check anywhere.Race proof (issue #123, same shape as #37's evidence): a program whose both writers are closure-bodied (
task worker()+ main, each writingcells[0]inside its ownFnLit) —hyb check:OK (0 diagnostics); emittedvar cells = []int{0}undergo run -race:WARNING: DATA RACE / Found 1 data race(s), exit 66. Nothing crosses the spawn site — A1c/A5 never see it.The fix
check_fnscans everyFnLitreachable from the body (newown_fnlits/expr_fnlitswalkers in walk.rs) at fn end with the completed env: assign-target roots anddelete()first args resolving to an immutable binding emitHYB-E3006at the write span. Each closure level carries only its own params/body-locals as the shadow set, so a shadowing fresh binding/param stays writable.const_write_rootfollows alias parents and may-share peers to the immutable root — the diagnostic names the const, not the copy.process_callroutesdelete(root, …)through the same root check (T22: a delete mutates the collection like an index write).The diagnostic emitter is factored into
emit_const_write_errorso both sites emit byte-identical messages/notes (the corpus first-error rule is unchanged).Verification
hy-checktests: 317/0 workspace-wide;const_write20/20 (10 new: 6 reject pins — closure write/rebind/fn-local capture/copy/let-copy/delete — + 4 accept guards — closure-local shadow, closure-param shadow, plain-local writes, plus the untouched [soundness] File-levelconstslice is mutable and reachable from every task: index writes accepted, races with 0 diagnostics #37 accepts)30-const-closure-alias-write-reject(EXPECTED-VERDICT: REJECT IMMUT), VERDICTS.md row 30,parse_allcount 30, README 30/30c1–c9+r3all reject;corpus27keeps exactly its 2 errors (no duplicate emission); shadow-accept controls passStacked
Targets
main; the diff carries the two predecessor commits until they merge (same as #117):Report first as #123 (no open issue covered E3006/closure/const since #37), then fixed here — report + fix in one flow, same as #109/#111.