Working state — a note taken while the work happens, not a specification. The system as it is meant to be is in Architecture.

Cross-actor shared-state audit — ƿit runtime C

Worktree: /Users/johnalanbrook/Documents/work/cell/.claude/worktrees/agitated-dubinsky-a3501e HEAD: c13443301 (branch claude/module-export-link-review-9f8813, off dev) Method: pure code reading. No build, no seed, no tests. Nothing edited. Precedent that motivated the audit: the image_lock per-call scan, fixed 2026-08-04 (plans/archive/night-2026-08-04/seed-speed.md §“Image-pin lock fix”).

Classification used throughout:

  • (a) immutable after init — read-only, no contention possible
  • (b) mutable but cold path — control plane, actor start/stop, compile, load
  • (c) mutable on a hot path — per turn / per message / per call / per allocation

0. Executive answer to the three questions John actually asked

  1. Do actors contend on shared mach pools during execution? No. Post-fix the per-call gate is one memory_order_acquire load of MachImageView.pin_owner (pit_internal.h:461-466) with no slow path at all. Everything else an executing image touches is read-only: the mapped bytes, the section table cached in the view, the shape rows, the stone constants. I enumerated every shared-write candidate — PGO counters, PitCodeRuntimeMeta, cpool_key_hashes, memoized text hashes, refcounts, stats — and every one is unreachable from the image lane or structurally closed (§3.4). Zero shared writes. pin_owner does not even false-share with the control-plane refcounts. Pool granularity (per module / per executable / one serving many) changes only the mapping count and the pin/unpin traffic; it does not change the execution-time story at all.

    The contention that remains in the pool machinery is entirely control plane: 4 image_lock acquisitions per pool per actor start, each doing an O(n) linear scan, plus a full blake2b + STONE re-hash of the pool before the dedup that would have made it unnecessary (§3.2, §6.7).

  2. Is runtime-created text still interned/shared? No. John’s belief is correct and the code proves it. Pit_NewStringLen, pretext_end, ppretext_end, Pit_ToString, concat — all allocate in the actor’s own heap via pit_alloc_string(ctx,…). pit_key_from_string is documented and implemented as pure normalization with no allocation and no interning (source/pit_text_utf32.c:338-354). The ct_lock pool has exactly 7 live producers, all boot/registration/native-lane, listed in §2. The comment on the struct (“safe to reference from any actor”) is accurate but describes a much narrower population than its placement suggests.

  3. What else has the image_lock shape? Ranked list in §6. The two real ones are the global actor registry + single global run queue on every message send (actors_mutex then engine.lock), and the O(n) linear scan under image_lock still paid at every pin/unpin (a press pins ~149 images ⇒ quadratic control-plane work under one runtime-global mutex). Everything else is genuinely cold.


1. Complete inventory of runtime-global mutable state

1.1 struct PitRuntime — field by field (source/pit_internal.h:1265-1354)

fieldwhat it islockwritten whenobserversclass
malloc_limitsoft capnoneinitall(a)
identityprocess-unique cache cookie, never 0/UINT32_MAXnone (source is _Atomic pit_runtime_identity_next, runtime.c:54)once per Pit_NewRuntime* (runtime.c:1679)all; read per dense-record alloc in the native lane(a)
buddy (BuddyAllocator)the only source of GC heap blocks for every actorbuddy.lock — “protects all fields” (pit_internal.h:1253), taken inside buddy_alloc/buddy_free (runtime.c:1211,1313)per GC heap block: actor heap create (runtime.c:1987), heap free (:2164), nursery grow/shrink (pit_gc_copy.c:857,2386,2895,2913), copy-space alloc (:1650,1654), compact dest block (pit_gc_compact.c:1646,1657)every actor(b) — but the hottest (b). See §6.3
regions_installeddid this rt install the process region tablenoneinit/teardown(a)
dynamic_region/static_region/owns_* (nan32)fixed windowsnoneinitall(a)
opaque_lock + opaque_values/opaque_next/opaque_capacity/opaque_count/opaque_free (nan32 only)side table mapping a 32-bit handle → native void* for PITCLASS recordsopaque_lock (runtime-global)per Pit_SetOpaque; read per Pit_GetOpaque (pit_internal.h:1775-1788)every actor holding any class record(c) on nan32 only — see §6.5
ct_lock + ct_base/ct_free/ct_end/ct_pages/ct_hash/ct_array/ct_size/ct_count/ct_resize_thresholdthe runtime-wide immutable text pool + its interning indexct_locksee §2 — boot keys, cfunc names, native-lane literals, and the arena bump for shapes/PitFunction/PitCodeevery actor (read-only)(b) writes; (a) for reads, except the unlocked ct_pages walk in is_ct_ptr — §6.4
builtin_key[7]length,toJSON,source,flags,true,false,null resolved through the ct pool at init (runtime.c:194-212)none neededinit onlyall(a)
shape_count, shape_bytesaccounting for shape rows the Mach loader builds in constant memoryct_lock (runtime.c:998-1004)per pit_record_shape_new$inspect.snapshot()(b) — see §2.3
code_lockguards pgo_sessions
code_cache_count, code_cache_bytesgravestone. Nothing writes them; tests assert exactly zero (comment pit_internal.h:1316-1323). Verified: no writer in tree.code_locknever$inspect(a)
pgo_sessions, pgo_disabled_sites (atomic_uint), pgo_disabled_warned (atomic_bool)dev-only PGO sessions, keyed by semantic graph + compiler salt; NULL in every ordinary runtimecode_lock for the list; hot counters lock-freelist mutated per PGO session open/close (mach_vm.c:746-835,1180-1200)actors running instrumented code(b) list / (c) counters when PGO is on — §3.4
cfunc_lock + cfunc_cache, cfunc_cache_count, cfunc_cache_bytesruntime-wide cache of immortal PitFunction objects for C functions, keyed by (fnptr, name, arity, proto, magic)cfunc_lockper Pit_NewCFunction2per registered C function per actor start (runtime.c:2347-2399); linear list scan with strcmp under the lockevery actor receives the same PitValue(b) — §4.3
image_lock + pinned_mach_images, pinned_mach_image_count, pinned_mach_image_capacityexact ranges of provider-owned views pinned into this runtimeimage_lockpin/unpin, each an O(n) linear scan (registry.c:72-156)GC + diagnostics(b) — §6.2, still the biggest remaining lock
mach_image_placements, mach_image_released, mach_image_teardown_depthplacement list + deferred-release list for teardown orderingimage_lockplacement create/detach, actor-heap teardown(b)

1.2 Process/file-scope mutable state (non-vendored, non-test)

source/runtime.c

  • :53 PitClassID pit_class_id_allocnon-static global, plain ++ at :2222. No lock, no atomic. Written whenever a native module registers an opaque class (Pit_NewClassID). → §5.1, a real bug.
  • :54 static _Atomic uint32_t pit_runtime_identity_next — fine, atomic, once per runtime.
  • :73 static PitRegionTable pit_region_table — process-global. Install-once under an _Atomic int installed CAS latch (:136-157); removed at teardown. (a) after install. nan64 reads it only for bookkeeping/diagnostics (pit_region_find has zero non-test callers in tree). nan32 reads the derived window table on every pointer encode/decode.
  • :83 static PitNan32Windows pit_nan32_windows — derived mirror, written inside pit_regions_install. (a) after install, but read on every nan32 value encode/decode (:235-271). Read-only, so no contention.
  • :84 static _Atomic(PitRuntime *) pit_nan32_active_runtime — one runtime owns the nan32 opaque table. (a) after install.
  • :2509 static PitLogSink g_log_sink — process-once tee (web ring buffer).

source/scheduler.c

  • :43 static timer_node *timer_heap — global timer min-heap, all mutations under engine.lock. (b), per timer arm/fire.
  • :71/:72 lane driver token: _Atomic source + _Thread_local holder. Clean.
  • :75-90 static struct {...} engineTHE global: one lock, three condvars, q_head/q_tail[4], main_q_*, execution_lanes, shutting_down, worker threads. (c) — every enqueue (enqueue_actor_priority:2315-2345) and every dequeue takes engine.lock.
  • :93 static char *g_log_actor_id — malloc’d id of the logger actor. Read (system_log_active, is_log_actor) on every log and every traced turn; strdup/freed on logger register/teardown with no lock. → §5.2, a use-after-free shape.
  • :96 static int g_system_trace — control toggle, read per turn.
  • :99 _Thread_local PitContext *g_execution_ctx — TLS, per turn entry/exit. Correct.
  • :181/:182 boot-trace path + mirror flag — process-once.
  • :846-848 actors_mutex, actors (stb_ds string map), starting_actors — the actor registry. (c): scheduler_ref_actor(id)/get_actor(id) take this global mutex + do a string hash lookup on every send_message (:4192-4245).
  • :869 watchers_mutex — the deliberate process-global leaf lock for stop_watchers. Its own comment (:850-868) says a reply-carrying send registers a watch, “so this path runs constantly rather than once per $couple” ⇒ (c).
  • :3318 and :5029 static uint32_t global_timer_idtwo independent counters, each ++’d outside engine.lock. → §5.3.
  • :3217 static int already_exiting — atexit guard, harmless.

source/registry.c (whole file read)

  • :20-23 pit_static_extensions / pit_static_endowments (+counts) — install-once, header says “No thread safety: the static table is installed once during startup”. (a). Lookup is a linear strcmp scan (:331-339) — O(n_extensions) per use(), cold but O(n).
  • :35/:36 static atomic_flag pit_lease_lock + pit_actor_leases — process-global spin lock (while(atomic_flag_test_and_set), no pause/yield, :210-216) over a linked list walked with strcmp. (b), rare. Noted as a latent priority-inversion on an oversubscribed box, not a current problem.
  • :398 static PitTickDrainEntry *pit_tick_drain_headunsynchronized head-swap push (:400-408), never removed. → §5.4.

source/start_plan.c

  • :100-102 static PitPlanImage plan_images[1024] + plan_image_count, guarded by static sys_mutex_t plan_images_lock. Daemon-lifetime scratch-image registry, slot 0 = boot cart, never evicted (this is the PLAN_IMAGE_MAX=256-shaped gotcha already in memory). (b).

source/cart_boot.c

  • :79 static PitCartBoot g_cart_boot — mounted boot cartridge sections. Written once before any runtime exists. (a).

source/image_provider.c

  • :72-75 four static PitClassID (image_handle, image_pin, pool_binding, image_authorization) — lazily initialized through the unsynchronized Pit_NewClassID (:353-357). Inherits §5.1.

source/mach_pool.c

  • :1122/:1134/:1144 three tri-state env levers (PIT_MACH_NO_FUSE, …NO_GUARD_FUSE, …NO_TEXT_GUARD_FUSE), lazily resolved. Racy but idempotent (getenv is stable) ⇒ benign. Consulted at load, never in dispatch.
  • :2325 static char effective[128] in Pit_MachABIEffective — lazy, idempotent, benign.

source/mach_vm.c

  • :735 static atomic_uint g_pgo_snapshot_generation — atomic, per snapshot. Fine.

source/pit.c

  • :24 char g_shop_actor_id[65] (non-static), :28 static PitBootConfig g_boot_config, :29 PitRuntime *g_runtime (non-static; also extern’d by crash.c), :35 static int g_cli_exit_code, :427 static pit_trace_hook g_pit_trace_hook. All process-once at boot except g_cli_exit_code (written by the terminal actor). (a)/(b).
  • g_runtime lazy init at :147-149 is an unguarded check-then-set, but it runs before any second thread exists in every real path.

source/crash.c

  • :32 _Thread_local volatile PitContext *g_crash_ctx — TLS, per turn. Correct.
  • :36 static char crash_log_path[PATH_MAX] — process-once.

source/press_value.c

  • :39 static PitPressRep press_reps[] is non-const: rows’ value_bits are patched by press_reps_ready() (:63-77) behind a fn-local static int state. Lazy, idempotent, benign — but it is a writable table.

GC files — all thread-local, zero shared state. pit_gc_copy.c (gc_ys_*, gc_from2_*, gc_minor_*, gc_from_frame_*) and pit_gc_compact.c (gcm_*: phase, mark bitmap, worklist, delta window, seen set) are __thread. The attribution globals gc_scan_container* (pit_gc_copy.c:915-919, mirrored pit_gc_compact.c:485-489) are explicitly TLS with a comment saying so because actor workers run concurrent GCs. This is exactly right and is the single best-isolated subsystem in the runtime.

Files with no file-scope mutable state at all: pit_module_internal.c, pit_text_utf32.c, pit_text_kim.c, pit_ring.c, pgo_profile.c, main.c, press_stubs.c, qbe_stubs.c, press_rep_*.c, buddy_debug.c, wildmatch.c.

1.3 Per-actor state, for contrast (all correct)

PitContext owns: its heap (heap_base/heap_free/heap_end/frame_free), nursery, class_array (per-actor! runtime.c:2236,2256), letters, parks_head, signals_head, rec_key_next, mutex (turn), msg_mutex (mailbox), and the _Atomic handshake fields (state, refcount, turn_gen, pause_flag, debug_suspended, profile_*). Nothing in an actor’s value graph is reachable from another actor’s value graph.


2. The interned-text question — VERDICT: John is right

2.1 What still allocates into the ct pool

Total live producers of rt_ct_alloc / pit_rt_ct_alloc / rt_intern_text_to_value:

sitewhatwhenfrequency
runtime.c:207 (rt_init_builtin_keys)the 7 builtin property-name keysruntime init7 per process
runtime.c:2351 (pit_new_cfunc_internal)the name key of a C functionper C-function registrationbounded by #C functions in the binary
runtime.c:2365the immortal PitFunction object for that C functionsamesame
runtime.c:999 (pit_record_shape_new)one PitRecordShape row + its key rowsper native-lane record template primenative lane only
runtime.c:4234 (Pit_NewAtomString in the property-list installer)property name from a PitCFunctionListEntryper module registrationbounded
mach_vm.c:2187,2199a PitCode wrapperper image-code wrapbounded by units
qbe_helpers.c:438,481,753,2186native-lane literal keys, template keys, PitCodenative (QBE) lane only, cold-primed oncenative lane only

That is the entire list. greped across source/, platform/, packages/, internal/.

2.2 What explicitly does NOT intern (the important half)

  • Pit_NewStringLen (pit_text_utf32.c:588-639) → pit_alloc_string(ctx,…)actor heap, then pretext_end stones it in place. Never touches ct_*.
  • pretext_* (concat/append/text()) → actor heap.
  • ppretext_end (parser) → actor heap.
  • pit_key_from_string (pit_text_utf32.c:345-354) — the function every obj[k] and Pit_SetPropertyStr goes through: identity for a non-empty text, PIT_KEY_empty for an empty one, zero allocation, zero interning. Its own comment says so and the body agrees.
  • mach_pool.c:2114-2146 carries the gravestone comment for the thing John remembers: the generation-time probe “used to build one through pit_key_new, which permanently interned every string constant of every generated unit just to ask”. That interning is gone; the probe is now answered from the cpool entry type.

2.3 So what is shared, and what lock is on the read path

Shared, immortal, read-only after creation: the 7 builtin keys, C-function name keys, C-function PitFunction objects, native-lane shape rows and their key rows. All are objhdr_s (stone) and live in a runtime arena the GC refuses to move (is_ct_ptr gate at pit_gc_copy.c:1175,1692).

The read path has no lock at all, and it does not need one:

  • pit_key_hash (pit_text_utf32.c:215-241) — chase() + get_text_hash. For a stone text the hash is memoized in the object (text->hash), computed once at creation. For a ct-pool text it is written at intern time under ct_lock. No write on the read path.
  • pit_key_equal (runtime.c:523-552) — pointer compare, then type compare, then PitText_equal (a memcmp of packed words). Reads only the two texts.
  • pit_mach_record_get_hashed / rec_find_slot_hashed (runtime.c:580+) — probes the actor’s own record table; the keys it compares against are actor-heap texts or image/ct stone texts. Read-only either way.

Therefore the profile leaves pit_key_equal (886 samples) and pit_mach_record_get_hashed (529) from the post-fix sample in plans/archive/night-2026-08-04/seed-speed.md are honest interpreter work, not shared-state contention. Against Pit_CallRegisterVMFunction’s 14,509 they are ~6% and ~4% of the interpreter. Nothing to fix there.

2.4 shape_count / shape_bytes

Shape rows are what pit_record_shape_new (runtime.c:954-1020) builds: a PitMachPoolShapeRow (== PitRecordShape) plus contiguous 16-byte key rows, laid out byte-identically to what an image carries, so a record cannot tell a decoded shape from a mapped one — it holds a bare pointer and does two hops either way (pit_internal.h:332-339).

Facts established:

  • There is no shape table. Nothing looks a shape up; density comes from records sharing one descriptor that the code object holds. The comment (pit_internal.h:1307-1312) is accurate.
  • Shapes are shared across actors: they are immortal, in constant memory, and a record in any actor may point at one. They are write-once.
  • shape_count/shape_bytes move under ct_lock with the allocation, so they add zero lock acquisitions of their own.
  • Lock traffic: one ct_lock acquisition per shape created, and shapes are created only from qbe_helpers.c:759 (native record-template priming, once per template per runtime) plus the C tests. In the mach lane, shapes come mapped from the pool and cost nothing.

3. The mach pool / image sharing model

3.1 Object graph — where each thing lives

structuredefinedlives whereshared?mutable when
the mapping (pool bytes)sys_mem_map’d read-only file, or a resident windowshared by all actorsnever (RO)
MachImageViewpit_internal.h:363-389provider-owned runtime storage: embedded in a PitMachImagePlacement, or a caller’s own structsharedat open (fields), then only pin_owner
PitMachImagePlacementimage_provider.cruntime table rt->mach_image_placements (off GC heap)sharedunder image_lock: create, refcount, detach, deferred release
PoolBindingpit_internal.h:403-412inside a PitMachPoolBindingHandle record (actor-heap PITCLASS record, opaque ptr to malloc’d struct)per-actor (the handle is non-transferable)immutable for one image generation
PitPinnedMachImageRangepit_internal.h:414-419rt->pinned_mach_images[], pit_realloc_rt’d arraysharedpin/unpin under image_lock
PitRecordShape rowspit_internal.h:326-339inside the mapping (mapped units) or the ct arena (decoded units)sharedwrite-once

The load-bearing lifetime argument (verified against image_provider.c): a live PoolBinding always holds its own pin — provider_binding_for_placement acquires a pin before building the handle, and handle->active is never cleared for a binding class. So binding->image (== &placement->view, runtime-owned storage, not mapping storage) is safe to dereference wherever binding is, and provider_detach_unused_locked cannot free the placement while pin_refs != 0.

One more fact that keeps the whole model sane: placements are deduplicated. provider_adopt_mapping (image_provider.c:217-240) looks the candidate up by (placement_id, artifact_hash, profile_hash, abi_hash, mapping_owned) before creating one, and start_plan.c:290-294 names a placement by the byte range’s own address ("plan:%p:%llu"). So N actors starting the same pool get one placement, one view, and one PitPinnedMachImageRange with pin_count == N. The registry’s size scales with distinct pools, not with actors.

3.2 Lifecycle traffic under image_lock

source/registry.c is the whole of it and it is small:

  • pit_runtime_pin_mach_image (:72-126) — lock, linear scan of pinned_mach_images for this view; bump pin_count or append a new range (realloc doubling); pit_mach_image_publish_owner(image, rt) (release store of rt into view->pin_owner); unlock. O(n) per pin.
  • pit_runtime_unpin_mach_image (:128-156) — lock, linear scan, decrement; at zero, publish owner NULL and swap-remove the range; unlock. O(n) per unpin.
  • pit_runtime_mach_image_pin_count (:158-171) — still locked, still a scan, but now only cold callers (C regressions, dbg_code_from_function at runtime.c:10021).
  • pit_runtime_contains_pinned_mach_pointer (:181-195) — locked scan. Reached only through pit_ptr_is_live_value (pit_internal.h:2993-2996), whose only non-HEAP_CHECK caller is runtime.c:10163 (an inspect/aliasing answer). Not hot in release. Good — but note its header comment (pit_internal.h:433-436) says it “is used only by GC and diagnostics”. The GC does not call it. The comment overclaims; correct it.
  • pit_runtime_dispose_mach_image_registry (:197-208) — teardown, asserts balance.

Frequency, counted exactly. Per pool window at actor start (start_plan.c:286-301image_provider.c:945-1012): 4 image_lock acquisitionsprovider_adopt_mapping (:235, holding the lock across a linear scan of mach_image_placements doing a strcmp + three 32-byte memcmps per entry), pit_runtime_pin_mach_image (registry.c:79, linear scan of pinned_mach_images, possibly pit_realloc_rt), provider_acquire_pin’s pin_refs++ (:265), and provider_release_open (:278).

Per binding at actor stop: pit_runtime_images_teardown_begin (1, :190-195, from Pit_FreeContext at runtime.c:2085), then per binding finalizer pit_runtime_unpin_mach_image + pin_refs--/provider_detach_unused_locked (2 in the common case, 3 when this is the last actor), then pit_runtime_images_teardown_end (1, :197-208, at runtime.c:2173).

So lock traffic is O(actors × windows) even though the tables are O(pools). Loose per-module realization makes both numbers large at once: 149 images, each pin scanning up to 149 entries, all through one runtime-global mutex. It did not show in the post-fix profile because the per-call amplification is gone — but it is the same lock and the same scan, one order of magnitude down.

And the lock is not even the expensive part of actor start. provider_adopt_bytes runs crypto_blake2b over the entire window (image_provider.c:878) and then Pit_MachPoolOpen (:889-891) re-hashes every STONE text via mach_pool_stone_text_section_valid (mach_pool.c:684) — and both happen before the dedup lookup. Every actor starting an already-mapped pool therefore redoes O(pool bytes) of hashing that the existing placement already proved. The file path (provider_open, :484) additionally mmaps and munmaps per call around the dedup. See §6.7.

3.3 Execution path, post-fix — what is READ

Per call into a PIT_FUNC_KIND_IMAGE function, the shared memory read is:

  1. binding->runtime == ctx->rt — a plain load from the actor’s own malloc’d binding.
  2. pit_mach_image_pinned_by(binding->image, ctx->rt) (pit_internal.h:461-466) — one atomic_load_explicit(memory_order_acquire) of view->pin_owner, compared to rt. (mach_vm.c:437-445.) There is no slow path at allmach_binding_is_pinned is three pointer-word compares plus one acquire load, and on failure mach_exec_from_function (:447-460) answers an invalid cursor. Verified: no conditional fallback to pit_runtime_mach_image_pin_count exists. The only writers of pin_owner are registry.c:92 (existing entry, republish — idempotent), :123 (new entry), :145 (pin_count hit 0, publish NULL), all release stores under image_lock. Release/acquire throughout; no relaxed anywhere in the pair.
  3. The MachImageView fields it needs — sections[], value_row_count (cached at open precisely so mach_exec_const_count, “the hottest constant read”, doesn’t re-derive it with a 64-bit division per dispatch, pit_internal.h:372-375).
  4. The mapping bytes themselves — instructions, constants, shape rows. Read-only pages.

All four are read-only at execution time. Multiple actors executing the same pool share clean, read-only cache lines, which is the ideal case: no coherence traffic beyond the initial fill.

Two caveats on the pin_owner mirror, both stated honestly in its own comment and both verified:

  • Single-owner — and it is only sound because placements are per-runtime. pin_owner holds one runtime. The soundness argument is that PitMachImagePlacements hang off rt->mach_image_placements, so a view can only ever be pinned by the runtime that owns its placement. Many PitContexts share one PitRuntime (Pit_NewContextRawWithHeapSize(PitRuntime *rt, …), runtime.c:1909/1929), but two runtimes never share a view. If a placement were ever hoisted process-global — a plausible future optimization for the “one pool serving many executables” granularity — the mirror becomes last-writer-wins and mach_binding_is_pinned starts refusing live images. This deserves an explicit guard comment on mach_image_placements, because the hoist is exactly the change someone would make for the wrong reason.
  • Drain race. A pin can drop to zero the instant after the load. Equally true of the locked version. What actually keeps a running image alive is ownership: the frame traces its binding handle, and the handle holds a pin for its whole life.

3.4 Execution path — shared WRITES: on the image lane, ZERO

I looked for every candidate and every one is either unreachable from an image or structurally closed. This is the strongest result in the audit.

candidateverdict
PGO counters (pgo_profile.c:94-100, saturating CAS into a shared table)Unreachable from an image. mach_exec_pgo returns NULL unconditionally for MACH_EXEC_IMAGE (mach_vm.c:610-612); mach_pgo_observe returns immediately on !session (:876-877); in MACH_PGO_LOADF_* the descriptor is NULL so both the PIC and COLLECT arms are skipped (:4661-4691). Register lane only.
PitCodeRuntimeMeta + mach_runtime_meta_ensure (mach_vm.c:293-304, a lazy unsynchronized pit_mallocz_rt write)Hangs off PitCodeRegister. mach_exec_shape/mach_exec_shape_count take the IMAGE branch before dereferencing runtime_meta. Never touched on the image lane.
cpool_key_hashes (mach_pool.c:2117-2126)Register lane, at load time, not execution. mach_exec_key_hash (mach_vm.c:599-603) does not consult it for images — it calls pit_key_hash on the stone object.
PitText.hash memoizationget_text_hash writes text->hash when it reads 0 (pit_text_utf32.c:12-23)The dangerous one, and it is correctly closed — verified by code, not by comment. Pit_MachPoolOpenmach_pool_stone_text_section_valid (mach_pool.c:345-357) walks every STONE object at open with verify_hash=1, recomputes the hash and forces it nonzero (mach_pool.c:340: if (!actual_hash) actual_hash = 1;) before comparing. A pool that opens therefore cannot contain a zero-hash stone text, so get_text_hash always short-circuits on the cached value and never attempts a store into the PROT_READ mapping. The claim at mach_vm.c:592-597 holds.
refcounts / pin_count / open_refs / pin_refsShared, all under image_lock. Control plane only. Never touched per call.
statistics countersThe dispatch loop’s only atomics are relaxed loads of ctx->profile_enabled and ctx->pause_flag — both per-context (mach_vm.c:3097,3134,3556,4090,…). No stores.

The one shared write near — but not on — the execution path is buddy_alloc / buddy_free at GC and heap-growth boundaries (§6.3). That is an allocator event, not a per-call one.

Cache-line detail that makes the fix worth what it bought: pin_owner sits at offset 40 in the view — the same line as placement_base, predecessor_abi and value_row_count, i.e. the other hot reads. open_refs/pin_refs, the fields mutated under image_lock, land past sections[11] (~545 bytes in). No false sharing between the hot read and the control-plane refcounts.

3.5 PitCodeRegister / PitCode — a pool creates neither

Pit_NewImageFunction (mach_vm.c:2264-2306) allocates only a PitFunction, with pit_mallocz_kind(ctx, …, PIT_ALLOC_FUNCTION) — the actor’s own GC heap. No PitCode, no PitCodeRegister, no pit_rt_ct_alloc. The function is a (binding, row index) pair plus two actor-heap links. Its header comment is exactly true.

The contrast, worth writing down because it is a loaded gun pointed at the future:

/* source/mach_vm.c:2182-2194 — the REGISTER arm of MACH_CLOSURE */
static PitValue pit_new_register_code(PitContext *ctx, PitCodeRegister *code) {
  jc = pit_rt_ct_alloc(ctx->rt, sizeof(PitCode), 8);   /* GLOBAL ct_lock, per closure */

Every register-lane closure creation would take the runtime-global ct_lock and bump-allocate permanently into an arena that is never freed. Today this is unreachable: grep finds no allocation of a PitCodeRegister anywhere in tree (the free helper at mach_pool.c:3322 is a bare comment over deleted code), which is the same fact the code_cache_count gravestone asserts (pit_internal.h:1316-1323, tests/runtime_arena_counters.cm asserts exactly zero). If the register lane ever comes back, it brings a global lock and a permanent leak per closure with it. Say so in the gravestone comment.

pit_new_native_code (mach_vm.c:2199) has the same shape for the QBE lane, but is memoized per-actor behind st->code_cache (qbe_helpers.c:2176-2181,2210-2214), so it is once per (dl_handle, fn_idx) per actor. Native lane only.

Dead field, overclaiming comment: PitCodeRegister.code_obj (pit_internal.h:1427) is commented /* shared OBJ_CODE wrapper for register functions */. There is no read and no write of it anywhere in C; the design it describes (one ct-arena OBJ_CODE shared by every actor, plans/archive/roadmap.md:1040) was abandoned. The comment advertises a sharing that does not exist. Delete the field or the comment.

3.6 The three pool granularities

Confirmed as three, and they differ only in mapping count and pin traffic:

  • one pool per module — the loose desktop realize lane. registry.c:38-41 names it explicitly: “loose desktop realization can map one image per unit and therefore has no useful architectural image-count cap”, which is why pinned_mach_images is dynamically sized rather than a fixed array. This is the granularity that makes the O(n) pin scan hurt: 149 images ⇒ 149-entry scans.
  • one pool per executable — the original conception; the press/pack output.
  • one pool serving many executables — the boot cart / resident window (pit_private_image_provider_adopt_resident, pit_internal.h:471-479), and the fused/resident press image.

Sharing story is identical in all three. Execution reads read-only memory in every case. What changes is (a) how many placements/ranges exist, hence how long the image_lock scans are, and (b) how many pin/unpin operations the control plane performs. Coarser pools are strictly better for both. There is no case in which two actors write the same pool memory.


4. Cross-actor channels

4.1 By design — correct, keep

channelmechanismverdict
letters / mailboxtarget->letters under target->msg_mutex, blob payloads copied (send_message, scheduler.c:4192-4245)by design. Wota-encoded copy, no pointer sharing.
run queuesengine.q_head/q_tail[4], main_q_*, per-lane queues under engine.lockby design, but a single global lock — §6.1
actor registryactors / starting_actors under actors_mutexby design, but on the send path — §6.1
stop watchersstop_watchers under the watchers_mutex leaf lockby design; the leaf-lock discipline (scheduler.c:850-868) is exactly right and fixed a real AB-BA deadlock
timerstimer_heap under engine.lockby design
parks / signals / call gatesper-actor lists, _Atomic state CAS under the owning actor’s msg_mutexby design, correct
pit_ring SPSC ringsper-endpoint _Atomic read/write positions (pit_ring.h:69-72)by design, lock-free SPSC, correct
$inspect / scheduler_snapshotone actors_mutex walk taking a refcount on each actor (scheduler.c:2936-2957)by design, correct — one lock, one walk, no lock ordering

4.2 Acceptable — counters and cookies

rt->identity, pit_runtime_identity_next, g_pgo_snapshot_generation, pgo_disabled_sites, next_execution_lane_driver_token, buddy accounting fields, shape_count/shape_bytes, cfunc_cache_*. None reachable from pit code; all either atomic or under the lock that guards the thing they describe.

4.3 The cfunc_lock cache — actors DO receive shared PitValues. Is it safe?

Yes, and here is why, with the caveat.

pit_new_cfunc_internal (runtime.c:2347-2399) hands the same PitValue to every actor that registers the same (fnptr, name, arity, proto, magic). The pointed-at PitFunction is:

  • allocated in the ct arena (pit_rt_ct_alloc) — off every actor’s GC heap, never moved (the collector’s is_ct_ptr gate refuses to copy it);
  • created with objhdr_make(…, /*stone=*/true) — so the language cannot mutate it;
  • written exactly once, before publication, under cfunc_lock;
  • kind PIT_FUNC_KIND_C: it holds a raw C function pointer, an arity, a magic, and a name key that is itself an immortal ct-pool text. It holds no actor-heap pointer, so it cannot be a bridge between two actor heaps.

So it is a shared immutable — the same category as image constants, and safe for the same reason. It is not a channel: nothing an actor does can change what another actor reads out of it.

Caveat (perf, not safety): the cache is a singly-linked list scanned with strcmp under a runtime-global mutex on every Pit_NewCFunction2, and every actor re-registers every C function of every module it uses at start. With M actors and F C functions that is M×F acquisitions of cfunc_lock, each doing an O(F) strcmp walk. For the fleet (77 actors) this is measurable actor-start cost. See §6.6.

4.4 The nan32 opaque_lock table — a real channel, and a real hot path on nan32

rt->opaque_values is a runtime-global side table mapping a 32-bit handle to a native void*, because nan32 values cannot carry a host pointer. rec_opaque()/rec_set_opaque() (pit_internal.h:1775-1788) call pit_opaque_handle_get/_set (runtime.c:274-283, 312+), each of which takes rt->opaque_lock.

Two things follow:

  • Performance: on nan32, every Pit_GetOpaque on a class record (every file read, socket op, child-process op, every provider handle deref) takes a runtime-global mutex. This is the image_lock shape, on the nan32 arm, undiscovered because nan32 has never been run under a parallel fleet.
  • Isolation: the handle space is shared. A handle is a plain uint32_t living in a record. It is not reachable as a language value (records expose no accessor for opaque_handle), so pit code cannot forge one. But a C module that mis-handles a handle can reach another actor’s native resource, which nan64 (where the pointer lives in the record itself) structurally cannot. Worth stating in docs/.

4.5 The native lane’s shared template caches (qbe_helpers.c) — safe, worth knowing

Two structures live in a generated native module’s writable data section, shared by every actor that calls that module. Both are compiled in for darwin/linux/windows (cake/plan.cm:768-773 selects qbe_helpers.c when the recipe has capabilities.native_payload; the three dev manifests all include it), but only exercised when a native payload actually runs — which the no-AOT ruling means is not the current lane.

  • AOTGenericRecordTemplate (qbe_helpers.c:638-696) — _Atomic uint32_t state 4-state priming CAS (0 unprimed → 1 priming → 2 good / 3 refused), with a spin loop on state==1 for losers (:692-695). Primed once, then read-only. The comment admits the priming “now fails for every real template” since the packed-text lane went, so it is effectively dead.
  • AOTRecordTemplate (qbe_helpers.c:723-795) — _Atomic uint32_t runtime_id + _Atomic uintptr_t shape, a most-recently-active-runtime cache over a shared PitRecordShape. The hot path (:780-795) is two acquire loads and no write; the cold path (:734-778) interns keys, builds a shape, and republishes with UINT32_MAX as a transient writer sentinel. The double-read of runtime_id around shape is deliberate and correct: it stops one runtime’s cookie pairing with another runtime’s descriptor.

Verdict: safe — read-mostly, atomics, and the values published are immortal shared constants, not actor-heap pointers. Note only that the cold path is a process-global mutable cache keyed on the current runtime, so a two-runtime embedder ping-pongs it. The comment says so.

4.6 Violations of the isolation model

None where pit code is concerned. I found no shared mutable structure reachable from the language. Every shared thing is either (i) stone/immortal (ct pool, shapes, cfuncs, image constants), (ii) engine plumbing not exposed as a value (queues, registry, watchers, ranges), or (iii) counters.

The violations that exist are C-level races, listed next.


5. C-level races found (bugs, not design problems)

5.1 pit_class_id_alloc — unsynchronized global ++ (real, memory-safety-adjacent)

runtime.c:53 PitClassID pit_class_id_alloc = PIT_CLASS_INIT_COUNT; runtime.c:2222 class_id = pit_class_id_alloc++;

Pit_NewClassID is called from native module registration, which runs inside script_startup (pit.c:145) — and script_startup is called from boot_actor_shell_with_mach_ownership (scheduler.c:3979) holding only actor->mutex, the starting actor’s own mutex.

Concurrency chain, verified end to end: pit_actor.c:196 (run_boot, an ordinary C function callable from pit code) → boot_actor_shell_with_machscript_startup. run_boot runs inside the calling actor’s turn, on whatever worker thread that actor was scheduled on. Two actors calling run_boot on two workers therefore run script_startup — and Pit_NewClassID — genuinely concurrently.

Compounding it: the per-module holders are themselves lazy-init statics with a read-check-write, e.g. image_provider.c:72-75 + :353-357, and Pit_NewClassID only allocates if (*pclass_id == 0) — a classic double-checked init with no barrier.

Consequence if it fires: two distinct classes get the same PitClassID. Pit_GetOpaque (runtime.c:3052-3061) authenticates on class_id alone, so it would accept a record of the wrong class and hand back its void* reinterpreted as the wrong struct. That is arbitrary memory corruption inside one actor.

Fix: make it _Atomic PitClassID with atomic_fetch_add, and make the per-module holders a CAS-published _Atomic PitClassID (allocate, CAS 0→id, on failure free the id and use the winner’s). ~20 lines total. Low risk. Should be done.

5.2 g_log_actor_id — free-while-read (real)

scheduler.c:93 is a char* that system_log_set_actor (:116-119) frees and strdups, and that system_log_active (:130-132) and is_log_actor (:155) read/strcmp from other threads with no lock. The long comment (:121-131) argues convincingly about staleness but says nothing about the free. A logger that dies while another thread is inside strcmp(g_log_actor_id, …) is a use-after-free.

Fix: _Atomic(char*) with the old pointer retired rather than freed (or an actors_mutex-style tiny lock; the path is not that hot). ~15 lines.

5.3 Two unsynchronized global_timer_id counters

scheduler.c:3318 (unneeded-countdown arm) and scheduler.c:5029 (actor_add_timer). Both static uint32_t global_timer_id = 1; id = global_timer_id++; and both increment before taking engine.lock. They are also two separate counters, so the two paths hand out overlapping ids by construction.

actor_remove_timer(actor, timer_id) matches on actor and id, so a collision between two actors is harmless; a collision for the same actor cancels the wrong timer. Low severity, trivial fix: one _Atomic uint32_t shared by both sites.

5.4 pit_tick_drain_head — unsynchronized list push, never removed, never deduped

registry.c:400-408. e->next = head; head = e; with no lock — a lost-update race if two actors initialize a drain-registering module concurrently. Worse, the header comment says modules register “during their normal initialization path”, which is per actor: N actors using the module ⇒ N identical entries ⇒ pit_run_tick_drains calls the same function N times per pit_drain, and nothing is ever freed. Today only wgpu-class modules use it, so it is latent.

Fix: CAS the head, and dedupe on fn. ~10 lines.

5.5 is_ct_ptr reads rt->ct_pages unlocked (benign-by-construction, worth a note)

pit_internal.h:2967-2979 walks the ct_pages linked list with no lock, from pit_ptr_in_actor_space — which is called on VM paths (mach_vm.c:1516,1522,1744) and per-slot inside the collector (pit_gc_copy.c:1175,1692). Writers push under ct_lock (runtime.c:494-503, reached only via pit_rt_ct_alloc and the two ct_lock-holding callers), and the push publishes page->next before head, so a reader sees either the old chain or the new one — both valid. It is UB by the letter of C11 and a data race under TSan, but it cannot produce a wrong answer on any platform in play.

Note also that the list is only reached when ct_base is exhausted (the primary arena covers everything in a normal daemon), and only for pointers not in the actor’s own heap/nursery/frames, because those disjuncts short-circuit first. So the walk is essentially never taken. Making ct_pages _Atomic is a one-word change if you want TSan cleanliness.


6. Verdict — ranked

(i) Hot-path shared facts that should be per-actor or lock-free

6.1 — actors_mutex + engine.lock on every message send. THE next image_lock. send_message (scheduler.c:4192) → scheduler_ref_actor(id) takes the process-global actors_mutex and does a string-hash lookup; then target->msg_mutex; then set_actor_stateenqueue_actor_priority takes the process-global engine.lock to push onto one of four shared queues and signal a condvar. Every worker also takes engine.lock to dequeue. So each inter-actor message costs two global lock acquisitions plus a condvar signal, and every turn costs another.

With the image lock gone, this is now the only remaining O(1)-per-message global serialization point in a 77-actor fleet. The post-fix profile’s __psynch_cvwait at 107,966 samples is consistent with threads parked on engine.wake_cond — i.e. the queue is now the thing they wait on. (I did not measure; this is a code-shape prediction, and it should be measured before it is fixed.)

Fix direction: (a) hand callers a refcounted PitContext* handle at couple time so the steady-state send skips the registry lookup entirely — the id→ctx map is only needed for the first resolution; (b) shard engine.lock/the run queues per worker with work-stealing, or at minimum split the timer heap off engine.lock onto its own mutex so the timer thread stops contending with every enqueue. Size: (a) medium, ~200 lines and a lifetime argument; (b) large, a scheduler rework. Measure first. Cheapest first step that is certainly right: give timer_heap its own lock — small, mechanical.

6.2 — image_lock is still an O(n) scan at every pin/unpin. registry.c:72-156. Loose per-module realization pins ~149 images; each pin/unpin scans the whole array under the runtime-global lock, so the load phase is quadratic and serialized. Not on the call path any more, but it is the same lock and the same scan. Fix direction: the range array wants to be a small open-addressed hash keyed on the view pointer, or — simpler and better — put the PitPinnedMachImageRange inline in the placement and give the view a direct pointer to its own range, making pin/unpin O(1) with an atomic pin_count and the lock needed only for array growth. Size: ~80 lines, low risk, the C regressions (mach_image_lifetime_test.c, resident_image_provider_test.c, mach_pool_test.c:1285) already cover the invariant.

6.3 — buddy.lock is the one global lock on the GC path. Every actor heap block, nursery grow, and copy/compact destination block goes through buddy_alloc/buddy_free, which take rt->buddy.lock and walk the pool list. With 77 actors collecting independently this is a genuine serialization point at GC boundaries — not per allocation (per-object allocation is a pure bump in the actor’s own arena), but per GC cycle and per heap resize, which under a compile fleet is frequent. Fix direction: a per-actor free-list cache in front of the buddy (an actor returning a block of order k keeps it for its own next request), or size-class-sharded buddy locks. Size: small for the cache (~60 lines), medium for sharding. Measure first — this is a prediction from code shape, not from a profile.

6.4 — nan32 opaque_lock on every Pit_GetOpaque. §4.4. Only bites the nan32 arm, which is not the arm under fleet load today, but it is structurally the same bug class as image_lock and should be fixed before nan32 ever runs a parallel fleet. Fix direction: a per-actor handle table (handles are already opaque and never cross actors), or a lock-free open-addressed table with a generation tag. Size: medium, ~150 lines, nan32-only blast radius.

6.5 — the PGO collector’s shared counters (§3.4). Latent, and further away than I first thought: the image lane cannot reach it at all (mach_exec_pgo → NULL). It is a register-lane structure, and the register lane is itself dead. Do not turn PGO collection on broadly, on any lane, without per-actor counter shards merged at snapshot.

6.6 — cfunc_lock linear strcmp scan per C-function registration per actor start. §4.3. Not per call, but M×F under one global lock at fleet start-up. Fix direction: key the cache by the C function pointer in a small hash rather than a list, and skip the pit_key_new before the lookup (it interns the name into the ct pool before discovering the entry already exists — runtime.c:2351 runs unconditionally). Size: ~50 lines. Easy win.

6.7 — actor start re-hashes the whole pool it is about to share. Not a sharing defect but the largest lifecycle cost the sharing model fails to save. provider_adopt_bytes blake2b’s the entire window (image_provider.c:878) and Pit_MachPoolOpen re-validates every STONE text’s hash (mach_pool.c:684) — both before the placement dedup lookup that would have told it the answer is already known. Every actor starting an already-mapped pool pays O(pool bytes) twice over. The file path also mmap/munmaps per call around the dedup (image_provider.c:484). Fix direction: move the dedup probe ahead of the hashing — the (placement_id, artifact_hash) key is available from the caller in the resident/plan path, and on the file path a (dev, ino, size, mtime) pre-probe is enough to skip the map+hash. Keep full validation for the first adopter; it is the repeat adopters that are paying for nothing. Size: ~60 lines, medium risk (the validation ordering is a security boundary — do not let a second adopter skip validation of a different byte range; key strictly).

(ii) Shared state that contradicts the stated model

Interned text: no contradiction. §2. The model holds. The only thing worth doing is documentary: the ct_lock comment sits at the top of a block that also covers shape rows, PitFunctions and PitCodes, which makes the pool look like a general text intern table when it is really “the immortal-constant arena, of which text is one tenant”. Renaming the block comment would stop the next reader (and the next John) worrying.

One genuine model deviation: pin_owner assumes one runtime per view (§3.3). It is fail-safe rather than fail-open, so it is a documentation/assert item, not a bug.

One structural deviation on nan32 only: the opaque handle table is genuinely shared state where nan64 has none (§4.4). It is not reachable from pit code, but it is a real asymmetry between the profiles and should be written down in docs/architecture/.

Overclaiming comments to correct while you are in there (each is a sentence, but each would mislead the next reader about sharing):

  • pit_internal.h:433-436pit_runtime_contains_pinned_mach_pointer “is used only by GC and diagnostics”. The GC does not call it; it has zero non-test callers.
  • pit_internal.h:1427PitCodeRegister.code_obj “shared OBJ_CODE wrapper”. Never read, never written, anywhere. Dead field advertising a sharing that was abandoned.
  • pit_internal.h:1290-1292 — “Runtime-wide immutable text pool… safe to reference from any actor.” True but it heads a block that also covers shape rows, PitFunctions and PitCodes. It reads as a general text intern table. Rename it to what it is: the immortal-constant arena, of which interned text is one tenant.
  • registry.c:12 — “No thread safety: the static table is installed once during startup.” Accurate for the extension table, but the same file also hosts the lease list and the tick-drain list, and a reader takes the sentence as covering the file.
  • pit_internal.h:1316-1323 (the register-lane gravestone) — add that the lane’s return would also reintroduce a runtime-global ct_lock acquisition and a permanent shared-arena allocation per closure (§3.5). Right now the gravestone only warns about the counters.

(iii) Confirmed clean — stop worrying about these

  • Execution out of mapped pools. One atomic load per call, everything else read-only, and I enumerated every shared-write candidate and found zero reachable from the image lane (§3.4). N actors on one pool cause zero coherence traffic. pin_owner does not even false-share with the control-plane refcounts. This is now genuinely right.
  • Pool granularity. Per-module / per-executable / many-executables differ only in mapping count and pin traffic. No granularity introduces a shared write.
  • The stone-text hash memoization, which was the one place an executing actor could plausibly have written into shared (and read-only!) pool memory. Closed at open by forced-nonzero hash validation of every STONE object (mach_pool.c:340).
  • Image function creation. Pit_NewImageFunction allocates one PitFunction on the actor’s own heap and nothing else. No shared allocation, no ct arena, no lock.
  • Record key lookup. pit_key_equal / pit_key_hash / rec_find_slot_hashed / pit_mach_record_get_hashed touch actor-local memory and immortal read-only text. No lock on the read path, and none needed. The profile leaves are honest work.
  • Ordinary text creation. Actor heap, always. No interning, no shared writes.
  • The garbage collectors. Both pit_gc_copy.c and pit_gc_compact.c keep 100% of their working state in __thread variables, deliberately and with a comment saying why. There is not one shared mutable byte in either collector besides buddy.lock.
  • PitContext isolation. Per-actor heap, per-actor class_array, per-actor mailbox/parks/signals, non-transferable PITCLASS records. No actor-heap pointer leaves an actor.
  • code_cache_count/code_cache_bytes. Verified: no writer in tree. The gravestone is intact.
  • Region table. Install-once behind an atomic latch; pit_region_find has zero non-test callers.
  • Static extension/endowment tables. Install-once, read-only.

Top 3 fixes, in order

  1. Get the message path off the two global locks (§6.1) — actors_mutex + engine.lock per send is the only remaining O(1)-per-message global serialization in a 77-actor fleet, and it is the shape image_lock had. Measure first (the code shape predicts it; nothing has profiled it since the pin fix moved the bottleneck). Cheapest correct first step: give timer_heap its own mutex so the timer thread stops contending with every enqueue. Then refcounted context handles at couple time to skip the registry lookup on the steady-state send.
  2. Make pin/unpin O(1) and stop re-hashing shared pools at every actor start (§6.2 + §6.7) — one arc: put the PitPinnedMachImageRange inline in the placement with an atomic pin_count (lock only for array growth), and move the placement dedup probe ahead of the blake2b + STONE re-validation. ~140 lines together; the C regressions (mach_image_lifetime_test.c, resident_image_provider_test.c, mach_pool_test.c:1285) already cover the pin invariant. This is the row that makes loose per-module realization stop being quadratic.
  3. Fix pit_class_id_alloc (§5.1) — atomic counter + CAS-published per-module ids. ~20 lines, and it closes a type-confusion path that Pit_GetOpaque cannot defend against, because it authenticates on class_id alone.

Runners-up, all small and independent: g_log_actor_id free-while-read (§5.2), pit_tick_drain_head (§5.4), the cfunc_lock scan (§6.6), the two global_timer_id counters (§5.3), and the five overclaiming comments listed above.


7. What I did not do

  • No measurement. Every performance claim in §6.1 and §6.3 is a prediction from code shape. §6.2/§6.7 are counted lock acquisitions and counted hashes, which is stronger, but still not a profile. The one measured fact in this document is the pin-lock fix’s own before/after, quoted from plans/archive/night-2026-08-04/seed-speed.md.
  • No platform/ sweep. The audit covered source/. Per-OS providers under platform/<os>/ may hold their own file-scope state (the posix signal/loop machinery is the likely candidate). Worth a second pass.
  • No TSan run. §5.1-§5.5 are read off the code. A ThreadSanitizer build of a parallel press would confirm or refute all five in one run, and would be the honest next step before touching any of them.

Source: plans/proposal-notes/runtime-sharing-audit.md