Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion stasis-plugins/src/esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export class StasisEsbuild {
#resources
#loaders

// Build starts observed by this (capture-mode) instance, across rebuilds AND across separate
// esbuild.build()/context() calls sharing one instance. The second start is refused -- see
// the onStart hook in setup().
#captureBuildCount = 0

constructor(options = {}, { loaders } = {}) {
// A caller that owns a State (e.g. `stasis build`) can pass it directly to drive the plugin, bypassing resolvePluginState; mode follows that State's config, and a foreign-copy State fails closed via the instanceof miss.
const state = options instanceof State
Expand Down Expand Up @@ -49,9 +54,33 @@ export class StasisEsbuild {
return 'stasis'
}

setup = ({ onResolve, onLoad, onEnd, resolve }) => {
setup = ({ onResolve, onLoad, onStart, onEnd, resolve }) => {
if (!this.#state) return // noop plugin

// Watch/rebuild capture is EXPLICITLY UNSUPPORTED -- fail the second build loudly before it
// captures anything. Capture's dedupe is keyed by PATH, not content (#seen below, plus
// State.write's compare-and-skip caches), so on a rebuild where a file's bytes changed the
// plugin would skip re-capture: the emitted output would use the new bytes while the
// bundle/lockfile silently kept attesting the OLD ones. Rather than track content changes
// across rebuilds, refuse them outright. esbuild rebuilds (watch mode, context.rebuild())
// re-fire onStart/onEnd against the SAME setup registrations, so an instance-level counter
// sees every start; it also covers reusing one plugin instance across two esbuild.build()
// calls, which has the same staleness hazard. Returning errors from onStart is the
// esbuild-idiomatic hard failure (the build reports them and onEnd's clean-build gate then
// skips the write). Load mode registers nothing here: it serves immutable attested bytes,
// so rebuilds have no drift hazard.
if (!this.#state.config.loadBundle) {
onStart(() => {
this.#captureBuildCount += 1
if (this.#captureBuildCount === 1) return undefined
return {
errors: [{
text: 'StasisEsbuild: watch/rebuild is not supported for capture -- run a one-shot build (rebuilds would silently attest stale content)',
}],
}
})
}

// The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
// Standalone and sidecar States have no such hook, so the plugin writes them when the
// build finishes -- but only on a clean build, otherwise a partial bundle/lockfile
Expand Down
43 changes: 35 additions & 8 deletions stasis-plugins/src/metro.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,19 @@ function metroDefaultSerializer() {
// hook) is a lower-coverage fallback -- see its note.
//
// KNOWN LIMITATIONS (Metro-specific coverage gaps -- documented, not silently ignored):
// - Child-process capture is best-effort and one-shot-build only. A worker KILLED by signal
// - Capture is ONE-SHOT ONLY, enforced: a dev server (`metro start`) re-invokes the serializer
// per rebuild, and the SECOND invocation throws (see #run) -- capture's path-keyed dedupe
// would otherwise keep attesting a file's first-build bytes after an edit changed them.
// Metro gives the serializer no watch signal on the first build, so the first rebuild is the
// earliest refusal point. Load mode (the companion worker transformer) is unaffected and
// runs fine under a dev server -- it only reads immutable attested bytes.
// - Child-process capture is best-effort within that one shot. A worker KILLED by signal
// before its exit hook (jest-worker forceExit / pool overflow) loses its shard SILENTLY at
// capture time -- a later frozen run still catches the gap (fail-closed), but nothing warns
// during capture. And it relies on the loader's exit-time write: a dev server (`metro start`,
// pool kept warm, killed by signal) never gets there, and standalone (no stasis loader) Metro
// mints no shard dir -- neither produces a toolchain-complete lockfile. Use a one-shot
// `metro build` under `stasis run --child-process` to capture, then verify with `--lock=frozen`.
// during capture. And it relies on the loader's exit-time write, which standalone (no stasis
// loader) Metro never reaches -- it mints no shard dir and produces no toolchain-complete
// lockfile. Use a one-shot `metro build` under `stasis run --child-process` to capture, then
// verify with `--lock=frozen`.
// - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed
// by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel
// scales/files arrays), NOT as separate graph modules, so only the base file is attested
Expand All @@ -130,6 +136,11 @@ export class StasisMetro {
#state
#resources

// Whether this instance already served one capture (#run). The serializer fires once per
// one-shot `metro build`; a dev server re-invokes it per rebuild, which capture refuses --
// see the guard in #run.
#ran = false

constructor(options = {}) {
const { state } = resolvePluginState('StasisMetro', options, process.cwd())
this.#state = state // null when the plugin should be inert (Rule 7)
Expand Down Expand Up @@ -192,15 +203,31 @@ export class StasisMetro {
'serializer config in load mode, or use bundle=add/replace/frozen here to capture.'
)
}
// Watch/dev-server capture is EXPLICITLY UNSUPPORTED -- refuse the second serialization
// loudly before it captures anything. Capture's dedupe is keyed by PATH, not content
// (#seen below, plus State.write's compare-and-skip caches), so on a rebuild where a
// file's bytes changed the plugin would skip re-capture: the emitted bundle would use the
// new bytes while the stasis bundle/lockfile silently kept attesting the OLD ones. The
// serializer fires exactly once per one-shot `metro build`; only a dev server
// (`metro start`) re-invokes it, and Metro hands the serializer no watch-mode signal on
// the FIRST build, so the first rebuild is the earliest point capture can refuse. (Load
// mode has no such hazard and legitimately runs under a dev server -- it lives in the
// companion worker transformer, rejected above, which only READS immutable attested bytes.)
if (this.#ran) {
throw new Error(
'StasisMetro: watch/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`'
)
}
this.#ran = true
this.#capture(graph, preModules)

// The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
// Standalone and sidecar States have no such hook, so the plugin writes them here. The
// serializer only runs once Metro has successfully built + transformed the whole graph
// (a failed build throws before serialization), so this is the clean-build equivalent of
// webpack's `stats.hasErrors()` / esbuild's `result.errors.length` gate. In watch / dev
// mode it fires on every rebuild; State.write's per-artifact compare-and-skip makes the
// unchanged-graph case a cheap no-op.
// webpack's `stats.hasErrors()` / esbuild's `result.errors.length` gate -- and it runs at
// most once per instance (the rebuild guard above), so this write is the one-shot build's
// single flush.
if (this.#state !== State.preload) this.#state.write()
}

Expand Down
51 changes: 31 additions & 20 deletions stasis-plugins/src/webpack.js
Original file line number Diff line number Diff line change
Expand Up @@ -484,35 +484,45 @@ export class StasisWebpack {
})
})

// Watch-mode capture is EXPLICITLY UNSUPPORTED -- refuse it loudly before anything is
// captured. Capture's dedupe is keyed by PATH, not content (#seen above, plus State.write's
// compare-and-skip caches), so on a watch rebuild where a file's bytes changed the plugin
// would skip re-capture: the emitted output would use the new bytes while the bundle /
// lockfile silently kept attesting the OLD ones. Rather than track content changes across
// rebuilds, refuse watch outright. compiler.hooks.watchRun fires ONLY under
// compiler.watch() (both webpack 4 and 5) and BEFORE the first watch build's compilation,
// so this rejects the whole watch session up front, not just the second build. watchRun is
// an AsyncSeriesHook: a sync `.tap` callback that throws propagates through the hook's call
// chain and fails the watcher -- exactly the loud refusal we want, so keep it a plain tap.
// Load mode is deliberately untouched (see #applyLoadMode): it serves immutable attested
// bytes back into the build, so a dev-server rebuild has no drift hazard there.
compiler.hooks.watchRun.tap('Stasis', () => {
throw new Error(
'StasisWebpack: watch mode is not supported for capture -- ' +
'run a one-shot build (watch rebuilds would silently attest stale content)'
)
})

// The stasis preload owns its own write via beforeExit/exit hooks (stasis-core/hooks.js).
// Standalone and sidecar States have no such hook, so the plugin writes them when the
// compiler finishes -- but only on a clean build. A failed compilation has incomplete
// state, and lock=replace would otherwise overwrite a good lockfile with a partial one.
if (this.#state !== State.preload) {
// Watch-mode detection. compiler.hooks.watchRun fires ONLY under compiler.watch() (both
// webpack 4 and 5) and always before that build's `done`, so the flag is set in time.
// The flag is per-compiler (this closure), which is what we want: each compiler reports
// its own run kind even when one shared plugin instance drives several.
let watching = false
compiler.hooks.watchRun.tap('Stasis', () => { watching = true })
compiler.hooks.done.tap('Stasis', (stats) => {
if (stats.hasErrors()) return
// Write immediately when the bundle cannot grow across `done`s:
// - watch mode: a watcher keeps the event loop alive, so a deferred flush would never
// fire; per-rebuild freshness is the point, and State.write's compare-and-skip keeps
// an unchanged rebuild a ~no-op; and
// - a single compiler: only one `done`, so there is no N-write amplification and no
// reason to give up the old write-by-`done` durability (the bundle is on disk by the
// time compiler.run()'s callback resolves).
// Defer ONLY the multi-compiler one-shot case. compiler.hooks.done fires once PER CHILD
// COMPILER, so a MultiCompiler with N targets sharing this one State's growing bundle
// would otherwise pay N brotli + writeFileSync passes over an ever-larger payload (the
// Write immediately for a single compiler: only one `done`, so there is no N-write
// amplification and no reason to give up the old write-by-`done` durability (the
// bundle is on disk by the time compiler.run()'s callback resolves). Defer ONLY the
// multi-compiler one-shot case. compiler.hooks.done fires once PER CHILD COMPILER, so
// a MultiCompiler with N targets sharing this one State's growing bundle would
// otherwise pay N brotli + writeFileSync passes over an ever-larger payload (the
// dominant, super-linear cost). Deferral collapses those into ONE write of the final
// bundle at process exit -- mirroring how the preload writes (hooks.js). esbuild / metro
// bundle at process exit -- mirroring how the preload writes (hooks.js). Watch mode
// never reaches this hook: capture refuses it at watchRun above. esbuild / metro
// are deliberately NOT changed: their write hook fires once per build (no per-child
// amplification), and stasis build reads esbuild output in-process, so their synchronous
// write is load-bearing in a way webpack's per-child `done` write is not.
if (watching || this.#captureApplyCount === 1) this.#state.write()
if (this.#captureApplyCount === 1) this.#state.write()
else this.#deferWrite()
})
}
Expand All @@ -527,8 +537,9 @@ export class StasisWebpack {
// a process that never exits normally -- one that stays alive after building and is then
// killed by a signal (SIGINT/SIGTERM/SIGKILL), which bypass both beforeExit and exit -- would
// not flush. That is the same capture-then-exit assumption the preload already relies on
// (hooks.js) and metro documents; the common build-and-exit and watch (`--watch`, dev-server)
// flows are unaffected, and a single-compiler build never reaches here (it writes on `done`).
// (hooks.js) and metro documents; the common build-and-exit flow is unaffected, a
// single-compiler build never reaches here (it writes on `done`), and watch (`--watch`,
// dev-server) capture is refused outright at watchRun (see #applyCaptureMode).
#deferWrite() {
this.#deferredWriteDirty = true
if (this.#deferredWriteArmed) return
Expand Down
131 changes: 131 additions & 0 deletions tests/plugins-rebuild-refusal.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Watch/rebuild CAPTURE is explicitly unsupported across the bundler plugins: capture dedupes
// files by PATH, not content (each plugin's instance-level `#seen` Set, plus State.write's
// compare-and-skip caches), so a watch/dev-server rebuild where a file's bytes changed would
// skip re-capture -- the emitted output would use the new bytes while the stasis
// bundle/lockfile silently kept attesting the OLD ones. Rather than track content changes
// across rebuilds, each plugin refuses the rebuild loudly at its first signal:
// - webpack throws from its watchRun tap, before the FIRST watch build (covered alongside
// the write-timing cases in webpack-defer-write.test.js);
// - esbuild fails the SECOND build start from onStart -- rebuilds re-fire onStart against
// the same setup registrations, and an instance-level counter also catches one plugin
// instance reused across two esbuild.build() calls (same staleness hazard);
// - metro 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.
// Load mode is deliberately untouched: it serves immutable attested bytes back into the
// build, so rebuilds have no drift hazard there.
//
// No real bundlers here (they aren't dependencies of this repo): the esbuild plugin's setup()
// is driven with stub build hooks and the metro plugin's customSerializer is fed a minimal
// Metro-shaped graph -- the same stub approach as webpack-defer-write.test.js /
// metro-fs.test.js.

import { test } from 'node:test'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { State } from '@exodus/stasis-core/state'
import { StasisEsbuild } from '@exodus/stasis-plugins/esbuild'
import { StasisMetro } from '@exodus/stasis-plugins/metro'

// Run `mk` chdir'd into a throwaway project dir (resolvePluginState anchors standalone plugin
// States at process.cwd(), like webpack-defer-write.test.js's withPlugin), restore cwd, and
// clean the dir up after the test. lock='none' + bundle='add' is the valid preload-less
// capture config the plugins are constructed with below.
const inProjectDir = (t, mk) => {
const dir = mkdtempSync(join(tmpdir(), 'stasis-rebuild-'))
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fx', version: '0.0.0' }))
const cwd = process.cwd()
process.chdir(dir)
let made
try {
made = mk(dir)
} finally {
process.chdir(cwd)
}
t.after(() => rmSync(dir, { recursive: true, force: true }))
return made
}

// Drive StasisEsbuild.setup() with stub build hooks, collecting the registered callbacks by
// kind -- what esbuild's plugin runner does once per build/context creation. A rebuild does NOT
// re-run setup(); it re-fires the collected onStart/onEnd callbacks, which the tests below do
// by hand.
const setupStub = (plugin) => {
const cbs = { onStart: [], onEnd: [], onResolve: [], onLoad: [] }
plugin.setup({
onStart: (fn) => cbs.onStart.push(fn),
onEnd: (fn) => cbs.onEnd.push(fn),
onResolve: (_filter, fn) => cbs.onResolve.push(fn),
onLoad: (_filter, fn) => cbs.onLoad.push(fn),
resolve: async () => ({ errors: [], warnings: [] }),
})
return cbs
}

const fireStarts = (cbs) => cbs.onStart.map((fn) => fn()).find((r) => r !== undefined)

test('esbuild: the second build start is refused in capture mode (watch/rebuild)', (t) => {
const plugin = inProjectDir(t, (dir) =>
new StasisEsbuild({ lock: 'none', bundle: 'add', bundleFile: join(dir, 'sources.br') }))
const cbs = setupStub(plugin)
t.assert.equal(cbs.onStart.length, 1, 'capture registers the rebuild guard')

t.assert.equal(fireStarts(cbs), undefined, 'the first build start proceeds')
// esbuild fails a build when a plugin's onStart returns errors -- the refusal fires before
// the rebuild can (not) re-capture anything, and onEnd's clean-build gate then skips write().
const refused = fireStarts(cbs)
t.assert.equal(refused.errors.length, 1)
t.assert.match(refused.errors[0].text,
/StasisEsbuild: watch\/rebuild is not supported for capture -- run a one-shot build/)
})

test('esbuild: a fresh setup() on the same instance still refuses (instance reuse across builds)', (t) => {
// Two esbuild.build() calls sharing ONE plugin instance each run setup() afresh, so a
// per-setup counter would reset and miss the hazard; the counter must live on the instance.
const plugin = inProjectDir(t, (dir) =>
new StasisEsbuild({ lock: 'none', bundle: 'add', bundleFile: join(dir, 'sources.br') }))
t.assert.equal(fireStarts(setupStub(plugin)), undefined, 'first build() proceeds')
const refused = fireStarts(setupStub(plugin))
t.assert.match(refused.errors[0].text, /StasisEsbuild: watch\/rebuild is not supported for capture/)
})

test('esbuild: load mode registers no rebuild guard', (t) => {
// Load serves immutable attested bytes (getFile re-verifies hashes on every read), so
// rebuilds have no drift hazard and must stay permitted -- no onStart is registered at all.
const plugin = inProjectDir(t, (dir) => {
const bundleFile = join(dir, 'sources.br')
// Mint an (empty) bundle to load: a capture State's write() emits it.
new State(dir, { lock: 'none', bundle: 'add', bundleFile }).write()
// The plugin accepts a caller-owned State directly (the `stasis build` path).
return new StasisEsbuild(new State(dir, { lock: 'none', bundle: 'load', bundleFile }))
})
const cbs = setupStub(plugin)
t.assert.equal(cbs.onStart.length, 0, 'load mode must not refuse rebuilds')
t.assert.ok(cbs.onResolve.length > 0, 'sanity: the plugin did register its load-mode hooks')
})

test('metro: the second serialization is refused in capture mode (dev-server rebuild)', (t) => {
let baseCalls = 0
const serialize = inProjectDir(t, (dir) => {
// childProcess mirrors `stasis run --child-process`, which StasisMetro requires for any
// capture-that-writes (Metro transforms in worker processes; see the constructor assert).
const plugin = new StasisMetro({
lock: 'none', bundle: 'add', bundleFile: join(dir, 'sources.br'), childProcess: true,
})
return plugin.customSerializer(() => { baseCalls += 1; return '// bundle output' })
})
// The minimal Metro ReadOnlyGraph shape the plugin reads: `dependencies` (Map<absPath,
// Module>) and `entryPoints` (Set<absPath>) -- both empty here, capture itself is not the
// point. preModules: none (like the experimental-hook path).
const graph = { dependencies: new Map(), entryPoints: new Set() }

t.assert.equal(serialize('/app/entry.js', [], graph, {}), '// bundle output',
'the one-shot build serializes through the base serializer')
t.assert.throws(
() => serialize('/app/entry.js', [], graph, {}),
/StasisMetro: watch\/dev-server rebuilds are not supported for capture -- use a one-shot `metro build`/,
'a dev-server re-invocation must throw'
)
t.assert.equal(baseCalls, 1, 'the refusal fires before the base serializer produces output')
})
Loading