Repository navigation
Validate kept unit state against GHC's recompilation fingerprints - #43
Draft
jun-mercury wants to merge 2 commits into
Draft
jun-mercury wants to merge 2 commits into
jun-mercury wants to merge 2 commits into
Conversation
A server that outlives a build keeps the first build's view of a unit and serves it to the next: the unit env in the make state's HUG (insertUnitEnv, never evicted), the module graph nodes merged old-wins (mergeModuleGraphNodes keeps the old ModSummary with its preprocessed source), the interpreter's loader state (never unloaded) and the HPT. Nothing in the suite feeds a second build to the same WorkerState: Test.Env.newResumeSessionEnv discards it, "simulating a worker restart", which is buck-proxy's one-worker-per-build lifecycle (socket named after BUCK_BUILD_ID, killed on SIGTERM unless --remain). A worker kept alive with --remain, or a pool of servers on remote execution, violates that assumption. StaleUnitTest feeds one WorkerState two builds of one unit with a change between them and compares the second build's interface with a fresh WorkerState's, exports and ABI hash. Four sequences, each red today: - a module's source changes, same args: the old source is recompiled - the unit's args gain -DFOO: the module recompiles with the old flags - a TH splice reads a module whose value changed: the splice sees the old value, the interpreter keeps the first build's code loaded - a module joins a known unit and an existing module imports it: the importer recompiles from its old source The cases are wrapped in stillStale, which passes while the inner assertions fail and prints their report to stderr, and fails once a sequence comes out fresh, the signal to delete the wrapper. Set expectFailures to False to see the failures on their own terms. The harness runs the legacy metadata path (no --dep-units-static). On the static path the same fourth sequence panics instead, "Could not find ModSummary in the module graph" for the new module, reproduced on a real unit outside the suite. Assisted-by: Claude:claude-fable-5-1
A server that outlives one build served the next build from memory by unit id alone. hasUnit and compareUnits skipped a known id without reading its plan, mergeModuleGraphNodes kept the old ModSummary when a metadata request produced a new one, prepareHmiLoader trusted every HPT entry, and the Interp was never dropped, so a TH splice kept running the first build's code because getLinkDeps excludes modules it has already loaded. GHC's --make runs checkOldIface before it trusts an interface: mi_src_hash, mi_flag_hash from fingerprintDynFlags, and the ABI hashes in mi_usages. The worker dropped that check when it started keeping state across requests. This puts it back at the granularity the state has. The unit id stays the key into the HUG. A UnitFingerprint beside each stored unit is the validity check: fingerprintDynFlags, fingerprintOptFlags and fingerprintHpcFlags over the unit's parsed args, plus the package flags, package databases and GHC libdir GHC leaves out of them, and the plan's module set. The plan and args file hashes are a fast path; when they match nothing is decoded. A changed fingerprint or module set evicts the unit (HomeUnitEnv, graph nodes, bytecode locks, extra-lib request, fingerprint, interpreter) and restores it from the plan as for an absent unit. Two roots differing only in -odir and -hidir agree on the fingerprint, as they do for GHC. Per interface, the loader compares a 1 KiB header read with the entry's mi_src_hash and the .hash sidecar --abi-out wrote with its mi_mod_hash, and reloads on either mismatch. The source hash matters because a value-only edit keeps the ABI hash and a splice reads the value. Fail closed: no stored fingerprint, an unreadable plan, args file or interface, or a missing sidecar all evict or reload. The interpreter is dropped whole when a loaded module is about to be recompiled or reloaded, since GHC has no partial unload for bytecode; the next splice relinks its closure once. Each eviction, reload and drop logs one ghc-worker: line with its reason. Cost, measured on the cards unit Mercury.Sentry.Constants through one server on an idle machine, two rounds of the A to D sequence each: warm -c requests average 124 ms before and 132 ms after (14 requests each, spread 88 to 174 ms); -M requests 115 ms before and 111 ms after. A request whose plan and args bytes match pays two file hashes for the unit and, per dependency in the closure, a 1 KiB interface read and a sidecar read. Assisted-by: Claude:claude-fable-5-1
jun-mercury
added a commit
to jun-mercury/ghc-persistent-worker
that referenced
this pull request
Oct 12, 2026
…de instead of failing The two Template Haskell cases of StaleUnitTest pass on the repository's default build. They reload a stale module through loadHmiFull, and with lazyByteCode on by default a build without -flinkables threw "features. lazyByteCode is on, but buck-worker-internal is not compiled with -flinkables" (run 35776330649 of MercuryTechnologies#43). Deferring bytecode needs what -flinkables compiles in, so without it the flag cannot apply and the worker loads the cached bytecode, which is the flag-off behaviour. mwb builds with -flinkables (nix/deps-manual/buck-worker-internal.nix), so the worker mwb runs is unchanged. Assisted-by: Claude:claude-opus-5-5 (cherry picked from commit 97560f2)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacks on #42 and carries its test commit beneath the fix. Read it as the diff from #42.
Why
A server that outlives one build serves the next build from memory. That memory is the unit's
HomeUnitEnvinmake.hug, its module graph nodes, the HPT entries, and the interpreter's loaded code. Until now the unit id alone decided whether that memory was reused.hasUnitandcompareUnitsskipped a known id without reading its plan,mergeModuleGraphNodeskept the oldModSummarywhen a metadata request produced a new one,prepareHmiLoadertrusted every HPT entry, and theInterpwas never dropped, so a TH splice kept running the first build's code because GHC'sgetLinkDepsexcludes modules it has already loaded. GHC's--makerunscheckOldIfacebefore it trusts an interface, and that check comparesmi_src_hash,mi_flag_hashfromfingerprintDynFlags, and the ABI hashes inmi_usages. The worker dropped that check when it kept state across requests. This change puts it back, at the granularity the worker's state has.The unit id stays the key. A new
UnitFingerprintbeside each stored unit is the validity check, the way Bazel'sWorkerKeykeeps the worker files' hash for validation and not for identity.Scope
Types.State.MakegainsUnitFingerprint(flags,modules,planFiles) andMakeState.unitFingerprints.Internal.State.MakegainsevictUnit,linkedInInterp,dropInterpIfLinked,storeUnitFingerprintandensureInterp, which replacesloadStateCompile.Internal.State.withStaterestores the HUG and module graph, runs the request's setup, then picks the interpreter, so an eviction or reload during setup gives the current session a fresh loader.Internal.Cache.MetadatagainsflagsFingerprintandvalidateStoredUnit, stores a fingerprint wherever a unit entershug, and revalidates known units inloadCachedDepUnits.Internal.Cache.HptgainsreadIfaceHeaderandinterfaceStale, checks them inprepareHmiLoader, and validates the unit inloadHomeUnit.Internal.Metadata.prepareMetadataSessionevicts a known unit before a metadata request redefines it.Internal.Session.withGhcMakeModuledrops the interpreter when the target module's old code is loaded.ghc-worker/test/StaleUnitTest.hsloses thestillStalewrapper, so the four sequences assert directly, and gains three tests, for the flag fingerprint, the header reader and the fail-closed sidecar check.The client, buck-proxy and the caps are not touched.
What is checked, and when
At request start, for the requesting unit named by
--home-unit, and for its dependency units when the unit is restored, the plan and args files are hashed and compared with the hashes stored at restore time. If they differ, the plan is decoded and the args parsed, and GHC'sfingerprintDynFlags,fingerprintOptFlagsandfingerprintHpcFlagsare computed over the parsed flags, together with the package flags and databases GHC leaves out of them (the worker builds the unit state from those) and the GHC libdir. A different flag fingerprint or module set evicts the unit. Eviction drops itsHomeUnitEnv, its module graph nodes, its bytecode locks, its extra-library request, its fingerprint, and the interpreter. The unit is then restored from the plan as if it had never been seen. Two execution roots that differ only in-odirand-hidirhash differently at the byte level and then agree on the fingerprint, so they do not evict each other.Per interface, in the loader path, a 1 KiB prefix of the
.higives the header's source hash, which is compared with the entry'smi_src_hash; the.hashsidecar--abi-outwrote is compared with the entry'smi_mod_hash. Either mismatch reloads the entry. The source hash is needed because a value-only edit keeps the ABI hash, and a TH splice reads the value.Fail closed. No stored fingerprint, an unreadable plan or args file, an unreadable interface, or a missing
.hashall count as not trusted and evict or reload. Each eviction, reload and interpreter drop is oneghc-worker:line through the request log with its reason.Tradeoffs
-odiror-hidirmove, which is every pair of execution roots on one host. The bytes are kept as the fast path and GHC's fingerprint decides when they differ.readBinIfaceplusinitModDetailsper dependency per request, against the 1 KiB header read and one sidecar read this change does. It is the alternative worth prototyping if the validation proves fiddly in production.:add-style path into the module graph. Whole-unit eviction is coarser and is the only granularity that handles a module added under a known unit today.-fprefer-byte-codenone are loaded.Blast radius
Every compile and metadata request through a long-lived server. When the plan and args bytes match, a request pays two file hashes for the unit and, per dependency in the closure, one 1 KiB read and one sidecar read. When they do not match, one plan decode and one args parse. A unit evicted in error is restored from its plan, which is what a fresh server does for every unit, so the failure mode of a wrong fingerprint is a slower build and not a stale one. Without the fix a server that survives a build hands the next build stale interfaces, stale flags and stale TH results, as #42 shows.
Verification
The test suite ran as
WT=<worktree> TEST_PATTERN=ALL nix build --impure -f test-stale.nixagainst lab's GHC 9.10.3. The builder is a scratch file that overridesbuck-worker-typesacross the package set so the newMakeStatefield is seen.The four sequences from #42 assert directly. The flag-hash and fail-closed cases are unit tests of
flagsFingerprintandinterfaceStale, because the in-process harness compiles without--abi-outand never writes a.hashsidecar, and a two-root plan-flow sequence needs a real server. The shell harnesses below provide that server.run-stale-seq.shdrives one server through six builds of unituand compares the ABI hash of each produced.dyn_hiwith a one-shotghc -cof the same tree. Upstream 85f41f9 had builds C, E and F wrong, see #42.The client's stderr carried
ghc-worker: evict unit u: redefined by a metadata requestonce, in build B of the legacy run. That eviction is what the four #42 cases depend on.run-th-stale.shgivesmth = I# 1#in build A andmth = I# 7#in build B, afterk = 7,-c Kand-c MTHthrough the same server. The one-shot reference isI# 7#, and upstream said1#. The client's stderr carriedghc-worker: drop interpreter: K is loaded and about to change.cards-ab.shsends the cards unitsrc/Mercury/Sentry/Constantsthrough one server in cases A to D and compares theFingerprints.dyn_hiABI with one-shot. The run used a scratch copy that strips a stray-DSENTRY_V2an earlier run had left in the checkout's args file. Upstream's earlier pass on case C was that leak.The client's stderr carried
ghc-worker: evict unit root-src-Mercury-Sentry-Constants-Constants: module set changed: +Mercury.Sentry.Constants.Extra. Case C evicted nothing, because the cards-Mruns with--dep-units-static, so the unit is transient and the-crestores it from the plan with the new args.Per-request cost comes from the same cards sequence on an idle machine, two rounds each, with the fork's client behind a timing wrapper. Warm
-crequests: 124 ms → 132 ms mean, 14 requests each, spread 88 to 174 ms.-Mrequests: 115 ms → 111 ms. A fast-path request reads two file hashes for the unit (plan and args) and, per dependency in the closure, a 1 KiB interface prefix and the.hashsidecar. A miss decodes the plan and parses the args once.The remote pool check is out of scope for this PR, because the pool would need a republished closure and a new pool for this binary, both shared state. The local cards check stands in for it.
Unverified at HEAD
The HEAD lineage (
fix/validate-unit-state-across-builds, on #42'stest/stale-unit-across-builds) is the cherry-pick of the 85f41f9 commit with conflicts resolved by hand and no build, since HEAD needs two GHC patches lab's 9.10.3 lacks. The resolution changed three things.evictUnittakesuseIncrModGraphand resetsmoduleGraphStatetoemptyEModuleGraphbeforerebuildModuleGraph, because the incremental reachability index only adds keys.loadCachedHomeUnitkeeps HEAD's node-based store and its extraBool. The import lists are the union of both sides. Everything tested above ran onfix/validate-unit-state-across-builds-85f41f93(same commit on 85f41f9, pushed to the fork for reference).A known limit stays. A compile node's stored
ModSummary(legacy-Mgraph withfixedNodesCache = False) can still hold an old source buffer when no metadata request reaches the state between builds; production uses fixed nodes, which re-summarise from disk.Assisted-by: Claude:claude-fable-5-1