Skip to content

Feature: threads-complete #575

Open
Open

Description

@amritk

Tracking issue for the /feature run that takes nish/threads to completion. This issue is the run's ledger; each slice's PR links here.

Run

  • repo: amritk/nish
  • base: main @ 35f8b62
  • plan: branch ccr-23558892-oof1r5 → .claude/plans/threads-complete.plan.md
  • requirements: "let's take multi threading to completion, ensure it's the easiest to use/understand but still the most powerful/performant of all the languages"
  • flags: max-slices 4, max-rounds 10, deadline 48h, merge on
  • verification: test npm test (undegraded) · coverage: none configured; gate is the repo's definition of done · lint npm run lint && npm run lint:dead · types npm run check · src/ stages also WASI_SYSROOT=/usr node tests/run.js wasi and node tests/run.js performance · bench node bench/run.mjs --only par (the plan's node bench/run.mjs par is wrong)
  • bounds: deadline 2026-10-13T17:52Z · no-branch 45m · no-commit 90m · re-runs 1 per check per head · check-ins 15→30→60m on no drift
  • acceptance: no browser surface; the acceptance session runs the criteria as commands, natively and under Node

Acceptance

# criterion static qa state
1 parallelMap(xs, (x) => x * k) with a const local k compiles, prints the same natively and under Node, and is ≥3x the sequential loop on 4 cores — — pending
2 bench/par-regions (2^17-element map ×1,000) is faster on merged main than on base main beyond the noise band, and faster than its sequential loop — — pending
3 NISH_NUM_THREADS=1 and taskset -c 0 each run a parallel program on one thread with unchanged output — — pending
4 s.spawn(() => work(a)) with void entries compiles and matches Node — — pending
5 #564's repro is refused, #567's repro is accepted, and a task filling its own new Array<i32>(n) compiles — — pending
6 A per-value AtomicI32 histogram over 4 tasks prints Node's checksum and beats the per-value-lock version — — pending
7 A task returning a string, a parallelMap producing string[], and a Channel<string> pipeline each match Node; a class-valued result is still refused — — pending
8 A guard passed to a helper that stores through it compiles and runs — — pending
9 Each of the ten most common thread refusals ends with code to write instead — — pending
10 docs/BENCHMARKS.md compares Nish, Rust and Go on ≥4 parallel kernels; Nish within 1.10x of the fastest on each, or the table says plainly where it loses — — pending
11 The guide's concurrency page has the which-tool table, and every example on it is compiled by npm test — — pending
12 npm run check, undegraded npm test, and the wasi and performance sections are green on main after the last merge — — pending

Slices

stage owns model depends on session branch PR CI rounds state
pool runtime/runtime-parallel.c, runtime/nish.h, tests/runtime-test.c, tests/run.js, bench/par-regions.ts, bench/run.mjs, docs/BENCHMARKS.md, docs/wp20-threads.md (+ knip.json, regenerated tests/self/goldens/checked.txt) lead — session_01T1S9vcWYb2sNierTWAQ3Ce feature/threads-complete/pool @ c9f58fe #586 → a19d5d6 green 2/10 merged
soundness src/{parallel,attributes,escape,codes}.ts, tests/cases/**, tests/self/goldens/**, tests/differential/**, docs/LANGUAGE.md, docs/IR_COOKBOOK.md (+ tests/link/thread_{task_private,channel_ctor_reads}*, docs/guide/using-nish/threads.md, tests/run.js) lead — session_018kZgaqhSBzj2JbshfNiKe6 feature/threads-complete/soundness @ 7c29167 #590 green 3/10 fixing
perf-ir src/{emit-parallel,runtime,parallel}.ts, runtime/runtime-parallel.c, runtime/nish.h, tests/**, docs/{LANGUAGE,IR_COOKBOOK,BENCHMARKS,wp29-thread-surface}.md lead pool, soundness — feature/threads-complete/perf-ir — — 0/10 pending
data-forms std/threads.ts, src/**, tests/**, examples/**, docs/{LANGUAGE,IR_COOKBOOK,wp29-thread-surface}.md lead perf-ir — feature/threads-complete/data-forms — — 0/10 pending
task-forms std/{threads,random}.ts, std/README.md, src/**, tests/**, examples/**, docs as above lead data-forms — feature/threads-complete/task-forms — — 0/10 pending
atomics std/threads.ts, src/**, runtime/**, tests/**, examples/**, docs as above lead task-forms — feature/threads-complete/atomics — — 0/10 pending
copy-at-join std/threads.ts, src/**, runtime/**, tests/**, docs as above lead atomics — feature/threads-complete/copy-at-join — — 0/10 pending
guards-diagnostics src/**, tests/**, docs as above lead copy-at-join — feature/threads-complete/guards-diagnostics — — 0/10 pending
bench-compare bench/par-*.{rs,go,ts,c}, bench/run.mjs, bench/README.md, docs/BENCHMARKS.md lead perf-ir — feature/threads-complete/bench-compare — — 0/10 pending
guide docs/guide/language/concurrency.md, scalar.config.json, docs/AI.md, README.md, docs/wp29-thread-surface.md, docs/MASTER_PLAN.md lead all, #570 foundation — feature/threads-complete/guide — — 0/10 pending
acceptance — sonnet all — — — — — pending

Review ledgers

#586 (pool) — merged

  • round 1 @ 0cf46aa: 4 of 16 confirmed → fixed in c9f58fe. round 2 @ c9f58fe: 0 of 2 → converged.
  • rejected: weakened on_caller/arena asserts; RSS bound; no spawnSync timeout; more-tasks-than-workers cap and stale pool_finished; fork inside a body; pthread_atfork re-registration; fixed-size cpu_set_t; 512-byte /proc/self/cgroup; wp20 vs BENCHMARKS figures; BENCHMARKS.md regenerated on a new machine; par-short 0.86x; wp20 §8c.1; Cpus_allowed_list vs online count; cgroup rows skipped without user namespaces.
  • lead decisions: knip.json unshare entry accepted (system binary, not a loosened rule); threaded ceiling 1,536 → 2,816 re-measured; squash message from 49c4819's trailers.

#590 (soundness)

  • round 1 @ 96909e8 — 16 findings verified (probes, mutants), 6 confirmed (test strength + doc wording) → fixed in 82aa39c.
  • round 2 @ 82aa39c — 3 verified with probes on head and main, 2 confirmed: soundness hole (nested-field write h.inner.x = 1 through a call or new in a scope region accepted; native 1, Node 0; call form pre-existing on main) and _this not isolated → fixed in 7c29167 (writesReach, 8 new negatives).
  • round 3 @ 7c29167 — 4 verified with probes, 2 confirmed: soundness hole (ctor stores a param's memory into this — this.inner = h.inner / this.items = h.items — then writes through it; this exempt as fresh; 4 shapes all native≠Node) and regression (a helper writing only its own new Grid() refused in a region since 7c29167; accepted at 82aa39c). Asked to share one fact: does a constructor keep an argument's memory in this.
  • rejected (never re-raised): arrow capture of a private array; private array as spawn argument; ctor keeping the array; nested array of arrays; store into a parameter's slot; foo(this.ch)/const c = this.ch/this.m()/arrow naming this; non-empty literal in a task; writesOutput panic-tail; arrays-only rule; super edge; dump_shared_write_callee golden move; ?? receiver negative (Map.get's probe is a write); reserve/getOrInsert wrapper (refused); lifted arrow writing through the enclosing function's parameter (arrows capture nothing).
  • lead decisions: accepted scope additions (tests/link/thread_task_private*, tests/link/thread_channel_ctor_reads*, guide threads page, tests/run.js after perf(runtime): a persistent FIFO worker pool, an affinity-aware core count and NISH_NUM_THREADS #586); pre-existing nested-write gap for calls fixed here rather than split out (fix(checker): a scope's region write check ignores constructor calls (new) #564's rule exposes it through new).
  • disputed: none · skipped: none

Log

Activity

  1. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    Log — #590 (soundness)


    Generated by Claude Code

  2. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    #590 review round 4 at 8a67993: rejected.

    A static adversary and a probe that built the head both reviewed it. The probe compiled every program and ran it natively and under Node.

    Round 3 verified closed (each shape refused NL2394, against 7/6 on the merge-base):

    • constructor this-alias via field, push, index, const local, this.touch(), and a helper return;
    • new Wrap(h.inner);
    • both using declarators;
    • the over-refusal (b) (thread_scope_region_fresh_object accepted, 7 15 both ways).

    Still open (blocking). These are all also accepted on the merge-base, but they are in #564/#567's scope:

    • Family 1: stores into a fresh object after its constructor has run. 10 programs are accepted and print native 7 / Node 6, or 1 / 0. followFresh ignores caller memory stored into a const w = new C() by:
      • a field (w.inner = h.inner);
      • an element;
      • push;
      • a nested field;
      • a method (w.bind(h));
      • a free function (attach(w, h));
      • a constructor field (attach(this.w, h)).
    • Family 2: this handed on through a local in a constructor. const me = this; attach(me, h) never sets thisAliased. 1 program.

    New over-refusals (accepted on base, refused at head):

    • pC: new Grid(4, arr) with the helper's own array;
    • p03e: a constructor that only pushes h.inner onto its own array.

    Not blocking: no docs/security record was touched.

    Every program text has been handed to the worker. Round 5 follows its push.


    Generated by Claude Code

  3. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    #590 review round 5 at afb5959: rejected.

    Two agents reviewed it: an adversary, and a probe that built the head. Every finding below was confirmed by a run in which the program was accepted and native and Node printed different output.

    Round 4 verified closed. All 61 round-4 probes keep or reach the expected verdict:

    • the 11 holes are refused;
    • pC and p03e are accepted.

    The probe also ran about 60 new programs against the new model. All the expected refusals hold for:

    • helper-made objects, return this, fresh objects nested in fresh objects, arrays;
    • let, loops and branches, recursion, generics, interfaces, for-of;
    • swaps, const aliases and callbacks.

    Still open:

    • Hole A: blocking, and a regression from c716dd9. Object-literal properties are never recorded. The code reads children[1], but an N_PROPERTY's value is children[0]. 9 programs fail, including const p: Pair = { inner: h.inner }; p.inner.x = 1.
    • Hole B: blocking. An own object's sub-path used as a value is not marked as escaped: u.mid = t.mid, [t.mid], push(t.mid), u.mid = ms[0], and a constructor's u.mid = this.mid. 6 programs fail. 2 of them were already holes at 8a67993; 3 are new since c716dd9; 1 combines holes A and B.
    • Over-refusal regressions (accepted at 8a67993, refused now): O3, a builder that writes its own fresh object and returns it; and 30g, c.reset(v) followed by an own write.

    Direction given to the worker:

    • Replace per-site handling with one total rule: any non-primitive value whose root is an own object or this, used in any value position, escapes that root.
    • Add negatives by enumerating (value position × value form), rather than sampling.
    • Merge main to clear the conflict (mergeable_state was dirty).

    Generated by Claude Code

  4. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    Check-in, 02:41Z: #590 (soundness)


    Generated by Claude Code

  5. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    #590 review round 6 at 758356d: no soundness holes; one blocking over-refusal regression.

    What round 6 checked:

    • Static adversary: found no blocking issue. Holes A and B are closed in the code. The fixpoint is monotone, the non-escaping positions (return, compared, followed argument) were each argued safe, and the unvisited positions were all checked.
    • Probe: re-ran all 156 earlier probes and 143 new r6 probes, with zero native/Node mismatches.
      • The 14 Hole A/B programs are refused.
      • O3 and 30g are accepted with the same output both ways, and so are O2 and 30j, which were not required.
      • Nothing got worse.
    • CI: green on 758356d and on cce98ee.

    Blocking regression: a function's own local Map with set is refused in a region.

    • tally makes new Map() and calls m.set("a", h.v). fix(checker): close the constructor hole in scope regions and admit a task's private writes #590 refuses it with NL2394; base ea34c4e accepts it, printing 1 1 both ways.
    • getOrInsert, a constant value and set alone are all refused the same way.
    • The cause is the "taken whole" receiver edge for mutating container builtins.
    • Handed back with the fix direction: model the mutation as a store into the receiver, and record a fused call's value argument as escaping into it.

    Non-blocking, folded into the same push:

    • the missing _this cells for assign and alias in the escape matrix;
    • dead declarations in the test templates;
    • an optional paren-skip tweak.

    The constructor-less-class finding is moot: its fields must be literals, so it can't be written (NL2030).

    Recorded for the guards-diagnostics stage (all pre-existing): builders, factories, fluent methods returning this, and an object returned from a user function are treated as unknown, so writes through them are refused. These matter for ease of use.


    Generated by Claude Code

  6. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    Check-in, 05:01Z: #590.

    • Head is still 758356d; CI was green there.
    • The round-6 Map-store fix has not been pushed. At 04:02Z the worker reported committing 76b1e10 locally with its gates running, then went idle. Its container probably restarted (worker epoch 3).
    • I've nudged the worker to verify the commit is still there, re-run the gates in the foreground, and push.
    • Next check-in: 05:52Z.

    Generated by Claude Code

  7. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    #590 review round 7 at 23d66e3: converged on soundness. Merge decided, with the over-refusals split out to #602.

    What round 7 checked

    • Static adversary: no soundness finding. Every read-back from a table and every for...of variable is a ROOT_UNKNOWN path, so a write through it counts against all parameters. That makes the table-store change and the for...of change safe.
    • Probe:
      • 307 regression programs and 147 new r7 programs ran, with 0 native/Node mismatches.
      • All 100 hole attempts against Map, Set and for...of were refused.
      • Every earlier verdict is held or improved. m1 and m2 (Map set and getOrInsert) are now accepted.
    • CI: green on all 19 checks at 23d66e3.

    Decision. The rule is sound, but it over-refuses common Map/Set/array code that main accepted, with the same output natively and under Node. Examples:

    • a histogram over m.keys();
    • a memo map handed to a recursive helper;
    • a sum over m.values();
    • an index map over a param array;
    • m.set("a", h.leaf) with no write after it;
    • a write through get on own fresh objects;
    • for (const l of ownArr) l.x = 1.

    Fixing these needs a design change: a read-back from an own container c becomes the path c[], collection builtins write shallowly, and scalar stores no longer pollute. That deserves its own focused review rounds. #590's scope (#564, #567) is complete and sound, and nothing is released until a human merges the Release PR. So:


    Generated by Claude Code

  8. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    perf-ir spawned (06:16Z).

    • Worker session: session_01MtD62mzX1QufrSJVswfpqP
    • Branch: feature/threads-complete/perf-ir
    • Scope: a futex-backed Mutex, REGION_COST retuned from the pool's measurement, and the NISH_NUM_THREADS docs.

    It started from main ahead of #590's merge, because the two stages touch different parts of src/parallel.ts. It will merge main and regenerate the goldens once #590 lands. Its verification line uses node bench/run.mjs --only par, correcting the plan's par.


    Generated by Claude Code

  9. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    #590 (soundness) merged, 07:58Z, as squash caaa57f. It closes #564 and #567.

    Merge gate

    Next

    • checker: scope regions refuse ordinary Map/Set/array code that writes only the function's own memory #602 (soundness-precision) is assigned to the soundness worker (session_018kZgaqhSBzj2JbshfNiKe6) on feature/threads-complete/soundness-precision. It needs negatives that fail when its rule is reverted, and positives for the over-refused programs.
    • perf-ir (session_01MtD62mzX1QufrSJVswfpqP) was told to merge main. Its futex mutex and the REGION_COST 2^22→2^20 retune are implemented, and its suite is running.
    • bench-compare becomes spawnable once perf-ir merges.
    Stage State
    pool merged (#586)
    soundness merged (#590)
    soundness-precision (#602) in progress
    perf-ir in progress
    data-forms, task-forms, atomics, copy-at-join, guards-diagnostics, bench-compare, guide pending

    Generated by Claude Code

  10. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    #602 progress (08:20Z)

    The soundness worker has implemented all four fixes on feature/threads-complete/soundness-precision, which it cut from caaa57f.

    The review probe corpus (456 programs, the runners and each round's verdicts) is now on the plan branch ccr-23558892-oof1r5, in .claude/plans/threads-complete-probes/, so workers can re-run it.


    Generated by Claude Code

  11. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    Wake 08:58Z: no stage PR is open yet.

    Both worker sessions report a seven-day rate-limit warning. The run continues; if limits bite, I'll prioritise finishing open stages over spawning new ones.


    Generated by Claude Code

  12. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    perf-ir: PR #607 opened (09:28Z), head 6480580. Worker's local runs (on f732f6c, which matches the head except for main's #605): npm test 17,521 passed and 0 failed; wasi 38/0; performance 203/0; selfhost 182/0; nish-cmp 0 undeclared. Its measured figures:

    • Grain: REGION_COST moves from 2^22 to 2^20. Loop vs map on the cheapest body at two chunks gives 1.19x (min) and 1.16x (median). The noise band is 1–6% on min.
    • Futex lock: 8 tasks on 4 cores.
      • CPU time falls 13% (min) and 31% (median).
      • Wall time is mixed: the best run is 10% slower and the median is 12% faster. The spread drops from 30–48% to 4%. The noise band is 2–3% on min and 14% on median.
    • Runtime size: runtime-parallel.c .text is 605 bytes (2,745 threaded), within its ceilings.
    • Out of scope, flagged by the worker:
      • docs/wp20-threads.md §8e is stale.
      • using g = j.bins[v & 7].lock() is refused with "Element access requires a numeric index". It may be a checker bug; the guards-diagnostics stage will triage it.

    Review round 1 started:

    • a static adversary focused on the three-state futex protocol (lost wakeups, memory ordering, the Darwin __ulock path, the wasm fallback), on the wall-time claim against its noise band, and on BENCHMARKS.md, which was regenerated whole on a different CPU;
    • a probe running contended-mutex stress tests at 8, 16 and 64 tasks, 200 or more runs each, with hang detection, plus timing against main.

    Generated by Claude Code

  13. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    #607 (perf-ir), round 1 at 6480580: rejected.

    Correctness is clean. The probe ran more than 10,900 contended-lock runs:

    • 8, 16 and 64 tasks, at 2, 4 and 8 threads;
    • four critical-section shapes, oversubscribed cores, and SIGSTOP/SIGCONT;
    • result: 0 hangs and 0 wrong totals.

    All thread_* and par_* programs pass, Node parity holds, and the static review traced the protocol (Drepper mutex 3) as sound.

    Blocking findings

    1. Grain threshold off by 2x. The code divides at the grain, which gives two chunks of about grain/2. That lands on the measured 0.98x row, not the claimed 1.19x one. The docs also misstate which body is cheapest; that body costs 3.

    2. "Median 12% faster" lies inside the 14% noise band.

    3. BENCHMARKS.md was regenerated wholesale on a different CPU. That rewrote unrelated recorded claims: fib, sieve, Map/Set, sizes.

    4. Lock timing regression against main, with a noise band of 1–4%:

      Benchmark Threads main PR
      contended 200 2 555 ms 2,616 ms (4.7x)
      contended 200 4 — +36%
      long critical section 2 454 ms 1,025 ms (2.3x)

      The cause: a 64-load spin puts waiters to sleep on most contended acquisitions. The PR makes 235k futex waits and 428k wakes where main makes 2.8k yields, and about 190k of those wakes find nobody asleep. The PR wins only on short critical sections at 4+ threads, where wall time is 8–11% lower.

    Direction to the worker: the lock must not be slower than main beyond the noise band at any measured thread count. Tune the spin, defer marking waiters until about to sleep, yield before sleeping, or count waiters. If it can't, drop the futex part and keep only the grain retune and the docs.

    The probe programs are on ccr-23558892-oof1r5 under .claude/plans/threads-complete-probes/perf-ir/.

    Non-blocking items (handed over too): syscall argument widths, a test that forces the sleep path, a tighter IR regex, and docs/AI.md:898 and wp20 §8e, which are stale.


    Generated by Claude Code

  14. amritk commented on Oct 12, 2026

    @amritk
    OwnerAuthor

    #602 is up as PR #608 (10:23Z), head 55c2d07.

    Worker's report

    • Probes: all 454 re-run against main. 0 newly refused; 29 newly accepted, each with native output equal to Node's; 0 mismatches. All 38 over-refusals listed in checker: scope regions refuse ordinary Map/Set/array code that writes only the function's own memory #602 are now accepted.
    • Mutation pins: reverting any one rule makes that rule's negatives start passing, so every negative tests something. The exception is readback_store_alias, which is pinned by two rules.
    • Tests: npm test 17,542 passed, 0 failed; selfhost 186/0, with the ratchet at 143.

    Review round 1 has started. A static adversary, plus a probe that rebuilds both #608 and main, re-runs all 454 programs, and attacks the new read-back rule: smuggling caller memory into an element, then writing through a read-back.

    perf-ir #607 status: the round-1 blockers on grain, claims and BENCHMARKS.md are fixed locally. The futex lock protocol is being replaced by one that keeps a sleeper count and only wakes when a sleeper is registered; the worker is tuning it against main. If no variant stays within main's noise band, the futex lock gets dropped.


    Generated by Claude Code

  15. 18 remaining items

  16. amritk commented on Oct 16, 2026

    @amritk
    OwnerAuthor

    2026-10-16T05:35Z: #644 round 2 @ 3975bce

    • Soundness converged. The fix holds against everything tried:
      • All 8 round-1 race programs are now refused.
      • 72 new attack programs are all refused. The routes: Map/Set values, nested chains, forwarding, constructors keeping params, this escaping, multi-declarator, ?:/??, id(), generics, guard escape, number arrays.
      • The 6 legitimate private patterns are accepted, with native = Node at 1, 2 and 8 threads.
    • CI is green.
    • Rejected: "a ctor-less class is refused". Compiled and run, it is accepted in both a parallel body and a task. "Channel send of a private object" is moot: channels carry scalars only (NL2475).
    • Handed back (test quality only):
      • reject_thread_task_private_object_array_own is refused by the older direct-store rule, not the new one;
      • a multi-declarator negative is missing;
      • two new tests have unused imports.
    • After that push, merge on green.
    • Over-refusals noted for the guards-diagnostics stage, all pre-existing on main:
      • h.poke() on an own holder whose field is fresh;
      • Map.set/Set.add on an own Map or Set in a body;
      • a task writing a scalar field of its own fresh argument.

    Generated by Claude Code

  17. amritk commented on Oct 16, 2026

    @amritk
    OwnerAuthor

    2026-10-16T06:10Z: #640 merged as ce97fa2 (fixes #618)

    Round 4 @ 9080c81 converged.

    • Probe: 548 rows.
      • All 22 private TLI programs from round 3 (abs, atan, labs, tanh, remainder, …) now match Node at -O3+LTO and -Oz+LTO.
      • 77 new r4 programs. Private strlen, memcmp, printf, pow, exp, sqrt, fmax, ldexp, cbrt, pause and others are equal to Node, natively and under wasi. Exported ones are refused with NL3037. Names like absolute, strlenOf and mySqrt are not over-refused.
      • Every available --dump-tli name on the 5 triples is listed.
      • The repo's c-symbols section: 35/0/0.
    • Adversary: low findings only. The check reads the list by regex rather than calling the compiler's function. The changelog said "installs" where CI only links the tools; I corrected it in the squash message. The list is LLVM 18's.
    • Filed c-symbols: the reserved list tracks LLVM 18's library-function table only #651: a newer LLVM's extra TLI names, and FreeBSD-only fls.
    • CI green on all 21 checks.
    • feat(checker): arrow tasks with captures, tasks without a destination, and a seeded Rng #644 is told to merge ce97fa2 and regenerate along with its round-2 test fixes.

    Generated by Claude Code

  18. amritk commented on Oct 16, 2026

    @amritk
    OwnerAuthor

    2026-10-16T07:07Z


    Generated by Claude Code

  19. amritk commented on Oct 16, 2026

    @amritk
    OwnerAuthor

    2026-10-16T09:15Z: #654 (atomics), round 1 @ 2d4aec9

    Probe: about 90 attack programs, 9 positives, edge semantics, wasi and timings. Adversary: static review.

    Refused correctly:

    • every ride-along shared write (methods, generic wrappers, holders, free functions, P1 bodies);
    • R1 bypasses: nullable receivers, destructuring, spread, index and JSON;
    • R7 routes: alias, helper, new, this, separate usings, nested scopes, ??, returns, Map, nested fields, for-of;
    • atomics as results.

    Positives match Node at 1, 2 and 8 threads. I32 overflow, signed min/max and compareExchange's return value also match. I64 atomics are align 8 on wasm.

    Handed back:

    • H1, miscompile: a root-package std/threads.ts declaring class AtomicI32 gets the real atomic lowering. isAtomicClass lacks the package check. The symptoms are wrong output, invalid IR, or NL0003.
    • H2, race, native ≠ Node: with using s = scope(), t = scope(), one scope's task can touch an atomic or Mutex that the other scope's task updates. It is pre-existing on main for Mutex, and is fixed here as the "every declarator" shape.
    • M1: an object literal can build an atomic. The general class case is filed as checker: an object literal typed as a class with methods is accepted, and Node throws on the method call #656.
    • M2: AtomicI64 under Node is documented three ways.
    • Lows:
      • a misleading message for a fresh, unreached atomic;
      • an "atomic nested in a generic" rule claimed but not implemented;
      • a wrong reason given for parallelReduce's split;
      • the histogram example has no test.

    Timings (probe machine, histogram, 32M values):

    Variant Median
    Local tally + one atomic add per bin 25 ms
    One thread 78 ms
    Atomic per value 480 ms
    Lock per value 1,757 ms

    The worker is making the local-tally pattern the example's default, and the docs lead with it.


    Generated by Claude Code

  20. amritk commented on Oct 16, 2026

    @amritk
    OwnerAuthor

    2026-10-16T10:20Z: #654 (atomics), round 2 @ de3168f

    Confirmed fixed:

    • H1: a root-package std/threads.ts now behaves as an ordinary class.
    • H2: atomics and Mutex shared across using s = scope(), t = scope() are refused.
    • M1: atomic object literals get NL2514 by every route (nested, array, argument, return, field, ?:, generic, Map).
    • A fresh, unreached atomic is admitted.
    • All positives and every histogram mode match Node at 1, 2 and 8 threads.

    Handed back:

    • H1: a destination slot shared by two sibling scopes' spawns gives native 10 and Node 200. It is pre-existing on main and is the same family as round 1's H2.
    • M1, regression from main: the sibling rule refuses disjoint locks or atomics by type. Mutex<A> against Mutex<B> is refused, and so are two different bindings of the same type.
    • M2: a Channel object literal is accepted and Node throws (pre-existing). NL2514 is to cover every nish/threads class; the general class case is checker: an object literal typed as a class with methods is accepted, and Node throws on the method call #656.
    • Docs: the sibling rule is missing from LANGUAGE.md, wp29 §4.5 A1 has the wrong Node wording, and the body's figures are stale.

    The PR also conflicts with main; a merge and regeneration were requested.

    Noted for guards-diagnostics: using g = m.lock(), s = scope() names the guard as the scope in its message.


    Generated by Claude Code

  21. amritk commented on Oct 16, 2026

    @amritk
    OwnerAuthor

    2026-10-16T12:50Z: #654 (atomics), round 3 @ 74e34de

    Soundness converged.

    • The probe ran 279 programs.
    • All 113 new attacks on the sibling-scope rule are refused. They cover aliases, ?:, helpers, holders, generics, arrays, Maps, capture vs argument, three declarators, constructors, higher-order helpers, nullable fields, and destination variants.
    • Every nested, contextual and nullable object literal of every exported nish/threads class gets NL2515.
    • Disjoint sibling scopes are accepted again, as they are on main, and match Node.
    • All positives and every histogram mode match Node at 1, 2 and 8 threads.

    CI: 28/28 green.

    Several r3 mutex and destination shapes diverge between native and Node on main today. This PR closes them.

    Codes, after the user's #642 took NL2512:

    • NL2513: parent touches an atomic in the region
    • NL2514: atomic member
    • NL2515: threads-class literal
    • NL2516: sibling scopes

    Handed back (docs and tests only):

    • stale benchmark figures in LANGUAGE.md, the guide and wp29;
    • a negatives count in the body;
    • pinning negatives for the nullable/contextual literal routes and for atomic sibling aliases.

    Merge on green after that push.


    Generated by Claude Code

  22. amritk commented on Oct 16, 2026

    @amritk
    OwnerAuthor

    2026-10-16T14:50Z: #654 (atomics) merged as ec8e597

    It went through 3 review rounds.

    • Round 1: found the miscompile of a root std/threads.ts and a race across two scopes in one using.
    • Round 2: found a shared destination slot, an over-refusal regression of disjoint locks, and a Channel literal.
    • Round 3: clean, with 279 programs and 113 attacks.

    Final head 77ec9a6:

    Acceptance #6 met. On 4 cores, 32M values:

    Approach Time
    Atomic add per value 677 ms
    Mutex per value 6,565 ms (9.7× slower)
    Local tally plus one atomic add per bin 24 ms (3.2× faster than one thread at 77 ms)

    Codes: NL2513 (parent touches an atomic), NL2514 (atomic member), NL2515 (threads-class literal), NL2516 (sibling scopes).

    Stage copy-at-join dispatched on feature/threads-complete/copy-at-join. It covers string and scalar-array results, Channel<string>, and strings through a guard, with use-after-free hunting under churn(), for acceptance #7.

    Remaining: guards-diagnostics, guide, then acceptance.


    Generated by Claude Code

  23. amritk commented on Oct 16, 2026

    @amritk
    OwnerAuthor

    2026-10-16T18:20Z: #669 (copy-at-join), round 1 @ ffd99a8

    Held: no observable use-after-free, no native≠Node difference, no crash, no invalid free. This was tested under churn plus valgrind at 1, 2 and 8 threads, across every escape route and on panic paths. Every exclusion is refused, and acceptance #7's three positives match Node.

    Handed back:

    • S1: a string stored through a guard into a string | null slot gets no copy, leaving a dangling pointer in the Mutex. No read path exists yet; main refuses it.
    • P1: a Channel<string> send is quadratic in the sender's arena. 1M kept sends take 63.6 s against 0.48 s for i32.
    • M1: a task answering a string never releases its arena on the caller thread. 200 × 1 MB peaked at 2.81 GB, 5.3× slower than inline.
    • M2: a string-valued parallelMap body gets no per-element recycle. 1M × 2 KB temporaries peaked at 2.11 GB against 13.6 MB for i32, and NL9012 is suppressed.
    • Convention: the new runtime code goes in its own translation unit with its own ceiling, rather than raising the parallel ceilings (runtime-budgets.js / wp7-runtime.md).
    • Smaller items: chunk-layout sharing, stale comments, a string[][] negative, and docs for the Mutex-drop leak.

    Measured on the probe machine: string results are currently slower than sequential in every form. 1M strings take 694 ms parallel against 410 ms sequential. The worker's analysis puts the cause in the serial per-element copy-in. A follow-up, adopting worker blocks into the joiner's arena (O(blocks) copy-in), is planned after #669 merges.


    Generated by Claude Code

  24. amritk commented on Oct 16, 2026

    @amritk
    OwnerAuthor

    2026-10-16T22:45Z: copy-at-join merged (#669 → e4a4a02).

    • Round 1 at ffd99a8 found five problems. All were fixed in 48cd0fd and 94abfa2:

      • a guard field typed string | null skipped the copy;
      • Channel<string> send was quadratic: 63.6 s for 1M sends;
      • a task never released its arena: 2.81 GB;
      • a map with string answers never recycled per element: 2.1 GB;
      • the copy code raised existing runtime ceilings. It now lives in its own translation unit, runtime-cross.c, with its own ceilings.
    • Round 2 at 94abfa2 produced five adversary items, fixed in 51aa834:

      • scalar and void tasks now release their arena too;
      • string += through a guard was confirmed unreachable and is pinned;
      • negatives added for parallelFrom and parallelMapInto;
      • the NL9012 comment was corrected;
      • the runtime unit counts now say eight.

      The probe ran 122 programs at 1, 2 and 8 threads: all printed the same as Node, and valgrind was clean.

    • CI first failed on two things:

      • the std_random golden had not been regenerated;
      • three new link programs read by shard 1 were missing from LINK_READ_LATER.

      Both were fixed in 5888215 and da7338d, and every check passed.

    • Acceptance Ship the self-hosted compiler #7 (a task returning a string, a string[] map, a Channel<string> pipeline, and a class result still refused) is covered by tests/link/thread_task_string, par_map_string and thread_channel_string, plus the reject_* negatives. It is to be re-run at acceptance.

    • Deferred to a follow-up:

      • copy-in is serial: 1M short strings take 270 ms at 4 threads against 200 ms sequential;
      • releasing an arena costs page faults when glibc trims the heap: 200 tasks building 1 MB each take 2.10 s against 1.15 s sequential, and 1.17 s with trim off.

    Started:

    • guards-diagnostics: session_01UgnMogyQPbnd8zBBzQWPjh, branch feature/threads-complete/guards-diagnostics. It covers the plan's guard helpers, diagnostics and --fix, plus six over-refusals found in review:
      • h.poke() on a task's own holder;
      • Map.set/Set.add on a body's own Map or Set;
      • a task writing a scalar field of its own fresh argument;
      • the region message naming a lock guard as the scope;
      • the NL2352 false positive;
      • the NL2468 null store.
    • copy-adoption: the copy-at-join worker, branch feature/threads-complete/copy-adoption. It makes copy-in O(blocks) by adoption and fixes the trim cost.

    Remaining: guide, then acceptance against the 12 criteria, then close-out.


    Generated by Claude Code

  25. amritk commented on Oct 17, 2026

    @amritk
    OwnerAuthor

    2026-10-17T05:50Z: guards-diagnostics merged (#691 → 7587702).

    • Round 1 at 46cea06. The probe reproduced a use-after-free: an expression-bodied guard helper, (g) => g.value, returned guarded memory after the lock was released. Native ran out of memory where Node printed the string, and valgrind reported an invalid read. The fix judges an expression body as its return. The same gap was closed in the channel rules (NL2501).
      • Also fixed:
        • a --fix method hoist that produced an invalid identifier;
        • a let-capture fix that rewrote assignments;
        • suggested Mutex code missing <T>;
        • NL2537 not recognising a parenthesised null.
      • Suspected holes refuted by running them: a helper returning a fresh container that holds an argument (refused with NL2471), parameter reassignment (refused with NL2179), and the declarator and module-data shapes.
    • Round 2 at 3f36b6f. The B1 fix held across 40+ expression-body attacks on guards, channels, tasks, parallel bodies and R5. r.poke() and its sibling shapes are refused. One blocker: --fix declared kNow twice when two statements captured the same let. Fixed with numbered names (kNow, kNow2) in f5d808a and 483a07e.
    • Final check at 483a07e. The lead verified the three duplicate-name repros plus three siblings: they compile, and native matches Node at 1, 2 and 8 threads. CI is 19/19 green.
    • Acceptance Return a small Result in a register, and let one cross to a host #8 (a guard passed to a helper that stores through it compiles and runs): tests/link/thread_mutex_helper.
    • Acceptance Rename the language to AmritScript and the compiler to amritc #9 (the ten common refusals end with code to write): NL2463–NL2481, pinned in tests/wordings.

    Still open: copy-adoption #686, head f0e1cfa, worker session_01NaDqCvGo7iKjhV9cj15pqN. Round-1 review items 1–5 are fixed:

    • the idle malloc_trim cuts held memory to 52 MB, against 118–135 MB on 0.18.0;
    • every kept call is no longer tail-marked;
    • the fixture gaps are closed.

    Blocking: F1, a 64 KB transfer block per chunk however little the chunk keeps (3,000 small maps peak at 62 MB, against 4 MB on main). It needs a main merge after #691.

    Next: merge #686, then the guide, bench-compare (acceptance #10), acceptance, and close-out.


    Generated by Claude Code

  26. amritk commented on Oct 17, 2026

    @amritk
    OwnerAuthor

    Ledger: #697 and #686 merged.

    • docs(guide): which thread tool to reach for, and the finished thread surface #697 (157e021), docs(guide): the threads guide's which-tool table, examples compiled by npm test, the refusal-to-fix table, and AI.md's threads rules. The accuracy review's findings were fixed before the merge: the --fix overclaim, the mutex codes, and the declined list.
    • perf(runtime): a thread's arena adopts what a worker copied out, and keeps freed memory #686 (efbbd8c), perf(runtime): a thread's arena now adopts what a worker copied out, instead of copying each element.
      • Transfer blocks start at 4 KB and double up to 64 KB. A join holding 64 KB or more is linked into the caller's arena; below that, the elements are copied in.

      • glibc's trim and mmap thresholds are set when the pool starts. A parked worker hands its arena back after a second with no work.

      • Three review rounds plus two runtime probes. The probes found three problems, all fixed before the merge:

        • a small-map growth bug (53/125 MB peak, now 5 MB);
        • an idle-RSS hold (82 MB, now 2.3 MB);
        • stale docs and figures.
      • Figures on the merged head (main / copy of main / this change):

        Probe Timings (ms)
        1M-string parallelMap, 4 threads 79 / 88 / 41
        200 tasks each answering 1 MB, 4 threads 173 / 179 / 47

        At 1 thread the map is not faster than the sequential loop, and no claim is made there.

    Next: acceptance against the 12 criteria on merged main, then close-out.


    Generated by Claude Code

  27. amritk commented on Oct 17, 2026

    @amritk
    OwnerAuthor

    Acceptance on merged main efbbd8c (4 vCPU VM): 9 pass, 1 fail, 2 partial.

    # Criterion Verdict Evidence
    1 parallelMap, const local k, ≥3x sequential FAIL x * k, one call over 2^25: 1.4x. 40 calls over 2^22: 0.43x, slower than the loop. With a compute-heavy body: 3.6–3.9x.
    2 par-regions faster than base, and faster than sequential PASS main 75 ms, base 35f8b62 278 ms, main's own loop 623 ms. 3.7x and 8.3x, far outside noise.
    3 NISH_NUM_THREADS=1, taskset -c 0 PASS 24 programs gave the same output and exit code in all three modes.
    4 void arrow spawn PASS Matches Node.
    5 #564, #567, a task filling its own new Array PASS #564 is refused; #567 and the own-array task match Node.
    6 atomic histogram beats lock per value PASS 499 ms vs 2,066 ms, 4.1x.
    7 strings and Channel<string> match Node; classes refused PASS Class results refused with NL2348 and NL2475.
    8 guard helper PASS The helper chain matches Node. NL2466, 2467, 2468, 2536 and 2537 each refuse their bad case.
    9 ten refusals with the code to write PARTIAL All fire with the right code. About half end in prose or give no remedy: NL2345, 2388, 2465, 2506, 2503, 2513, 2516, 2481, 2511, and 2469–2479. NL2467 and NL2468 suggest a field name that doesn't exist.
    10 BENCHMARKS against Rust and Go PASS, with a caveat Present, with its method. The table predates the later merges. A re-run on main has every kernel within 1.10x of Rust.
    11 guide which-tool table, examples compiled PASS All 22 fences are compiled by npm test. The 12 ok examples also match Node.
    12 check, undegraded npm test, wasi, performance PASS npm test: 21,013 passed, 0 failed, 2 environmental skips (aarch64 crt1.o, and NISH_BOOTSTRAP unset). The bootstrap checks were run separately: nish-cmp has 0 undeclared differences, and fuzz --stage1 agrees on 200 of 200. wasi 57, performance 213.

    Root cause of #1, confirmed in the IR. A function that calls parallelMap and returns a scalar has no nish_arena_mark/nish_arena_release, while its loop twin does. So every call keeps its result: 674 MB maxrss against 34 MB. No warning is printed, and using arena() around the call is refused.

    Follow-up workers started:

    • feature/threads-complete/par-reclaim: reclaim that frame when the result doesn't escape, with negatives for each escape route and an RSS-bound fixture.
    • feature/threads-complete/refusal-remedies: end every thread refusal with code, and add one refusal table to the guide with a test.

    Generated by Claude Code

  28. amritk commented on Oct 17, 2026

    @amritk
    OwnerAuthor

    Ledger: #702 is merged (ef8502b). fix(checker): every thread refusal now ends with the code to write.

    • What changed. Each refusal for NL2345–NL2536 now ends with a remedy written in the program's own names: its types, bindings, channel element types and spawn arguments. No remedy contains a ... or a placeholder.
    • What did not change. What the checker accepts and what it refuses is the same as on main. Two first sentences were shortened (NL2465, NL2471).
    • Docs. The threads guide has a new table, "Refused, and what to write instead". Every cell in it is compiled.
    • Test. A new test compiles each refusal on the page plus 37 pinned wordings. It requires the last clause to hold non-placeholder code. Against main's messages, 19 of 25 fail it; on this branch, 0 of 37 fail.
    • Review. Two adversary rounds; round 1's 18 findings were all fixed.

    This addresses acceptance criterion #9.

    Still open: #703, par-reclaim, which addresses criterion #1.


    Generated by Claude Code

  29. amritk commented on Oct 17, 2026

    @amritk
    OwnerAuthor

    Ledger: #703 merged (5082385), perf(codegen): a frame that calls parallelMap, parallelFrom or parallelMapInto now releases its arena when the result does not escape. This includes the string blocks it adopted under #686.

    How it was checked

    • Every escape route has a negative test, read after churn(). The runtime probe ran all of them against Node at 1, 2 and 8 threads, and valgrind reported them clean.
    • using arena() now accepts number and string maps.
    • 10 reject cases pin each way a map's result could leave the block.
    • Three .ll goldens record which frames reclaim and which do not.

    Measured

    • 40 maps of 2^22 on 4 threads: 140 ms / 34 MB, against 493 ms / 658 MB before. That is 1.87x the sequential loop, where it was 0.56x.
    • On 1 thread the map now matches the loop, where it took 2.2x as long.

    Workaround: the WASI parity loop now runs Node with --no-turbo-fast-api-calls. Without it, a Node 22 garbage collector segfaults inside the fast fd_write call.

    Next: re-running acceptance criteria #1 and #9 on merged main, then the final close-out.


    Generated by Claude Code

  30. amritk commented on Oct 17, 2026

    @amritk
    OwnerAuthor

    Final acceptance. The run is closing.

    Criteria 2–8 and 10–12 were verified on efbbd8c. Criteria 1 and 9 were re-run on merged main at 5082385.

    # Criterion Verdict Evidence
    1 parallelMap with a const local k runs at least 3x the sequential loop on 4 cores NOT MET Medians over 9 interleaved rounds, each binary paired with a byte-identical copy (see below).
    2 par-regions beats the base 35f8b62, and beats sequential PASS main 75 ms, base 278 ms, loop 623 ms (3.7x and 8.3x)
    3 NISH_NUM_THREADS=1 and taskset -c 0 PASS 24 programs gave the same output in all three modes
    4 void arrow spawn PASS matches Node
    5 #564, #567, and a task that fills its own array PASS
    6 the atomic histogram beats a lock per value PASS 499 ms vs 2,066 ms (4.1x)
    7 strings and Channel<string>, with class results refused PASS
    8 guard helper, with bad cases refused PASS NL2466, 2467, 2468, 2536 and 2537
    9 ten refusals that tell you the code to write PASS, 12 of 13 Hand-written programs with their own names and types. 12 of 13 remedies use the program's names, and each pasted fix compiles and matches Node. NL2484 does not; see below.
    10 BENCHMARKS compares Rust and Go PASS, with a caveat The table predates the later merges. A single re-run kept every kernel within 1.10x.
    11 the guide's which-tool table, with compiled examples PASS
    12 check, an undegraded npm test, wasi and performance PASS 21,013 / 0 / 2 environmental skips; wasi 57; performance 213

    Criterion 1 in detail

    Body Before (efbbd8c) After (5082385)
    Memory-bound x * k, 40 × 2^22 elements 0.1–0.35x the loop, 674 MB 1.75–1.89x the loop, 34.5 MB (#703)
    Memory-bound x * k, one call over 2^25 elements — 1.27x
    Compute-heavy ~2.3x (unchanged) 2.1–2.3x; 2.7x with four times the work

    The machine has 4 real cores, and 4 independent sequential copies slow each other by only 1.1–1.3x, so the hardware would allow about 3x. The memory-bound map waits on memory, but the heavy body's gap points at overhead in the runtime's scheduling or chunking. That is not closed.

    An earlier 3.7x figure for the heavy body came from c1_heavy, whose checksum is always 0 (an XOR over a bijection), so it proved nothing. The re-run used an order-sensitive fold instead.

    Criterion 9, NL2484. Its remedy is fixed text, const sendAll = (ch: Channel<i32>): void => { ch.send(1) } (src/parallel.ts:4776). It ignores the program's channel type, so pasted into a Channel<string> program it does not compile. The test misses this because the guide's example happens to use Channel<i32>.

    Merged in this run: #607, #608, #609, #615, #616, #621, #640, #644, #645, #654, #669, #686, #691, #697, #702 and #703.

    Every worker session is archived and no PR subscriptions remain.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions