Back to SFEPs

SFEP-0022

Darwin (macOS arm64) Memory Governor

Status
Accepted
Type
runtime
Created
Updated
Author
agent:compiler-architect
Tracking
SFN-626

Darwin (macOS arm64) memory governor for the Sailfin toolchain

Status: proposal (design only). Spike measurements below were run on this machine (Apple Silicon, 12 cores, 64 GiB) against seed 0.7.0-alpha.47.

1. Problem (one paragraph)

On Linux the toolchain self-applies an 8 GiB RLIMIT_AS at startup (runtime/sfn/platform/rlimit.sfn:130 apply_default_mem_limit, called from compiler/src/cli_main.sfn:2930), inherited across fork/exec so every emit worker + clang/llvm-link child runs under the same hard cap. On Darwin that function returns early at rlimit.sfn:136 (/proc/self/status absent), so the macOS toolchain has zero memory backpressure. A single sfn test fixture build fans out to min(nproc, 8) parallel sfn emit workers (capsule_resolver.sfn:436 _cr_resolve_jobs, dispatched via xargs -0 -P N at capsule_resolver.sfn:738), each of which can peak in the multi-GB range (measured below). On a ~7 GiB macos-26 CI runner, 8 concurrent uncapped emits blow past RAM and the kernel (Jetsam) SIGKILLs them — the 216×137 / 2×134 pattern on PR #1532’s int-e2e-caps shard. int-e2e-caps tips first because it is the only shard that builds binaries (integration + e2e + capsule fixtures).

2. Spike results (measured, not assumed)

2.1 What the kernel exposes (symbol reachability)

All in libsystem_kernel.dylib (nm -gU), reachable as plain externs — no new C runtime needed for the observe path:

Symbol Reachable Verdict
proc_pid_rusage yes observeRUSAGE_INFO_V2.ri_phys_footprint, flat struct, getpid() arg, no mach port
task_info(TASK_VM_INFO) yes observe — phys_footprint, but needs mach_task_self() + count dance
task_set_phys_footprint_limit yes (in SDK header) rejected — does not enforce
setrlimit/getrlimit yes already used; RLIMIT_AS/RLIMIT_DATA rejected
sysctlbyname yes usedhw.memsize for RAM-aware fan-out

2.2 Hard-cap mechanisms — all ruled out on Darwin

Confirmed by clang-compiled probes (/tmp/sfn_spike/*.c):

  • RLIMIT_AS: on Darwin RLIMIT_AS == 5 == RLIMIT_RSS (not 9 as on Linux). setrlimit(RLIMIT_AS, 2 GiB) returns -1 / EINVAL(22); a 3 GiB touch survived. Not honored. (Matches the prompt’s diagnosis.)
  • RLIMIT_DATA (resource 2): setrlimit(RLIMIT_DATA, 256 MiB) also returns -1 / EINVAL(22); even ignoring that, a 400 MiB malloc+touch survived — Darwin’s allocator is mmap-backed, so RLIMIT_DATA (brk/sbrk segment) does not bound it. Useless here.
  • task_set_phys_footprint_limit(mach_task_self(), 256, &old): returned kr=8, and a 400 MiB touch under the “256 MiB cap” survived. It is a Jetsam-coordination private API, not a self-imposed hard kill for an ordinary process. Rejected.

Conclusion: there is no inheritable, fork/exec-propagating hard memory cap on Darwin equivalent to Linux RLIMIT_AS. The Linux design (cap once in fn main, inherit everywhere) has no Darwin twin. This is the load-bearing finding that shapes the whole design.

2.3 Observation works and is accurate

proc_pid_rusage(getpid(), RUSAGE_INFO_V2) reported phys_footprint 1,245,544 B at start and 211,091,960 B after a +200 MiB touch — tracks within noise. task_info(TASK_VM_INFO) agreed (1,261,928 B). proc_pid_rusage is the simpler call and the recommended observe primitive.

2.4 Per-module-emit peak footprint (the number that sizes fan-out)

2026-06 baseline (historical) — Darwin arm64, seed 0.7.0-alpha.47

SAILFIN_MEM_LIMIT=unlimited /usr/bin/time -l <seed> emit ... llvm <module>, run on the 12-core/64 GiB Apple Silicon spike machine named in the introduction:

Module Lines max RSS peak footprint
capsule_resolver.sfn (heaviest) 4675 3.23 GiB 8.5 MiB*
typecheck_types.sfn (typical-heavy) 2113 1.08 GiB 8.5 MiB*
build_stamp.sfn (small) 111 59 MiB

* peak memory footprint from time -l under-reports here (it samples the final value, not the peak of a short-lived process); max RSS is the figure that matters for Jetsam, and it is what the runtime governor reads live via phys_footprint (which does track the peak as it climbs). The headline: a single heavy-module emit peaks above 3 GiB RSS. Eight of those concurrently is ~25 GiB of demand on a 7 GiB runner — exactly the Jetsam trigger.

This is the spike’s most important quantitative result: even 2 concurrent heavy emits (~6.4 GiB) exceed a 7 GiB runner’s usable RAM. The safe steady-state fan-out on a 7 GiB runner is effectively 1–2, not 8.

Caveat: measured on a 64 GiB machine where the allocator is under no pressure; absolute RSS on the constrained runner may differ (the allocator releases more aggressively under pressure), but the ratio (heavy ≈ 3 GiB, ~30× a small module) and the >RAM-on-7GiB conclusion are robust. The exact runner peak needs one CI confirmation run (see §7).

This baseline is the figure the original per_job_reserve_bytes = 3 GiB constant (§3) was sized against; it is kept here as the historical record and superseded below.

2026-07-31 recalibration (SFN-626) — Linux x86_64, seed 0.8.4

sfn bench --compiler --csv over all 302 compiler/src modules (isolated emit per module via process.run_capture_metered). Host: Linux x86_64, 4 cores, 16,856,244,224 B (15.7 GiB) RAM, idle. Total bench wall time 446.61 s. Compiler-module data in docs/baselines/compile-0.8.4-linux-x86_64.csv (schema module,time_s,peak_kb,ir_lines,status,seed_version; all 302 rows status=ok; the CSV covers compiler/src only, no runtime/sfn rows).

Worst workers (peak RSS):

Module peak_kb GiB
llvm/runtime_helpers.sfn 1,623,420 1.55
llvm/lowering/lowering_core.sfn 1,441,140 1.37
llvm/expression_lowering/native/core_call_emission.sfn 1,402,088 1.34
llvm/expression_lowering/native/core_operands/union_fallback.sfn 1,118,456 1.07

A separate, smaller measurement checked runtime/sfn did not hide a heavier worker: /usr/bin/time on the five largest runtime/sfn sources by line count, same host.

Module peak_kb GiB
runtime/sfn/adapters/websocket.sfn 621,204 0.59
runtime/sfn/adapters/http.sfn 587,600 0.56
runtime/sfn/process.sfn 522,232 0.50
runtime/sfn/platform/process_windows.sfn 484,484 0.46
runtime/sfn/string.sfn 340,860 0.33

The heaviest runtime/sfn source (0.59 GiB) sits well under the compiler’s worst worker — the runtime is not the constraint. Distribution across the 302 compiler modules: 26 above 1 GiB, 136 in 512 MiB–1 GiB, 78 in 256–512 MiB, 62 under 256 MiB. No module now exceeds 1.55 GiB, versus the 3.23 GiB capsule_resolver.sfn figure the 2026-06 baseline recorded — that module and every other former >2 GiB module has since been decomposed (SFN-625, SFN-634, SFN-637, SFN-638, and the #2707 resolver split).

The reserve constant (§3) is now sized against this measurement: 2.5 GiB is a 61% margin over the 1.55 GiB measured worst worker. That margin is deliberate, not reclaimable slack — see §7 for the Darwin-platform-delta and hard-floor reasoning it exists to cover.

Because no hard cap exists, the design is defense-in-depth, with the cheap layer carrying almost all the weight:

Layer 1 (primary, ships first): RAM-aware fan-out governor

Make _cr_resolve_jobs() (capsule_resolver.sfn:436) memory-aware on Darwin, not just CPU-aware. Today it returns min(nproc, 8). Replace the clamp with min(nproc, 8, ram_budget_jobs) where:

ram_budget_jobs = max(1, floor(usable_ram_bytes / per_job_reserve_bytes))
usable_ram_bytes = hw.memsize * SAFETY_FRACTION (e.g. 0.66)
per_job_reserve_bytes = 2.5 GiB (SFN-626 recalibration, §2.4; see the hard
floor below before lowering further)

On a 7 GiB runner: floor(7*0.66 / 2.5) = floor(1.85) = 1serial emit (unchanged). On a 64 GiB dev box: floor(64*0.66 / 2.5) = 16 → still clamped to 8 (unchanged local behavior). On a 16 GiB laptop: floor(16*0.66/2.5) = 4 (was 3 under the 3 GiB reserve).

The SFN-626 recalibration (§2.4, §7) moved the reserve from 3 GiB to 2.5 GiB, raising job counts across the fleet: a 16 GiB host goes 3 → 4 emit workers; the _test_jobs_budget pool sees a 32 GiB host go 7 → 8 and an 8 GiB host go 1 → 2. A 7 GiB host is unchanged at 1, and RAM-rich hosts stay clamped to nproc/8. Do not lower the constant further without a Darwin arm64 measurement — see the 2.31 GiB hard floor in §7.

hw.memsize is read via sysctlbyname (confirmed reachable). This is the fix for the concurrent-clang multiplier and it is cheap, deterministic, and needs no per-process monitoring. SAILFIN_BUILD_JOBS still overrides everything (explicit user/CI intent wins, including the existing escape hatch).

Apply the RAM clamp on all platforms, not just Darwin — it is a strictly better default than the blind min(nproc, 8) everywhere (Linux benefits too on small runners), but it is only load-bearing on Darwin because Linux still has the inherited RLIMIT_AS hard floor underneath. Gate the aggressiveness by platform if desired (Linux can keep a looser fraction since RLIMIT_AS catches the tail), but a single shared formula is simpler and the doc recommends that.

Layer 2 (the real governor, ships second): self-monitor soft cap

A best-effort in-process watchdog that mirrors the Linux intent (a per-process budget the process refuses to exceed), implemented as periodic observation since no kernel enforcement exists. Add a Darwin branch to apply_default_mem_limit() that, instead of setrlimit, arms a footprint monitor:

  • A background mechanism polls proc_pid_rusage(getpid(), RUSAGE_INFO_V2).ri_phys_footprint against the same 8 GiB target (and the SAILFIN_MEM_LIMIT override / unlimited/off/0 disable).
  • When footprint crosses the budget, it prints the existing-style [mem-limit] ... trace and aborts the process (a controlled abort()/_exit(137)-style exit) before the runner OOMs — converting a random Jetsam SIGKILL of an arbitrary victim into a deterministic, attributable self-kill of the actual offender, with a diagnostic line.

Polling vehicle — the honest uncertainty. Sailfin has no thread/timer primitive exposed to runtime/sfn/ today (concurrency runtime is unshipped per CLAUDE.md). Three options, in order of preference:

  1. Allocator hook (no thread): wire the check into a coarse allocation path so footprint is sampled on a 1-in-N allocation basis. Cleanest (no background thread, fully inheritable semantics within one process) but requires a hook point in the Sailfin/runtime allocator — needs a feasibility probe (does runtime/native expose a single malloc choke point we can instrument without a C edit?). Flag: likely needs a small C helper in runtime/native/src/ (an extern fn sfn_mem_footprint_check() -> void that does the proc_pid_rusage read + threshold compare + abort). This is the one place a C helper may be genuinely required; call it out for an architect decision rather than forcing pure-Sailfin.
  2. SIGALRM/setitimer watchdog (C helper): arm a repeating timer whose handler reads phys_footprint and aborts past budget. Inheritable across fork only if re-armed in the child; xargs-spawned sfn emit children would each re-arm via their own apply_default_mem_limit() in their fn main (each worker is a fresh sfn emit process — see §4). Needs a C signal helper.
  3. Coarse checkpoint calls (pure Sailfin): insert mem_footprint_check() calls at a few known-hot loop boundaries in the emitter/lowering. Pure Sailfin, no thread, but coverage is only as good as the checkpoint placement — misses a single pathological allocation between checkpoints. Lowest risk to ship, weakest guarantee.

Recommendation: Layer 1 (fan-out) is the actual CI fix and should ship alone first. Layer 2 is a safety net; implement it as option 3 (pure-Sailfin checkpoints) first (zero new C, mirrors the soft-cap intent), and only escalate to option 1/2 (C helper) if CI shows single-process emits still OOM after fan-out is serialized — which the spike suggests they will not (a single heavy emit is ~3 GiB, comfortably under 7 GiB once it’s the only one running).

Why not a hard cap at all

The spike proves Darwin offers no self-imposed hard memory kill. Marketing or documenting a “macOS memory cap” with the same hard-guarantee language as Linux would be a “parsed but not enforced”-class overclaim (CLAUDE.md design framework). The Darwin governor is explicitly a soft, best-effort floor; document it as such. The hard guarantee on Darwin is delivered by not creating the over-subscription in the first place (Layer 1), not by catching it after.

4. How the budget propagates across the fan-out

Linux relies on RLIMIT_AS inheritance across fork/exec. On Darwin there is no inherited limit, so propagation is structural:

  • The emit workers are separate sfn emit processes spawned via xargs -0 -P N sh worker (capsule_resolver.sfn:738), each running its own fn mainapply_default_mem_limit(). So each worker independently arms its own Darwin governor (Layer 2) — no inheritance needed; the budget is re-derived per process. This is actually cleaner than Linux’s inheritance model.
  • Layer 1 (fan-out) governs the parent’s decision of how many workers to spawn — it is the only thing that prevents N-way over-subscription, and it lives entirely in the parent (_cr_resolve_jobs). clang/llvm-link grand- children are not Sailfin processes and cannot self-cap; the only lever for them is also the fan-out count (fewer parallel sfn emit ⇒ fewer concurrent clang). This reinforces that Layer 1 is the primary mechanism.

5. Constraints honored

  • Self-hosting invariant: all changes are in capsule_resolver.sfn and rlimit.sfn (both compiled into the toolchain). make compile must pass after each step. The fan-out change is pure arithmetic on existing helpers — no new language feature, so the old seed compiles the new compiler (additive).
  • SAILFIN_MEM_LIMIT contract (.claude/rules/compiler-safety.md): unlimited/off/0 must still fully disable. The Darwin branch reuses the exact env-parsing block already in rlimit.sfn:142-170 (factor it out so Linux and Darwin share one parse), so the disable path and the < 1 MiB rejection are identical. Sanitizer legs: ASAN reserves ~16 TB vmem; the fan-out clamp never blocks it (it only reduces parallelism), and the Layer-2 monitor must early-return under unlimited/off/0 exactly as Linux does — so an ASAN run with SAILFIN_MEM_LIMIT=unlimited arms no monitor. Preserved by construction.
  • Windows cross-link exclusion (rlimit.sfn:47-55): unchanged. The Darwin branch is gated behind a Darwin probe (see §6) that is false on the Windows cross-link too; Windows continues to resolve the strong no-op stub in runtime/ir/windows_stubs.ll. Do not add new externs to rlimit.sfn that the mingw link can’t resolve — proc_pid_rusage/sysctlbyname are Darwin-only, so if they live in rlimit.sfn they must be added to the cross-windows RUNTIME_MODS exclusion or (preferred) the Darwin-only externs live in a separate runtime/sfn/platform/darwin_mem.sfn module that is itself excluded from the Windows bridge (same precedent as process.sfn). Pin the exclusion in test_cross_windows_runtime_modules.sh’s peer test.
  • #306 inline-extern discipline: new externs (proc_pid_rusage, sysctlbyname) declared inline at point of use, per the directory convention.
  • Pure-Sailfin goal: Layer 1 is pure Sailfin. Layer 2 option 3 is pure Sailfin. A C helper is only needed for Layer 2 options 1/2 — flagged, not assumed.

6. Platform detection on Darwin

The Linux gate is sfn_fs_exists("/proc/self/status") (rlimit.sfn:136). For the Darwin branch, the symmetric probe is the absence of /proc AND presence of a Darwin marker. Cheapest reliable Darwin marker without a uname subprocess: sfn_fs_exists("/System/Library/CoreServices") (always present on macOS, absent on Linux/Windows). Alternatively sysctlbyname("hw.memsize", ...) succeeding (returns 0) is itself a Darwin signal and is the value we need anyway — prefer that (one call serves detection + the RAM number). The control flow in apply_default_mem_limit():

if /proc/self/status exists -> Linux path (unchanged setrlimit)
else if sysctlbyname hw.memsize ok -> Darwin path (arm soft monitor / no-op for now)
else -> other (Windows stub / unknown): no-op (unchanged)

Layer 1’s _cr_resolve_jobs reads hw.memsize via sysctl -n hw.memsize (it already reads hw.ncpu, so the probe path is established).

Stale as written (SFN-809). This section originally said to reuse _cr_shell_read. That helper has been deleted — it was an orphaned third copy of the shell-capture guard with zero callers. More importantly the shell path it assumed is no longer the house pattern: capsule_emit_parallel.sfn (_cr_host_cpu_count / _cr_host_memsize_bytes) now probes CPU and RAM with no shell at all — Linux reads /proc via fs.exists/fs.readFile, Darwin execs /usr/sbin/sysctl by absolute path with an empty env, and Windows reads NUMBER_OF_PROCESSORS via env.get. Any implementation of this proposal should extend that ladder rather than reintroduce a popen.

7. CI

  • Fix the stale comment at ci.yml:~681 (“int-e2e-caps runs serially [green]”) — it is false; the shard runner is serial (--jobs 1 in test_shards.sh run) but each fixture build fans out internally. The comment should say the test runner is serial while per-build emit fans out, and reference this governor.
  • Interim stopgap (ship in the same PR as Layer 1, or before it): set SAILFIN_BUILD_JOBS=1 (or 2) on the macOS build-macos job env in ci.yml, and on the build-compiler-macos job. This is the zero-risk unblock — it forces serial emit immediately, independent of any compiler change, and is exactly what Layer 1 will compute anyway on a 7 GiB runner. Set it as an env on the job and remove it once Layer 1 lands and a CI run confirms the governor self-selects ≤2. Document the stopgap inline so it is not mistaken for a permanent setting.
  • The per-job reserve constant was recalibrated in SFN-626 (2026-07-31): sfn bench --compiler --csv over all 302 compiler/src modules on Linux x86_64 (docs/baselines/compile-0.8.4-linux-x86_64.csv, §2.4) found the worst worker at 1.55 GiB RSS, down from the 3.23 GiB capsule_resolver.sfn figure the 2026-06 baseline recorded — that module and every other former

    2 GiB module has since been decomposed (SFN-625, SFN-634, SFN-637, SFN-638, #2707). The reserve dropped from 3 GiB to 2.5 GiB, a 61% margin over the 1.55 GiB measured worst worker. That margin is deliberate, not slack to reclaim: the measurement is Linux, but the constant’s binding constraint is Darwin, where RLIMIT_AS is a no-op and this arithmetic is the only backpressure, so the margin absorbs the unmeasured platform delta. There is also a hard floor at 2.31 GiB: at or below it, a 7 GiB Darwin runner’s 66% slice (4.62 GiB) budgets 2 workers instead of 1, reopening the #1532 OOM this governor exists to prevent. A future CI/Darwin-arm64 confirmation run remains the only sound basis for lowering the reserve past that floor.

8. Staged implementation plan (smallest blast radius first)

Each step independently sfn check-able and make compile-able.

Step 0 — CI stopgap (no compiler change, ships immediately). Set SAILFIN_BUILD_JOBS=2 (conservative) on the macOS jobs in ci.yml. Unblocks PR #1532’s shard now. Verify: re-run int-e2e-caps; expect zero 137/134 exits.

Step 1 — Darwin-aware fan-out governor (Layer 1, the real low-risk fix).

  • capsule_resolver.sfn:436 _cr_resolve_jobs: after the SAILFIN_BUILD_JOBS override and the nproc read, add a RAM clamp: read hw.memsize via the no-shell probe ladder in capsule_emit_parallel.sfn (_cr_host_memsize_bytes — see the note in §6; do not reintroduce _cr_shell_read, which is deleted), compute ram_budget_jobs (§3 formula), return min(nproc_clamped, ram_budget_jobs). Keep the SAILFIN_BUILD_JOBS override ahead of the clamp (explicit intent wins).
  • Verify: sfn check compiler/src/capsule_resolver.sfn; make compile; on this box confirm _cr_resolve_jobs still yields 8 (64 GiB ⇒ 14 clamped to 8); add a unit test (§ test strategy) asserting the formula. Then drop the Step-0 stopgap to SAILFIN_BUILD_JOBS unset on a trial CI run and confirm the governor self-selects ≤2 on the runner. This is the step that closes the CI failure on its own. Bundle the ci.yml comment fix here.

Step 2 — Darwin branch in apply_default_mem_limit (Layer 2 scaffolding, pure-Sailfin no-op + trace).

  • Factor the SAILFIN_MEM_LIMIT parse out of rlimit.sfn into a shared helper returning (target, explicit, disabled).
  • Add the Darwin detection branch (§6). For this step it only emits a [mem-limit] darwin soft monitor armed (target=N) trace and returns 0 — no enforcement yet. Establishes the env contract + detection + Windows-exclusion plumbing with zero behavioral risk.
  • If Darwin-only externs are introduced, create runtime/sfn/platform/darwin_mem.sfn and add it to the cross-windows exclusion; update test_cross_windows_runtime_modules.sh’s peer.
  • Verify: make compile; make check; cross-windows link still resolves; SAILFIN_TRACE_MEM_LIMIT=1 sfn version on macOS shows the new trace; on Linux the trace is unchanged.

Step 3 — Layer 2 enforcement (pure-Sailfin checkpoint monitor).

  • Add mem_footprint_check() reading proc_pid_rusage/phys_footprint, comparing to the budget, aborting past it with a [mem-limit] diagnostic. Honor unlimited/off/0 (no-op). Wire calls at a few hot emit/lowering loop boundaries.
  • Verify: make compile; a *_test.sfn regression (below) that sets a tiny SAILFIN_MEM_LIMIT and asserts the diagnostic + nonzero exit on macOS; and asserts unlimited produces no abort (sanitizer-leg safety).
  • Only if Step 1 + a CI run prove single-process emits still OOM (spike says they won’t): escalate to a C-helper allocator hook (Layer 2 option 1/2) under a separate issue, with the ASAN-interleave gate from .claude/rules/compiler-safety.md.

9. Test strategy

Mirror mem_limit_selfcap_test.sfn and the sanitizer-skip discipline. All *_test.sfn, no bash e2e (.claude/rules/no-bash-e2e.md).

  • Fan-out formula (unit, pure): a compiler/tests/unit/ test that exercises the RAM-clamp arithmetic. Since _cr_resolve_jobs shells out, the testable surface is the pure formula helper — extract _cr_ram_budget_jobs(memsize: i64, nproc: int) -> int as a pure fn and unit-test it directly: 7 GiB ⇒ 1, 16 GiB ⇒ ≤3, 64 GiB ⇒ 8 (clamp), SAILFIN_BUILD_JOBS override bypasses it.
  • Darwin trace (e2e, ![io]): extend mem_limit_selfcap_test.sfn. Add a _is_darwin() peer to _is_linux() (fs.exists("/System/Library/CoreServices")). On Darwin, assert the [mem-limit] darwin soft monitor armed (Step 2) / enforcement trace appears under SAILFIN_TRACE_MEM_LIMIT=1; the existing Linux cases keep their _is_linux() skip. Each test skips (assert true) on the non-matching platform — same shape as today’s _is_linux() guard.
  • Enforcement (e2e, ![io], Step 3): spawn sfn version (or a tiny build) with a deliberately tiny SAILFIN_MEM_LIMIT and assert a nonzero exit + the [mem-limit] over-budget diagnostic on stdout/stderr (via sfn/strings contains, since ![io] can’t use ![pure] matchers). A companion case with SAILFIN_MEM_LIMIT=unlimited asserts the run completes — the sanitizer-leg safety regression.
  • Sanitizer-leg safety: if any test builds with -fsanitize=address, it must run with SAILFIN_MEM_LIMIT=unlimited and skip-not-fail on a non-ASAN startup abort, per .claude/rules/compiler-safety.md (the monitor must be a no-op under unlimited, so ASAN’s 16 TB reservation is never tripped by our code).

10. Risks

  • Per-job reserve constant drift. Recalibrated once, in SFN-626 (3 GiB → 2.5 GiB, §2.4/§7), against a Linux x86_64 measurement — not Darwin, so the original spike’s 64 GiB/allocator-under-no-pressure caveat (§2.4) still applies to the Darwin number this constant is load-bearing for. Mitigate: the constant is a single named value easy to tune; do not lower it below the 2.31 GiB hard floor (§7) without a fresh Darwin arm64 measurement, since below that floor a 7 GiB runner’s 66% slice budgets 2 workers instead of 1 and reopens #1532.
  • floor(...) = 0 starving the build. Formula uses max(1, ...) — a tiny runner still gets serial emit, never zero. Verified in §3.
  • Layer 2 checkpoint coverage gaps (option 3): a pathological single allocation between checkpoints could still OOM. Accepted as a soft floor; the hard protection is Layer 1’s serialization. Escalation path to a C allocator hook documented.
  • Windows cross-link regression from new Darwin-only externs. Mitigate: put them in a separate module excluded from the bridge, pin with the existing peer test. Highest-blast-radius risk; isolated to Step 2 and gated by make check
    • the cross-windows test.
  • Local dev behavior change. Layer 1 could reduce parallelism on small dev machines. Mitigate: SAFETY_FRACTION=0.66 + the 2.5 GiB reserve (SFN-626, §3) keeps a 16 GiB laptop at 4 jobs and a 64 GiB box at the existing 8; SAILFIN_BUILD_JOBS always overrides.

11. Verification commands

  • Per step: build/bin/sfn check <touched .sfn> then make compile.
  • Step 1 local sanity: confirm _cr_resolve_jobs still 8 on this box (a debug trace or the unit test on the extracted formula).
  • Full gate before “shipped”: make check (triple-pass self-host + suite), sfn fmt --check on every touched .sfn, cross-windows link test green.
  • CI: a trial int-e2e-caps run with SAILFIN_BUILD_JOBS unset confirming the governor self-selects ≤2 and zero 137/134 exits; then remove the Step-0 stopgap.

12. Future considerations (1.0)

  • When the concurrency runtime lands, Layer 2 can move from checkpoint polling to a proper background watchdog thread — but the fan-out governor stays as the primary mechanism; structured concurrency does not give Darwin a hard memory cap.
  • The build-performance plan (docs/proposals/0006-build-architecture.md, <5 min target) wants more parallelism; the RAM-aware governor is the safety valve that lets the parallel emit path raise its clamp on big hosts without endangering small ones — they are complementary, not opposed.
  • A future per-host per_job_reserve calibration (measure the actual heaviest module’s peak once at first build, cache it) would make the formula self-tuning; out of scope for the CI unblock. SFN-626 (2026-07-31) performed a one-time manual recalibration (3 GiB → 2.5 GiB, §2.4) via a full-suite sfn bench run on Linux, not a per-host measurement — the self-tuning version described here is still unimplemented. Any further lowering of the reserve needs a Darwin arm64 measurement first; do not go below the 2.31 GiB floor (§7) on the strength of a Linux number alone.