SFEP-TBD
Cross-invocation runtime-object persistence for the `sfn test` link path
- Status
- Draft
- Type
- tooling
- Created
- Updated
- Author
- agent:compiler-architect; human review
- Tracking
- SFN-1086
SFEP-XXXX — Cross-invocation runtime-object persistence for the sfn test link path
1. Summary
sfn test pays a ~40 s (Linux) / ~47 s (Windows) runtime-object build on every
invocation whose scratch is cold, and that cost never amortizes across
invocations. The pool already amortizes it within one invocation
(multi_file_run.sfn:105 warms once, children hit via
SAILFIN_TEST_RUNTIME_OBJDIR), and sfn build / sfn run already amortize it
across invocations through the shared content-addressed cache (#1096). The
test path is the only link path excluded, because
compiler/src/cli/commands/test/link.sfn:142 passes the literal "" as
shared_cache_root.
This proposal removes that exclusion: resolve a real shared_cache_root on the
test path and thread it to the two assemble_runtime_capsule_link_inputs call
sites the test command owns (link.sfn:142, multi_file_run.sfn:105), gated
"" under --no-test-cache exactly as sfn build gates it "" under
--no-cache (build.sfn:590-592). Measured proxy A/B on a novel, never-built
test file with a fresh scratch each run: 40.36 s → 38.80 s today (no
amortization) versus 40.24 s → 1.59 s with persistence — 25x on the
second and every subsequent invocation.
No new machinery is introduced. The store, the key derivation, the multi-writer
atomic-rename discipline, and the artifact kinds (runtime-obj, sfn-asm) all
ship today and are already exercised by the build path.
2. Motivation
2.1 Measurements (Linux x86_64, build/bin/sfn 0.10.4, jobs=2)
| Scenario | Wall |
|---|---|
| 1 trivial test file, fresh scratch | 40.33 s |
| same file again, same scratch | 0.03 s |
| different file, same scratch | 1.26 s |
| fresh scratch again | 39.78 s |
| 5 files, one pooled invocation, fresh scratch | 45.8 s |
| N=15 / N=30 / N=60 (30 newly built) | 51.0 / 59.8 / 212.6 s |
The scaling is strictly linear in file count, so the 40 s is not per-file — it
is a per-fresh-scratch constant. Per fresh scratch the run materializes 40
runtime .o + 41 .ll, 36 link-obj/*.o, and 180 rt-import-context staging
files. A three-line trivial test costs the same as a real one.
Windows: cold 47.4 s; the single-file warm floor is 3.15 s against Linux’s 0.42 s, so Windows carries a second, separate constant (per-object file copy) that this proposal reduces the frequency of but does not remove.
2.2 The cause is one literal
compiler/src/cli/commands/test/link.sfn:139-142 // Test link path keeps runtime objects work-dir-local (shared root // empty); the shared content-addressed runtime cache (#1096) is // scoped to the build/run path that feeds the build-quality gate. let rt = assemble_runtime_capsule_link_inputs(runtime_caps, rt_cache_dir, "-O0", binary_dir, "");The same function on the build path receives a real root
(build/link.sfn:292, fed from build.sfn:590-592 / run.sfn:297-299).
2.3 The exclusion was scope, not correctness — verified
a3234818 (“fix(cache): back runtime objects with the shared content cache
(#1096) (#1101)”) states in its body: “The shared layer is threaded as
shared_cache_root and gated on cache_config.enabled (empty under
--no-cache), and the sfn test link path stays work-dir-local.”
That sentence is a statement of scope. The commit’s entire stated motivation
is restoring the build-quality determinism gate’s cache.hit_rate >= 0.95 on
the det-pass1 → det-pass2 sibling-work-dir pair — a build-path gate. No
correctness argument for excluding the test path appears in the commit body, in
link.sfn’s comment (which only re-states the scope), or anywhere in
runtime_objs.sfn.
I looked specifically for a correctness reason and did not find one. The two candidates I checked and ruled out:
- “Test and build compute different runtime source sets, so sharing is unsound.” Addressed in §3.4 — for the demand-set difference specifically, the key folds what distinguishes them, so a differing set produces a different key, i.e. a miss. Note §3.4’s broader “no false hit is reachable” claim is withdrawn for an unrelated reason (SFN-1087); it does not reinstate this objection, which concerns the demand set only.
- “Enabling it would defeat
make check’s cold suite.” Real, and the reason this proposal ships the--no-test-cachegate (§3.5). It is an argument for a lever, not for a permanent exclusion.
Verdict: the fix is sound. The remainder of §3 states the correctness argument explicitly.
2.4 Where the win lands
- Local inner loop. Every
sfn test <file>after the first, on any scratch, on any branch that has not changed the compiler binary or the runtime sources. - The e2e suite. Nested
sfn test/sfn buildchildren inheritSAILFIN_BUILD_CACHE_DIRthroughclean_runner_env(capsules/sfn/test/src/fixtures.sfn:236-251strips only_pool_managed_keysand_caller_override_keys; the cache dir is not in either list), so ~309 subprocess-driving e2e files participate automatically. - CI, once §3.7 wires the cache path.
3. Design
3.1 Shape of the change
Three edits plus one helper. Nothing in compiler/src/build/runtime_objs.sfn
changes: it already accepts, threads, and honours shared_cache_root on every
layer the test path uses.
The probe ladder in _prepare_runtime_sfn_object is additive — this
proposal only makes the third rung reachable from the test path:
runtime_objs.sfn:888 stamp fast path (_runtime_stamp_cache_hit)runtime_objs.sfn:903 work-dir sidecar (_runtime_obj_cache_hit)runtime_objs.sfn:910 shared CAS <— today unreachable from `sfn test`runtime_objs.sfn:920+ emit + clangNothing is removed. Every existing hit path keeps priority, which is why no currently-fast path becomes slower (§5.3).
3.2 New helper — runtime_obj_cache_root()
compiler/src/build_cache.sfn, immediately after test_bin_cache_root
(:1489-1504), mirroring its shape exactly:
// Pure variant.fn runtime_obj_cache_root_with_override(override_path: string) -> string { // `<base>/<build_cache_schema_version()>`, base = override or "build/cache" return cache_root_from(override_path, false, "", "");}
// Production reader.fn runtime_obj_cache_root() -> string ![io] { return runtime_obj_cache_root_with_override(_get_env_cmd("SAILFIN_BUILD_CACHE_DIR"));}Why this ladder and not cache_root(capsule_name). The test command never
resolves a root capsule name; discovery.sfn:205 reads per-suite manifest names
only. Threading one to link.sfn would be a real refactor for no correctness
gain. The chosen ladder produces the identical string to
cache_root("sfn/compiler") in both cases that matter:
| Condition | cache_root("sfn/compiler") |
runtime_obj_cache_root() |
|---|---|---|
SAILFIN_BUILD_CACHE_DIR=X |
X/v2 |
X/v2 |
| unset, in-tree | build/cache/v2 |
build/cache/v2 |
So in this repo — and in CI — sfn build -p compiler and sfn test share one
store by construction. The precedent is test_bin_cache_root, which has used
exactly this ladder since #1230.
Accepted divergence. For a non-compiler capsule with HOME set,
sfn build resolves ~/.cache/sailfin/v2 while sfn test resolves
build/cache/v2. The two then do not share. This costs a warm-start
opportunity; it can never produce a wrong artifact (different roots, same key
derivation). Unifying the two ladders is a separate question that belongs with
sfn cache, which itself reads cache_root("") (cli/commands/cache.sfn:111)
and therefore already disagrees with the self-host pin. Out of scope; noted so
the next reader does not mistake it for an oversight.
3.3 The three seams
(a) compiler/src/cli/commands/test/link.sfn:49 — add a trailing parameter:
fn _clang_link_test_cmd_with_deps(ll_path, out_path, runtime_root, dep_ll_paths, cache_dir, binary_dir, runtime_demand, shared_cache_root: string) -> TestLinkResult ![io]and at :142 replace the literal "" with shared_cache_root. Replace the
:139-141 comment with one that records the gate (§3.5) rather than the
retired exclusion. _ensure_dir_cmd(rt_cache_dir) at :138 is unchanged.
A parameter, not an env read: this function already reads
SAILFIN_TEST_RUNTIME_OBJDIR at :78 and adding a second ambient input to a
link function makes the decision untestable from the call site. There is exactly
one production caller.
(b) compiler/src/cli/commands/test/single_process_run.sfn — the sole
caller. no_test_cache: boolean is already parameter 7 at :70. Resolve the
root once, beside the existing test_cache_enabled block at :186-206:
let mut rt_shared_cache_root: string = "";if !no_test_cache { rt_shared_cache_root = runtime_obj_cache_root(); }and pass it at :321. Resolving once (not per file) matches the existing
treatment of tb_cache_root, tb_compiler_identity, and tb_target_triple —
all invocation-stable, all hoisted for the same reason.
(c) compiler/src/cli/commands/test/multi_file_run.sfn:105 — the parent
warm. no_test_cache is already parameter 8 at :42. Same two lines, same
gate, then pass the resolved root as the fifth argument.
The pool needs no new plumbing: _pool_child_argv
(pool.sfn:143-149) already forwards --no-test-cache to every child, so
parent and children compute the same root independently and cannot disagree.
3.4 Correctness argument — why sharing is safe
Claim, as narrowed by review. For every input the key does fold, two
producers (a sfn build and a sfn test, a narrowed and an unnarrowed demand
set, an -O0 and an -O2 link) either produce identical bytes and share a
key, or produce different bytes and land on different keys — a miss.
The stronger claim this section originally made — “a false hit is
unreachable” — is withdrawn. Adversarial review of PR #3077 found a second,
unkeyed source of truth: _load_llvm_import_context
(compiler/src/main.sfn:947) ignores the .import-deps sidecar the key folds
and re-resolves imports against the on-disk contents of ctx_root,
including .slugalias indirection, a depth-3 transitive walk, and
module_aliases that reach symbol mangling. The object is therefore a function
of a directory’s contents while the key is a function of a sidecar list; they
coincide only when ctx_root holds exactly staged_slugs, and nothing prunes
ctx_root.
That gap is pre-existing on the build/run path and is tracked as SFN-1087. This proposal does not create it, but it does widen exposure by adding a second producer into the same store, so it is recorded here rather than left to the issue alone. No reproducer is known; it is an unsound invariant, not an observed miscompile.
The .o key, composed at runtime_objs.sfn:900 and re-composed identically
by the stamp writer at :578:
runtime_object_cache_key_with_identity(src, opt_flag, compiler_identity) // build_cache.sfn:1114-1141, :1173-1178 = sha256(src bytes) "\n" opt_flag "\nsecsplit1-strabi1" "\n" compiler_identity |> _runtime_obj_key_with_sibling_deps(ctx_root, slug) // :636 — "\n" sha256(each staged .sfn-asm listed in <slug>.import-deps) |> _runtime_obj_key_with_slug(slug) // :689 — "\nobj1:" slug |> _runtime_obj_key_with_target(target_triple) // :754 — "\ntgt2:" tripleMatch that against the emit’s actual inputs — the child argv at :922
(emit --module-name <slug> --import-context <ctx_root> -o … llvm <src>) plus
the subsequent llvm_assemble_argv(ll, obj, opt_flag, []):
| Emit/compile input | Folded by |
|---|---|
| source bytes | sha256(src) |
module slug (.module <slug> is literally in the emitted text) |
obj1: |
sibling signatures threaded via --import-context |
per-dep sha256 of each staged .sfn-asm |
| the compiler binary doing the emit | compiler_identity |
| clang opt level | opt_flag |
| section/ABI flag scheme | secsplit1-strabi1 |
| target | tgt2: |
That table is the whole argument. The two specific risks raised against it:
Risk 3 — the demand-set difference. Confirmed sound. sfn test forces
runtime_demand_all() (discovery.sfn:433-451, SFN-882) while sfn build
narrows via select_runtime_sfn_sources (runtime_selection.sfn:149-171).
Narrowing is gate-table-driven per source, not import-closed — so a build’s
staged set can be a strict subset of a test’s. Two cases, both safe:
- A module whose
.import-depslist is identical under both (the common case: its imports survived narrowing) hashes the identical dep.sfn-asmbytes and gets the identical key. Its emitted bytes are identical too, because_write_runtime_sfn_import_deps(:1339) derives the list from the module’s own.importentries and only filters againststaged_slugs— a wider staged set adds nothing to a narrower module’s sidecar. A genuine shared hit. - A module that lost a dep under narrowing gets a shorter dep list, hence a
shorter key string, hence a different digest. Its emitted
.llgenuinely differs (less import context). A miss, correctly.
The same reasoning covers the SFN-800 re-export closure (:1287), which also
only ever contracts under a narrower staged set.
Risk 4 — the opt flag. Confirmed folded. runtime_object_cache_key
(build_cache.sfn:1141) returns content + "\n" + opt_flag + "\nsecsplit1-strabi1". Test’s -O0 and build’s -O2 objects can never collide.
The .o filename stem also carries the flag (_runtime_obj_stem, :145), so
the work-dir tier separates them too.
A genuine cross-tool share does exist, and it is desirable.
runtime_asm_cache_key (:776) calls
runtime_object_cache_key_with_identity(src, "", compiler_identity) — the opt
flag is deliberately empty, because a staged .sfn-asm is emitted by
emit --no-resolve-gate … native (:1187) with no opt level and no import
context. Its inputs are exactly source bytes + slug + compiler, all folded. So
sfn build and sfn test legitimately share all 180 staging artifacts.
That is a large part of the measured win and it is already unit-covered
(compiler/tests/unit/runtime_stage_shared_cache_test.sfn:209, :246, :275
pin slug separation, identity separation, and kind separation respectively).
The residual, stated rather than papered over. cache_compiler_identity
(build_cache.sfn:1414-1422) folds the binary SHA-256 only when the version
ends in .dirty. On a clean tree at a commit, build/native/.build-stamp
reads 0.10.4+dev.<hash> and the identity is that bare string — so two
different binaries built from the same commit (pass-1 build/bin/sfn and
build/bin/sfn-seedcheck) share one identity. This is pre-existing and already
applies to the .ll module cache (capsule_resolver/compile.sfn:318 uses the
same helper) and to the runtime objects on the build path. It is not created by
this change, but it is the reason §3.5’s lever is mandatory rather than
optional.
3.5 --no-test-cache and make check’s cold-suite policy
Rule: --no-test-cache resolves shared_cache_root to "".
This is the direct analogue of the build path, where --no-cache sets
build_shared_cache_root = "" (build.sfn:590-592, run.sfn:297-299) and
--clean additionally clears it for one invocation (build.sfn:606-608). The
empty root disables both the read and the write inside
_runtime_obj_shared_cache_fetch / _publish
(runtime_objs.sfn:216-217, :233-235), so nothing leaks in either direction.
What this preserves, concretely.
make check (Makefile:741) runs the seedcheck full suite as
make test NATIVE_BIN=build/bin/sfn-seedcheck TEST_BIN_CACHE_FLAGS=--no-test-cache.
With the gate, that run resolves an empty root and rebuilds every runtime object
with the seedcheck binary — byte-for-byte the behaviour it has today. The
same holds for make check’s optional CHECK_FULL_PASS1=1 leg
(Makefile:683) and for sfn dev verify, which hard-codes --no-test-cache
(dev_verify.sfn:568-575).
The stage2/stage3 fixed point is untouched: sfn selfhost spawns each stage as
build --no-cache -p compiler (cli_selfhost.sfn:319), which already zeroes
the shared root.
make test’s default TEST_BIN_CACHE_FLAGS ?= (Makefile:161) is empty, so
the local inner loop and CI’s cache-enabled shard legs get the speedup.
Why couple to --no-test-cache rather than mint a new flag. The flag’s
help text is “Bypass the per-test linked-binary cache”; the widening makes it
“this invocation neither reads nor writes any cross-invocation test artifact
cache.” That is a stronger and more defensible contract than today’s, it is
the meaning every caller already relies on (Makefile:157-160:
“the merge/seedcheck gate always cold-builds every test binary — the cache can
never mask a test-compile regression”), and it means zero call sites change. A
second flag would be a second thing to remember to pass, and the one place it
matters (make check) already passes this one. Update the flag description at
test/mod.sfn:218 to match.
Honest scoping of what “cold” already means. --no-test-cache has never
made a make check suite fully cold: the .ll module cache
(capsule_resolver/compile.sfn:318) is not gated by it and, on a clean tree,
already shares entries between the pass-1 and seedcheck binaries via the
identity residual in §3.4. This proposal does not widen that hole and does not
close it. Closing it is a separate decision — the natural fix is making
cache_compiler_identity fold the binary hash unconditionally, which would
change cache behaviour for every consumer and must not ride along here.
3.6 The import-context staging phase
Two questions, answered.
(a) Does routing the test path’s shared root make staging persist? Yes,
automatically. _stage_runtime_sfn_import_context (:1408) already takes
shared_cache_root and forwards it to _stage_one_runtime_sfn_import_context
(:1116) for both the sibling loop (:1428) and the dep-closure loop
(:1490), and to _stage_imported_platform_externs (:1547). That function
fetches at :1161 and publishes at :1220, kind "sfn-asm". It is reached
from _compile_runtime_sfn_sources:1672, which receives the root from
assemble_runtime_capsule_link_inputs:1873. So changing the one literal at
link.sfn:142 persists the 180 staging artifacts as well as the 40 objects.
This is the larger half of the win — staging is 68 serial child emits.
(b) Is parallelizing it separately shippable? Yes — and it should NOT be in scope. Three reasons:
- It is a no-op inside pool children.
_cr_resolve_jobs(capsule_emit_parallel.sfn:44-58) readsSAILFIN_BUILD_JOBSfirst, and_pool_child_envpins every pooled child toSAILFIN_BUILD_JOBS=1(pool.sfn:246-248, SFN-547) so the two fan-outs cannot nest and multiply the RAM budget. A parallel staging loop would therefore only help the parent warm and the single-file leaf path — the same two places this proposal already reduces to a CAS copy. - Its value is measured after, not before. With persistence in place the cold staging path runs once per compiler identity per host, not once per fresh scratch. Sizing the parallelization against today’s cold-every-time baseline would overstate it by roughly the post-fix miss rate.
- It is genuinely delicate. Staging is a three-phase pipeline with an
in-place-growing worklist: sibling + dep-closure staging, then
_stage_imported_platform_externs(:1547) which reads the staged.sfn-asmof pass 1 and pushes ontostaged_slugs/staged_asm_pathswhile iterating, then the.import-depswriting pass (:1508-1513) which must see the complete set. Fanning out phase 1 is tractable; the wave structure is what makes it a design of its own.
File it as a follow-up, sized from a post-fix measurement.
3.7 CI wiring
Without this, the fix is local-only: CI’s test-shard legs restore
build/cache/test-bin (ci.yml:1005, :1288, :1790) and nothing else. The
module-IR / runtime-obj restore in the shared action is explicitly gated off for
exactly these jobs — .github/actions/sailfin-build/action.yml:218 reads
if: ${{ inputs.mode != 'skip-build' }}, and the shard legs pass
mode: skip-build.
Two changes, mirroring keys that already exist:
- Produce.
build-quality.yml’stest-bin-baselinejob already restoresbuild/cache(:471-478) and runs the full suite with the cache enabled (:538-549). After this proposal that run also populates the-O0runtime-objandsfn-asmentries. Add aSave build cachestep beside the existingSave test-bin cache for PR CI(:560-565), under the samesailfin-buildcache-linux-x86_64-<seed>-<fp>-<freshness>key thebuild-qualityjob already saves at:275-279. - Consume. In the three shard legs, add an
actions/cache/restoreforbuild/cachebefore the existing test-bin restore, with thesailfin-buildcache-key +restore-keysprefix copied verbatim fromaction.yml:222-226.
Three properties make this safe and they are the same three the existing steps rely on:
- Restore-only in the shards. Saving there would occupy a key slot a
complete baseline run needs — the SFN-797 failure mode, spelled out at
build-quality.yml:566-583. - Ordering. The test-bin restore must run after the
build/cacherestore so its populated subtree overlays the (empty)test-binthe wider archive carries.action.yml:236-238already documents this ordering requirement. - Key discipline. The
sailfin-buildcache-key carries the seed version and the layout fingerprint; the looser target-only fallback stays omitted (action.yml:212-216). Do not merge this into thesailfin-testbin-prefix —action.yml:227-231records why a shared prefix lets a test-bin-only entry shadow the module-IR cache.
Size. Measured in-tree: 1.7 MB of runtime .o and 2.5 MB of
rt-import-context per target/opt combination. Against a build/cache/v2 that
is already 358 MB, the added slice is noise; no eviction policy change is
needed. sfn cache prune --max-size remains the release valve.
4. Effect & capability impact
None. Every touched function is already ![io] build-driver orchestration. No
effect signature changes; no new capability surface; no change to
canonical_effects() or to the manifest derivation. runtime_obj_cache_root()
is ![io] for the same reason test_bin_cache_root() is — one _get_env_cmd.
5. Self-hosting impact
5.1 Passes
No compiler pass changes. The change is confined to the build driver
(compiler/src/cli/commands/test/) and one helper in
compiler/src/build_cache.sfn. Lexer, parser, AST, typecheck, effect checker,
emit_native.sfn, and LLVM lowering are untouched.
5.2 Seed dependency — bundle, no seed cut
Per .claude/rules/seed-dependency.md: this is a compiler-source-only change
with no runtime-source consumer. Nothing under runtime/ calls a new
builtin or intrinsic, so the runtime carve-out does not apply. make compile
builds the new compiler from the old pinned seed, and that fresh binary carries
the new sfn test behaviour immediately. One issue, one PR, no
seed-blocker, no /pin-seed.
The regression tests in §8 drive sfn_bin_path(), which resolves to the
freshly-built binary via SAILFIN_BIN — so they observe the new behaviour in
the same PR that introduces it.
5.3 No existing fast path regresses
The shared fetch is the third rung of the probe ladder (§3.1), behind the
invocation stamp and the work-dir sidecar. Concretely: test_bin_cache_test.sfn
mints a fresh SAILFIN_BUILD_CACHE_DIR per leg but inherits one
SAILFIN_TEST_SCRATCH across all four, so legs 2–4 hit the work-dir sidecar
today and keep hitting it after the change. No file gets slower.
5.4 Concurrency
No new exposure. The parent warms sub_root before forking
(multi_file_run.sfn:104-121), so children find a populated objdir and the CAS
fetch is not on the concurrent path in the normal case. Where it is reachable
concurrently (a failed parent warm — already possible today),
_cache_atomic_copy (build_cache.sfn:853-860) publishes through a sibling
temp + rename, and _runtime_obj_cache_record writes the .key sidecar only
after the artifact is in place.
6. Alternatives considered
Do nothing; accept the 40 s. Rejected: it is paid by every cold-scratch invocation, which is every CI shard, every e2e nested runner without an inherited warm objdir, and every developer switching branches. The cost is already the dominant term for a trivial test.
A new SAILFIN_TEST_SHARED_CACHE env var instead of reusing
SAILFIN_BUILD_CACHE_DIR. Rejected — boring wins. A second root to configure,
a second thing for CI to cache, and a second way for the two tools to disagree
about where objects live. The existing variable already scopes every other cache
layer in the tree.
Gate on a new --no-runtime-cache flag rather than widening
--no-test-cache. Rejected: §3.5. The one place the gate is load-bearing
(make check) already passes --no-test-cache, and a second flag that must be
remembered fails in the unsafe direction.
Thread a CacheConfig through the test command so it resolves
cache_root(capsule_name) exactly like sfn build. Rejected for this
proposal: the test command has no root-capsule notion (discovery.sfn:205 is
per-suite), and §3.2 shows the simpler ladder is string-identical in both cases
that matter. Revisit only if the sfn cache / self-host-pin ladder is unified.
Fold this into
docs/proposals/draft-nested-build-object-cache-sharing.md. Rejected —
see §9.
Ship staging parallelization together. Rejected: §3.6(b).
7. Stage1 readiness mapping
This is a build-driver change, not a language feature; the frontend rows are N/A by construction and are marked so rather than left ambiguous.
- Parses — N/A (no syntax change)
- Type-checks / effect-checks — N/A (no new effect surface); the change must
pass
sfn checkon the touched modules - Emits valid
.sfn-asm— N/A - Lowers to LLVM IR — N/A
- Regression coverage — §8
- Self-hosts —
make compile, thenmake checkonce before merge -
sfn fmt --checkclean - Documented in
docs/status.md(build/test performance row) — no spec chapter applies
8. Test plan
All tests are *_test.sfn using sfn/test. No bash
(.claude/rules/no-bash-e2e.md). Every nested invocation hand-builds its child
env and threads its own SAILFIN_BUILD_CACHE_DIR and SAILFIN_TEST_SCRATCH,
per SFN-401 and the pool-isolation trap.
8.1 New — compiler/tests/e2e/test_runtime_obj_shared_cache_test.sfn
Model on compiler/tests/e2e/runtime_obj_shared_cache_test.sfn, which is the
build-path twin of every leg below (_build_env, _has_file_named, the
[cache]-summary assertions via sfn/strings::find).
- Cold
sfn testpopulates the shared cache. FreshSAILFIN_BUILD_CACHE_DIR- fresh
SAILFIN_TEST_SCRATCH; run a one-test fixture file. Assert the cache root then contains at least oneruntime.oand at least oneir.sfn-asm(_filename_for_kind,build_cache.sfn:733-741). This is the leg that fails onmain— today the test path writes neither.
- fresh
- A second invocation with a FRESH scratch and the SAME cache root skips the
rebuild. The core contract. Assert via the
[cache]summary thatmisses=0(or hits ≥ 1 and no runtime miss) on run 2, and additionally that run 2’s wall time is a small fraction of run 1’s is not asserted — timing assertions are flaky under the pool. Assert counters only. --no-test-cacheneither reads nor writes. Fresh cache root, run with--no-test-cache, assert the cache root contains noruntime.o. Then pre-populate the root via a plain run, wipe the scratch, re-run with--no-test-cache, and assert the run still misses (the[cache]summary shows runtime misses > 0). This is the cold-suite policy test — the one that must fail if a future change removes the gate.- A runtime source content change busts the entry. Copy
runtime/to a temp root (the shaperuntime_identity_stamp_test.sfn:234already uses), warm, edit one module, re-run with a fresh scratch, assert a miss. Guards the §3.4 key argument end to end. - A cross-opt-level probe. Run
sfn buildon a one-line program andsfn teston a one-test fixture against one shared cache root, then assert both a-O0- and a-O2-stemmed runtime object exist in the respective work dirs and that neither run reports an anomalous hit. Pins Risk 4 at the integration level; the unit-level pin already exists (§8.3).
8.2 New — compiler/tests/unit/runtime_obj_cache_root_test.sfn
![pure] where possible, against runtime_obj_cache_root_with_override:
- override empty →
"build/cache/" + build_cache_schema_version() - override set →
"<override>/" + build_cache_schema_version() - the two agree with
cache_root_from(explicit, true, xdg, home)for the self-host capsule under both conditions — the §3.2 table, pinned so a future edit to either ladder that breaks the build↔test share fails a test rather than silently halving the win.
8.3 Existing tests — verdict per file
Must keep asserting cold behaviour; must NOT be updated:
| File | Why it stays as-is |
|---|---|
compiler/tests/e2e/runtime_obj_shared_cache_test.sfn |
Build path only; unaffected. Its three legs remain the build-path contract. |
compiler/tests/e2e/test_bin_cache_test.sfn |
Asserts test_bin_* counters only. Its --no-test-cache leg (:146) asserts both counters zero — still true, and now additionally backed by §8.1 leg 3. |
compiler/tests/unit/runtime_stage_shared_cache_test.sfn |
Eight legs pinning slug / identity / kind / empty-root / torn-restore separation for the sfn-asm layer. These become more load-bearing (the test path now exercises that layer) — do not touch. |
compiler/tests/unit/runtime_obj_target_identity_test.sfn |
24 ![pure] legs on the key folds, including the opt-flag and slug adjacency. Unchanged. |
compiler/tests/unit/runtime_obj_slug_wiring_test.sfn |
Cross-slug false-hit guard. Unchanged. |
compiler/tests/e2e/dep_closure_prewarm_test.sfn |
.ll module-cache stampede, separate layer, own SAILFIN_BUILD_CACHE_DIR (:47-60). Unchanged. |
compiler/tests/e2e/build_clean_runtime_objects_test.sfn |
sfn build --clean, build path. Unchanged. |
compiler/tests/e2e/cache_command_test.sfn |
Operates on a seeded synthetic root. Unchanged. |
Needs review, likely unchanged:
| File | Check |
|---|---|
compiler/tests/e2e/dep_object_cache_test.sfn |
Uses --no-test-cache (:57) to force the cold link path so link-obj/ is created. Under the gate that run also skips the runtime CAS — which is what it already gets today from its fresh mkdtemp objdir. Leg B (unwritable link-obj, :71) is the work-dir-local object cache, a different directory from the CAS. Expect no change; confirm link-obj/*.o still appears. |
compiler/tests/e2e/runtime_identity_stamp_test.sfn |
Leg 4 (:234) copies runtime/ out of tree and asserts a content change is observed. Post-change the unmodified copy may CAS-hit entries the in-repo runtime published (same content, same slug, same identity → same key — correct by §3.4). The assertion is about observing the change, not about a cold build, so it should still pass. Verify explicitly; if it turns out to assert cold staging implicitly, thread a private SAILFIN_BUILD_CACHE_DIR rather than weakening the leg. |
compiler/tests/e2e/runtime_sfn_sources_link_consumer_test.sfn |
Asserts the canonical %Timespec* staged form (SFN-344). Now potentially served from the CAS. Content-identical by key, so the assertion holds; confirm on a warm run. |
8.4 Commands
sfn fmt --write compiler/src/build_cache.sfn compiler/src/cli/commands/test/*.sfnsfn fmt --check compiler/src/build_cache.sfn compiler/src/cli/commands/test/*.sfnsfn check compiler/src/build_cache.sfn compiler/src/cli/commands/test/link.sfn \ compiler/src/cli/commands/test/single_process_run.sfn \ compiler/src/cli/commands/test/multi_file_run.sfnmake compilebuild/bin/sfn test compiler/tests/e2e/test_runtime_obj_shared_cache_test.sfnbuild/bin/sfn test compiler/tests/unit/runtime_obj_cache_root_test.sfnbuild/bin/sfn test compiler/tests/unit/runtime_stage_shared_cache_test.sfnbuild/bin/sfn test compiler/tests/e2e/runtime_obj_shared_cache_test.sfnbuild/bin/sfn test compiler/tests/e2e/test_bin_cache_test.sfnbuild/bin/sfn test compiler/tests/e2e/dep_object_cache_test.sfnbuild/bin/sfn test compiler/tests/e2e/runtime_identity_stamp_test.sfnmake check # once, before merge — the cold-suite policy gateSpeedup verification (record in the PR body, both on one host):
# baseline and after, same command, novel test file, fresh scratch each runSAILFIN_TEST_SCRATCH=$(mktemp -d) SAILFIN_BUILD_CACHE_DIR=/tmp/sfn-cas-probe \ build/bin/sfn test <novel_test.sfn> # run 1: coldSAILFIN_TEST_SCRATCH=$(mktemp -d) SAILFIN_BUILD_CACHE_DIR=/tmp/sfn-cas-probe \ build/bin/sfn test <novel_test.sfn> # run 2: expect ~1-2 s, not ~40 s9. Relationship to existing design work
This is a new SFEP, not an amendment. Three documents touch adjacent ground; none owns this.
- SFEP-0044 (
0044-test-runner-invocation-cache.md, Implemented). Owns within-invocation amortization: the parent-warm stamp (work item A) and in-process SHA-256 (B). Its whole framing is “keys that are invocation-constant” (§1). It removes the per-child re-derivation cost; it does not and cannot remove the per-invocation build cost. Its work item C (resolver-pass sharing) is also within-invocation. Non-overlapping. This proposal composes with it cleanly — the stamp remains the first probe rung (§3.1) and is unmodified. draft-nested-build-object-cache-sharing.md(Draft). Owns within-invocation sharing of the objdir (SAILFIN_SHARED_OBJDIR, a work-dir-shaped store) so nested builds inside e2e tests can participate. Its §3.3 item 2 says of thesfn testleaf link: “No behaviour change for the pool today; this lets a nested runner participate.” That is a different seam (link.sfn:77-79, the objdir ladder) and a different axis. Not superseded — but materially resized. With cross-invocation persistence landed, a nested build’s runtime objects arrive from the CAS regardless of whether it inherits an objdir, so the draft’s already-modest claimable win (its own §2.5/§9.1 revise it down) shrinks further. Recommendation: land this proposal first, then amend that draft’s §2.5 sizing and §9.1 range against a fresh post-fix measurement before accepting it. Do not merge the two documents: one is a store-lifetime change, the other a store-location change, and conflating them would make either hard to revert.- SFEP-0040 (
0040-artifact-cache.md). Owns the cache root ladder and the self-host pin this proposal reuses. No change required; §3.2’s accepted divergence is noted against it.
10. Scope split
Minimum shippable — one issue, one PR (M).
runtime_obj_cache_root()+ pure variant (build_cache.sfn, after:1504).- The three seams (§3.3) and the
--no-test-cachegate (§3.5). - Flag help text at
test/mod.sfn:218. compiler/tests/e2e/test_runtime_obj_shared_cache_test.sfn(§8.1) andcompiler/tests/unit/runtime_obj_cache_root_test.sfn(§8.2).docs/status.mdnote.
Do not split (1) from (2): the helper has exactly one consumer and splitting
would manufacture a review cycle for no benefit (.claude/rules/seed-dependency.md,
“Don’t manufacture splits”). There is no seed gate either way (§5.2).
Bundle-or-split judgement call — the CI wiring (§3.7), 3 YAML blocks. Recommend bundling into the same PR. It is small, its keys are copied verbatim from adjacent steps, and shipping the compiler change without it means the fix demonstrably does nothing on the machine where the 40 s is paid most often. Splitting costs no seed cut, so if a reviewer prefers YAML isolated, that is a defensible S follow-up — but it must land in the same cycle or the win is invisible in CI timings and the change will look like a regression-risk with no payoff.
Follow-ups — separate issues, sized after measurement.
| Item | Size | Note |
|---|---|---|
Parallelize _stage_runtime_sfn_import_context (:1408) |
M | §3.6(b). No-op in pool children; three-phase worklist; measure post-fix first. |
| Windows warm floor (3.15 s vs Linux 0.42 s) | ? | Separate constant — per-object file copy on the fetch path. Investigate cache_copy_artifact_to on Windows. Not addressed here. |
Unconditional binary-hash fold in cache_compiler_identity (:1414) |
M | §3.4 residual. Affects every cache consumer; must not ride along. |
Unify the cache_root ladders (sfn cache vs self-host pin vs test) |
S | §3.2 accepted divergence. |
11. Risks
R1 — a future change silently drops the --no-test-cache gate and quietly
weakens make check. Mitigated by §8.1 leg 3, which asserts the gate
directly (no runtime.o written under --no-test-cache, and a miss on a
pre-warmed root). This is the single most important new test.
R2 — a cache-asserting e2e test goes vacuous. §8.3 enumerates every
candidate. All eight “must not change” files either scope their own
SAILFIN_BUILD_CACHE_DIR or assert a different layer. Adopting the guard test
proposed in draft-nested-build-object-cache-sharing.md §3.4
(cache_assertion_env_hygiene_test.sfn) would make this mechanical rather than
review-dependent; recommended, not required, and it belongs to that draft.
R3 — repo-cache pollution from nested runners. After this change, an e2e
test that spawns a nested sfn test without scoping SAILFIN_BUILD_CACHE_DIR
will write runtime-obj entries into the repo’s build/cache/v2. This is already
true of every nested sfn build for the .ll layer, the entries are
content-addressed (never wrong, only extra), and the measured slice is
~4.2 MB per identity against a 358 MB tree. Accepted.
R4 — the .dirty identity residual (§3.4). Two same-version binaries share
runtime-object entries on a clean tree. Pre-existing on the build path and the
.ll layer; not widened here; fully neutralized for make check by the §3.5
gate. Tracked as a follow-up, not a blocker.
R5 — the demand-set difference produces a lower hit rate than expected
(build’s narrowed staging yielding shorter dep lists than test’s). Not a
correctness risk (§3.4), but it could mean the build↔test sfn-asm share is
smaller in practice than the “all 180” upper bound. The §8.1 leg-2 counter
assertion measures the real number; report it in the PR body rather than
projecting it.
12. References
a3234818— “fix(cache): back runtime objects with the shared content cache (#1096) (#1101)” — the commit whose scope statement this proposal reverses.- SFEP-0044
0044-test-runner-invocation-cache.md— within-invocation amortization (the stamp). - SFEP-0040
0040-artifact-cache.md— the cache root ladder and self-host pin. - SFEP-0011
0011-ci-test-speed.md— Lever 2, the per-test binary cache. draft-nested-build-object-cache-sharing.md— adjacent; resized by this (§9).- SFN-142 (cold-suite policy), SFN-547 (
SAILFIN_BUILD_JOBS=1pin), SFN-797 (cache key occupancy), SFN-861 / SFN-870 (thesfn-asmand.oslug folds), SFN-882 (demand-driven runtime selection), SFN-545 (test-bin compiler identity). .claude/rules/seed-dependency.md— the bundle-vs-split decision applied in §5.2.