feat(plugins)!: refuse watch/rebuild capture instead of silently attesting stale content#61
Open
exo-nikita wants to merge 1 commit into
Open
Conversation
…sting stale content All three bundler plugins dedupe file capture with an instance-level #seen Set keyed by PATH and never cleared, and State.write's compare-and-skip caches are content-blind in the same way. On a watch / dev-server REBUILD where a file's bytes change, capture therefore skipped the re-read: the emitted output used the new bytes while the stasis bundle/lockfile silently kept attesting the OLD ones -- an attestation tool quietly attesting the wrong thing. Rather than track content changes across rebuilds, make watch/rebuild capture explicitly unsupported and refuse it loudly at the first rebuild signal: - StasisWebpack taps compiler.hooks.watchRun and THROWS. watchRun fires only under compiler.watch() and BEFORE the first watch build, so the whole watch session is rejected before anything is captured (a sync tap's throw propagates through the AsyncSeriesHook and fails the watcher). The done hook's now-dead `watching` immediate-write arm is gone with it. - StasisEsbuild registers an onStart guard (capture mode only) and fails the SECOND build start with returned errors -- rebuilds re-fire onStart against the same setup registrations, and the instance-level counter also catches one plugin instance reused across two esbuild.build() calls, which has the same staleness hazard. - StasisMetro throws on the serializer's SECOND invocation: a one-shot `metro build` serializes once; only a dev server (`metro start`) re-invokes it per rebuild, and Metro hands the serializer no watch signal on the first build, so the first rebuild is the earliest refusal point. Load mode is deliberately untouched: it serves immutable attested bytes back into the build (getFile re-verifies hashes on every read), so rebuilds have no drift hazard -- and Metro's load path (the companion worker transformer) legitimately runs under a dev server. stasis-core is untouched too: the compare-and-skip write caches still serve the webpack MultiCompiler multi-`done` case. Tests: webpack-defer-write's watch case now pins the watchRun refusal (throw, no write on any path); new plugins-rebuild-refusal.test.js drives esbuild's setup() with stub hooks (second onStart returns errors; a fresh setup() on a reused instance still refuses; load mode registers no guard) and metro's customSerializer with a minimal mock graph (the second serialization throws before the base serializer runs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M16Lwt8cM1azwev94UP81X
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.
The hazard
All three bundler plugins dedupe file capture with an instance-level
#seenSet that is keyed by path and never cleared (andState.write's compare-and-skip caches are content-blind in the same way). On a watch / dev-server rebuild where a file's bytes change,#seenskips the re-capture: the emitted output uses the new bytes while the stasis bundle/lockfile silently keeps attesting the old ones. Verified empirically by driving the esbuild plugin callbacks across two builds — an attestation tool quietly attesting the wrong content.The decision
Do not fix the staleness by tracking content changes across rebuilds. Instead, make watch/rebuild capture explicitly unsupported: each plugin refuses it loudly at the first rebuild signal, before anything stale can be recorded.
What now refuses, and where
stasis-plugins/src/webpack.js): capture mode tapscompiler.hooks.watchRunand throwsStasisWebpack: watch mode is not supported for capture ....watchRunfires only undercompiler.watch()and before the first watch build, so the whole watch session is rejected up front (a sync tap's throw propagates through the AsyncSeriesHook and fails the watcher). Thedonehook's now-deadwatchingimmediate-write arm is removed; single-compiler write-on-doneand the MultiCompiler deferred flush are unchanged.stasis-plugins/src/esbuild.js): capture mode registers anonStartguard that fails the second build start the esbuild-idiomatic way — returnederrors(StasisEsbuild: watch/rebuild is not supported for capture ...). Rebuilds re-fireonStartagainst the same setup registrations, and the counter lives on the plugin instance, so it also catches one instance reused across twoesbuild.build()calls (same staleness hazard).onEnd's clean-build gate then skips the write.stasis-plugins/src/metro.js): the serializer throws on its second invocation (StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shotmetro build``). A one-shotmetro buildserializes once; only a dev server (`metro start`) re-invokes it per rebuild, and Metro hands the serializer no watch signal on the first build, so the first rebuild is the earliest refusal point. The class-level KNOWN LIMITATIONS block now documents the enforced one-shot stance.Deliberately untouched
bundle=load) in all three plugins: it serves immutable attested bytes back into the build (getFilere-verifies hashes on every read), so rebuilds have no drift hazard — and Metro's load path (the companion worker transformer) legitimately runs under a dev server. The esbuild plugin registers noonStartin load mode.State.write's compare-and-skip caches stay — they still serve the webpack MultiCompiler multi-donecase.Tests
tests/webpack-defer-write.test.js: the watch case now pins the refusal —watchRunthrows and nothing writes on any path (immediate or deferred).tests/plugins-rebuild-refusal.test.js(new, dep-free like the other stub suites): drives esbuild'ssetup()with stub build hooks (secondonStartreturns the error; a freshsetup()on a reused instance still refuses; load mode registers no guard) and metro'scustomSerializerwith a minimal mock graph (second serialization throws before the base serializer runs).webpack-fs.test.js,state-write-skip.test.js,state-split-bundle.test.js). No README/doc promises watch capture, so no doc changes were needed.Ran locally (Node 22, stub-driven, all green):
plugins-rebuild-refusal,webpack-defer-write,webpack-fs,metro-fs,metro,metro-transformer,plugins,state-write-skip,state-split-bundle. The real-bundler suites (webpack*,esbuild*, cli/build-cmd) need installed devDependencies + Node 24 — covered by CI.🤖 Generated with Claude Code
https://claude.ai/code/session_01M16Lwt8cM1azwev94UP81X
Generated by Claude Code