diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md index 3adb29736..52067ce11 100644 --- a/docs/architecture/rsc-runtime-workbench.md +++ b/docs/architecture/rsc-runtime-workbench.md @@ -135,6 +135,7 @@ examples/ src/build/serialize-definition.ts src/definition.ts src/dev/definition-entry.ts + src/dev/environment-checkpoint-store.ts src/dev/generation-materializer.ts src/dev/inspection-security.ts src/dev/invocation-worker.ts diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index 0f874bca8..64b944993 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -9,10 +9,8 @@ import { Layers, pluginRSC } from 'rsbuild-plugin-rsc'; import { emitRuntimeArtifacts } from './src/build/emit-artifacts.js'; export interface RscRuntimeCompileSnapshot { - readonly acceptCompilerAssetCheckpoint?: () => void; readonly attemptId: string; readonly candidateId: string; - readonly discardCompilerAssetCheckpoint?: () => void; readonly preparedRevision: string; readonly rscCohortRevision: number; readonly sourceRevision: string; @@ -20,6 +18,13 @@ export interface RscRuntimeCompileSnapshot { export type RscRuntimeActivationOutcome = 'activated' | 'failed'; export type RscRuntimeCompileFailureKind = 'provider-lifecycle' | 'source-build'; +export type RscRuntimeCompileEnvironmentName = 'app' | 'rsc' | 'widget'; +export type RscRuntimeCompileEnvironmentHashes = Readonly>; + +const compileEnvironmentNames: readonly RscRuntimeCompileEnvironmentName[] = Object.freeze(['app', 'rsc', 'widget'] as const); + +const isCompileEnvironmentName = (value: string): value is RscRuntimeCompileEnvironmentName => + (compileEnvironmentNames as readonly string[]).includes(value); export interface RscRuntimeRsbuildConfigOptions { readonly compilerRoot?: string; @@ -36,12 +41,24 @@ export interface RscRuntimeRsbuildConfigOptions { capture(input: { readonly attemptId: string; readonly cohortChanged: boolean; + readonly environmentHashes: RscRuntimeCompileEnvironmentHashes; readonly hasErrors: boolean; readonly sourceRevision: string; }): Promise; /** Queues provider activation but never blocks the Rsbuild compile hook. */ enqueue(snapshot: RscRuntimeCompileSnapshot): unknown; failAttempt(attemptId: string, error: unknown, kind: RscRuntimeCompileFailureKind): void; + /** + * Stages an immutable checkpoint of one environment's completed output + * root. Awaited inside the environment compiler's `done` hook, where + * Rsbuild blocks that compiler's next write cycle until staging + * finishes, so the copy reads a quiescent output root. + */ + stageEnvironmentCheckpoint(input: { + readonly distPath: string; + readonly environmentName: RscRuntimeCompileEnvironmentName; + readonly statsHash: string; + }): Promise; }>; } @@ -100,6 +117,25 @@ const runtimeCompileObserverPlugin = ( return { name: 'agent-bundle:rsc-runtime-compile-observer', setup(api) { + api.onAfterEnvironmentCompile(async ({ environment, stats }) => { + // Immutable per-environment staging (#74): Rsbuild awaits this hook + // inside the environment compiler's Rspack `done` tap, so the copy + // reads that environment's completed writeToDisk root before its + // next compile can rewrite it. Failed compilations, unexpected + // environment names, and missing hashes stage nothing here; the + // global after-compile hook is the loud failure path for those, and + // rejecting this hook instead would skip that dispatch entirely and + // strand the FIFO attempt pairing. + if (stats === undefined || stats.hasErrors()) return; + const name = environment.name; + if (!isCompileEnvironmentName(name)) return; + if (typeof stats.hash !== 'string' || stats.hash.length === 0) return; + await observer.stageEnvironmentCheckpoint({ + distPath: environment.distPath, + environmentName: name, + statsHash: stats.hash, + }); + }); api.onBeforeDevCompile(() => { // Rsbuild documents global hook order, but not one before/after pair // per MultiCompiler cohort. FIFO pairing is only empirical in 2.2.1; @@ -129,13 +165,13 @@ const runtimeCompileObserverPlugin = ( return; } const json = stats.toJson({ all: false, children: true, hash: true }); - const cohortHashes = new Map<'rsc' | 'widget', string>(); + const cohortHashes = new Map(); // Rspack documents optional Stats child names, but Rsbuild does not // promise they equal environment keys. We explicitly name each // compiler below; the name-based cohort match is otherwise only an // empirical Rsbuild 2.2.1 behavior. for (const child of json.children ?? []) { - if (child.name !== 'rsc' && child.name !== 'widget') continue; + if (child.name === undefined || !isCompileEnvironmentName(child.name)) continue; if (typeof child.hash !== 'string' || child.hash.length === 0) { throw new Error(`RSC runtime ${child.name} compilation has no hash.`); } @@ -144,14 +180,22 @@ const runtimeCompileObserverPlugin = ( } cohortHashes.set(child.name, child.hash); } - if (cohortHashes.size !== 2 || !cohortHashes.has('rsc') || !cohortHashes.has('widget')) { - throw new Error('RSC runtime compile requires exactly one RSC and widget stats child.'); + if (cohortHashes.size !== compileEnvironmentNames.length) { + throw new Error('RSC runtime compile requires exactly one RSC, widget, and App stats child.'); } + const environmentHashes = Object.freeze(Object.fromEntries( + compileEnvironmentNames.map((name) => [name, cohortHashes.get(name) as string]), + )) as RscRuntimeCompileEnvironmentHashes; + // The App environment ships through its own dev-server surface, so + // only the rsc and widget children define the source revision that + // decides whether a new runtime generation is needed. The App child + // hash still selects which staged App checkpoint joins the cohort. const hashes = (['rsc', 'widget'] as const).map((name) => [name, cohortHashes.get(name) as string]); const sourceRevision = createHash('sha256').update(JSON.stringify(hashes)).digest('hex'); snapshot = await observer.capture({ attemptId, cohortChanged: sourceRevision !== capturedCohort?.sourceRevision, + environmentHashes, hasErrors: false, sourceRevision, }); @@ -168,7 +212,6 @@ const runtimeCompileObserverPlugin = ( const completion = queued instanceof Promise ? queued as Promise : Promise.resolve(undefined); - snapshot.acceptCompilerAssetCheckpoint?.(); void completion.then((outcome) => { if (outcome === 'activated' || outcome === undefined) return; if (capturedCohort?.activationSequence === activationSequence) capturedCohort = undefined; @@ -177,11 +220,6 @@ const runtimeCompileObserverPlugin = ( }); } } catch (error) { - try { - snapshot?.discardCompilerAssetCheckpoint?.(); - } catch { - // The original capture/enqueue error remains the attempted failure cause. - } observer.failAttempt(attemptId, error, 'provider-lifecycle'); } }); diff --git a/examples/rsc-agent-runtime/src/dev/environment-checkpoint-store.ts b/examples/rsc-agent-runtime/src/dev/environment-checkpoint-store.ts new file mode 100644 index 000000000..75b2f2fdb --- /dev/null +++ b/examples/rsc-agent-runtime/src/dev/environment-checkpoint-store.ts @@ -0,0 +1,520 @@ +import { createHash } from 'node:crypto'; +import { lstat, mkdir, open, readFile, readdir, rm } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; + +/** + * Immutable per-environment output staging (#74). + * + * Rsbuild documents `onAfterEnvironmentCompile` as the per-environment + * completion hook and awaits it inside that environment compiler's Rspack + * `done` tap, so an awaited staging copy runs while that compiler cannot + * start its next write cycle. Each staged checkpoint is an immutable copy of + * one environment's `writeToDisk` root, keyed by that compilation's Stats + * hash. Runtime cohorts are then assembled exclusively from a complete + * hash-compatible checkpoint set, never from the mutable live compiler + * roots, so a cohort's Stats hashes and its copied bytes always describe the + * same per-environment moments. + * + * Rsbuild 2.2.1 fires the global after-compile hook from the last-completing + * child's `done` tap without waiting for the other children's asynchronous + * per-environment hooks, so a cohort request may arrive before its matching + * checkpoint is staged. Acquisition therefore waits for an exact (environment, + * hash) match and fails fast once a newer compilation supersedes the awaited + * hash. + */ + +export type RscRuntimeEnvironmentName = 'app' | 'rsc' | 'widget'; + +export const rscRuntimeEnvironmentNames: readonly RscRuntimeEnvironmentName[] = + Object.freeze(['app', 'rsc', 'widget'] as const); + +export type RscEnvironmentCohortHashes = Readonly>; + +/** One immutable staged copy of a single environment's completed output root. */ +export interface RscStagedEnvironmentCheckpoint { + readonly environment: RscRuntimeEnvironmentName; + /** Relative slash-separated path to sha256 digest for every staged file. */ + readonly files: ReadonlyMap; + /** The Stats hash of the compilation that produced these bytes. */ + readonly hash: string; + readonly root: string; +} + +export interface RscEnvironmentCheckpointCohort { + readonly checkpoints: Readonly>; + /** Unpins the cohort so superseded checkpoints can be garbage-collected. */ + release(): void; +} + +/** + * Validates one freshly staged tree and returns the digests the next staging + * of the same environment may treat as known carry-over content. Throwing + * rejects the checkpoint. + */ +export type RscEnvironmentCheckpointValidator = (input: Readonly<{ + readonly files: ReadonlyMap; + readonly priorKnownAssets: ReadonlyMap; + readonly root: string; +}>) => Promise>; + +export interface RscEnvironmentCheckpointStoreOptions { + readonly root: string; + readonly validators?: Partial>>; +} + +export interface RscEnvironmentCheckpointStore { + /** + * Resolves once every environment has a staged checkpoint matching the + * requested hash, pinning the set against garbage collection. Rejects when + * a requested hash has been superseded by a newer staged compilation, when + * staging the requested hash failed, or when the store closes. + */ + acquireCohort(hashes: RscEnvironmentCohortHashes): Promise; + close(): Promise; + /** + * Records a staging failure that happened before `stage` could run, so + * cohorts requiring this (environment, hash) fail loudly instead of + * waiting for a checkpoint that will never land. + */ + recordStagingFailure(input: Readonly<{ + readonly environment: RscRuntimeEnvironmentName; + readonly error: Error; + readonly hash: string; + }>): void; + /** Stages an immutable checkpoint of one environment's completed output root. */ + stage(input: Readonly<{ + readonly environment: RscRuntimeEnvironmentName; + readonly hash: string; + readonly sourceRoot: string; + }>): Promise; +} + +const maximumSupersededHashHistory = 64; + +const digestBytes = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex'); + +const isSafeSegment = (value: string): boolean => + value.length > 0 && value !== '.' && value !== '..' && !value.includes('/') && !value.includes('\\') && !value.includes('\0'); + +const assertInside = (root: string, target: string): void => { + const path = relative(resolve(root), resolve(target)); + if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) { + throw new Error('Environment checkpoint path escaped its root.'); + } +}; + +const fsyncPath = async (path: string): Promise => { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +}; + +const copyFileDigested = async (source: string, destination: string): Promise => { + const bytes = await readFile(source); + const handle = await open(destination, 'wx'); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } + return digestBytes(bytes); +}; + +/** + * Copies one completed compiler output root into an immutable staging + * directory, digesting every file. Applies the same containment rules as + * generation capture: regular files and directories only, no symbolic links, + * no unsafe path segments. + */ +const stageTree = async (sourceRoot: string, destinationRoot: string): Promise> => { + const sourceStatus = await lstat(sourceRoot); + if (!sourceStatus.isDirectory() || sourceStatus.isSymbolicLink()) { + throw new Error(`Compiler environment ${JSON.stringify(sourceRoot)} must be a regular directory.`); + } + await mkdir(destinationRoot, { recursive: false }); + const files = new Map(); + + const copyDirectory = async (source: string, destination: string, prefix: string): Promise => { + assertInside(sourceRoot, source); + assertInside(destinationRoot, destination); + const entries = await readdir(source, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!isSafeSegment(entry.name)) throw new Error('Compiler output contains an unsafe path segment.'); + const sourcePath = join(source, entry.name); + const destinationPath = join(destination, entry.name); + assertInside(sourceRoot, sourcePath); + assertInside(destinationRoot, destinationPath); + const status = await lstat(sourcePath); + if (status.isSymbolicLink()) throw new Error('Compiler output cannot contain symbolic links.'); + const path = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`; + if (status.isDirectory()) { + await mkdir(destinationPath, { recursive: false }); + await copyDirectory(sourcePath, destinationPath, path); + } else if (status.isFile()) { + files.set(path, await copyFileDigested(sourcePath, destinationPath)); + } else { + throw new Error('Compiler output can contain only regular files and directories.'); + } + } + await fsyncPath(destination); + }; + + await copyDirectory(sourceRoot, destinationRoot, ''); + return files; +}; + +interface CheckpointRecord { + readonly checkpoint: RscStagedEnvironmentCheckpoint; + deleted: boolean; + pins: number; + superseded: boolean; +} + +interface CheckpointWaiter { + readonly hash: string; + reject(error: Error): void; + resolve(record: CheckpointRecord): void; +} + +interface EnvironmentState { + failures: Map; + knownAssets: ReadonlyMap; + latest: CheckpointRecord | undefined; + supersededHashes: Set; + tail: Promise; + waiters: CheckpointWaiter[]; +} + +type PendingAcquisition = Readonly<{ + cancel(): void; + readonly promise: Promise; +}>; + +class EnvironmentCheckpointStore implements RscEnvironmentCheckpointStore { + readonly #environments = new Map(); + readonly #failedDeletions = new Map(); + readonly #pendingDeletions = new Set>(); + readonly #releaseWaiters: Array<() => void> = []; + readonly #live = new Set(); + readonly #root: string; + readonly #validators: Partial>>; + #closePromise: Promise | undefined; + #closed = false; + #sequence = 0; + + constructor(options: RscEnvironmentCheckpointStoreOptions) { + this.#root = resolve(options.root); + this.#validators = options.validators ?? {}; + for (const environment of rscRuntimeEnvironmentNames) { + this.#environments.set(environment, { + failures: new Map(), + knownAssets: new Map(), + latest: undefined, + supersededHashes: new Set(), + tail: Promise.resolve(), + waiters: [], + }); + } + } + + #state(environment: RscRuntimeEnvironmentName): EnvironmentState { + const state = this.#environments.get(environment); + if (state === undefined) throw new Error(`Unknown RSC runtime environment ${JSON.stringify(environment)}.`); + return state; + } + + #maybeDelete(record: CheckpointRecord): void { + if (record.deleted || record.pins > 0 || (!record.superseded && !this.#closed)) return; + record.deleted = true; + this.#live.delete(record); + // A failed removal must not be silent: the root is remembered, retried + // once while the store drains, and still-failing roots reject close so + // session teardown reports the leaked staging directories. + const deletion = rm(record.checkpoint.root, { force: true, recursive: true }).catch((error: unknown) => { + this.#failedDeletions.set( + record.checkpoint.root, + error instanceof Error ? error : new Error(String(error)), + ); + }); + this.#pendingDeletions.add(deletion); + void deletion.finally(() => { + this.#pendingDeletions.delete(deletion); + this.#notifyRelease(); + }); + } + + #notifyRelease(): void { + for (const waiter of this.#releaseWaiters.splice(0)) waiter(); + } + + #unpin(record: CheckpointRecord): void { + record.pins -= 1; + this.#maybeDelete(record); + this.#notifyRelease(); + } + + recordStagingFailure(input: Readonly<{ + readonly environment: RscRuntimeEnvironmentName; + readonly error: Error; + readonly hash: string; + }>): void { + if (this.#closed || input.hash.length === 0) return; + const state = this.#state(input.environment); + if (state.latest?.checkpoint.hash === input.hash) return; + state.failures.set(input.hash, input.error); + const remaining = state.waiters.filter((waiter) => waiter.hash !== input.hash); + const rejected = state.waiters.filter((waiter) => waiter.hash === input.hash); + state.waiters.length = 0; + state.waiters.push(...remaining); + for (const waiter of rejected) waiter.reject(input.error); + } + + async stage(input: Readonly<{ + readonly environment: RscRuntimeEnvironmentName; + readonly hash: string; + readonly sourceRoot: string; + }>): Promise { + if (typeof input.hash !== 'string' || input.hash.length === 0) { + throw new Error(`RSC runtime ${input.environment} compilation has no hash to checkpoint.`); + } + const state = this.#state(input.environment); + const previousTail = state.tail; + let releaseTail!: () => void; + state.tail = new Promise((resolveTail) => { releaseTail = resolveTail; }); + await previousTail; + try { + if (this.#closed) throw new Error('RSC environment checkpoint store is closed.'); + if (state.latest?.checkpoint.hash === input.hash) return; + await this.#stageLocked(state, input); + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + this.recordStagingFailure({ environment: input.environment, error: failure, hash: input.hash }); + throw error; + } finally { + releaseTail(); + } + } + + async #stageLocked( + state: EnvironmentState, + input: Readonly<{ + readonly environment: RscRuntimeEnvironmentName; + readonly hash: string; + readonly sourceRoot: string; + }>, + ): Promise { + await mkdir(this.#root, { recursive: true }); + const stagedRoot = join(this.#root, `${input.environment}-${String(++this.#sequence)}`); + let files: ReadonlyMap; + let knownAssets: ReadonlyMap; + try { + files = await stageTree(resolve(input.sourceRoot), stagedRoot); + const validator = this.#validators[input.environment]; + knownAssets = validator === undefined + ? files + : await validator({ files, priorKnownAssets: state.knownAssets, root: stagedRoot }); + if (this.#closed) throw new Error('RSC environment checkpoint store is closed.'); + } catch (error) { + await rm(stagedRoot, { force: true, recursive: true }).catch(() => undefined); + throw error; + } + const record: CheckpointRecord = { + checkpoint: Object.freeze({ + environment: input.environment, + files, + hash: input.hash, + root: stagedRoot, + }), + deleted: false, + pins: 0, + superseded: false, + }; + const previous = state.latest; + state.latest = record; + state.knownAssets = knownAssets; + state.failures.delete(input.hash); + state.supersededHashes.delete(input.hash); + this.#live.add(record); + if (previous !== undefined) { + previous.superseded = true; + state.supersededHashes.add(previous.checkpoint.hash); + while (state.supersededHashes.size > maximumSupersededHashHistory) { + const oldest = state.supersededHashes.values().next().value; + if (oldest === undefined) break; + state.supersededHashes.delete(oldest); + } + this.#maybeDelete(previous); + } + const settled = state.waiters.splice(0); + for (const waiter of settled) { + if (waiter.hash === input.hash) { + record.pins += 1; + waiter.resolve(record); + } else { + waiter.reject(new Error( + `RSC runtime ${input.environment} checkpoint ${JSON.stringify(waiter.hash)} was superseded by a newer compilation.`, + )); + } + } + } + + #acquire(environment: RscRuntimeEnvironmentName, hash: string): PendingAcquisition { + const state = this.#state(environment); + if (this.#closed) { + return Object.freeze({ + cancel: () => undefined, + promise: Promise.reject(new Error('RSC environment checkpoint store is closed.')), + }); + } + if (typeof hash !== 'string' || hash.length === 0) { + return Object.freeze({ + cancel: () => undefined, + promise: Promise.reject(new Error(`RSC runtime ${environment} cohort has no checkpoint hash.`)), + }); + } + const failure = state.failures.get(hash); + if (failure !== undefined) { + return Object.freeze({ + cancel: () => undefined, + promise: Promise.reject(new Error( + `RSC runtime ${environment} checkpoint ${JSON.stringify(hash)} failed to stage: ${failure.message}`, + )), + }); + } + const latest = state.latest; + if (latest !== undefined && latest.checkpoint.hash === hash) { + latest.pins += 1; + let cancelled = false; + return Object.freeze({ + cancel: () => { + if (cancelled) return; + cancelled = true; + this.#unpin(latest); + }, + promise: Promise.resolve(latest), + }); + } + if (state.supersededHashes.has(hash)) { + return Object.freeze({ + cancel: () => undefined, + promise: Promise.reject(new Error( + `RSC runtime ${environment} checkpoint ${JSON.stringify(hash)} was superseded by a newer compilation.`, + )), + }); + } + let waiter!: CheckpointWaiter; + let resolvedRecord: CheckpointRecord | undefined; + let cancelled = false; + const promise = new Promise((resolvePromise, rejectPromise) => { + waiter = { + hash, + reject: rejectPromise, + resolve: (record) => { + resolvedRecord = record; + if (cancelled) { + this.#unpin(record); + rejectPromise(new Error(`RSC runtime ${environment} cohort acquisition was cancelled.`)); + return; + } + resolvePromise(record); + }, + }; + }); + state.waiters.push(waiter); + return Object.freeze({ + cancel: () => { + if (cancelled) return; + cancelled = true; + if (resolvedRecord !== undefined) { + this.#unpin(resolvedRecord); + return; + } + const index = state.waiters.indexOf(waiter); + if (index >= 0) state.waiters.splice(index, 1); + }, + promise, + }); + } + + async acquireCohort(hashes: RscEnvironmentCohortHashes): Promise { + const acquisitions = rscRuntimeEnvironmentNames.map((environment) => + Object.freeze({ acquisition: this.#acquire(environment, hashes[environment]), environment })); + let records: readonly CheckpointRecord[]; + try { + records = await Promise.all(acquisitions.map(async ({ acquisition }) => acquisition.promise)); + } catch (error) { + for (const { acquisition } of acquisitions) { + acquisition.cancel(); + void acquisition.promise.catch(() => undefined); + } + throw error; + } + let released = false; + const checkpoints = Object.freeze(Object.fromEntries( + records.map((record) => [record.checkpoint.environment, record.checkpoint]), + )) as Readonly>; + return Object.freeze({ + checkpoints, + release: () => { + if (released) return; + released = true; + for (const record of records) this.#unpin(record); + }, + }); + } + + close(): Promise { + if (this.#closePromise === undefined) { + this.#closed = true; + const closeError = new Error('RSC environment checkpoint store is closed.'); + for (const state of this.#environments.values()) { + for (const waiter of state.waiters.splice(0)) waiter.reject(closeError); + } + for (const record of [...this.#live]) this.#maybeDelete(record); + this.#closePromise = this.#drain(); + } + return this.#closePromise; + } + + async #drain(): Promise { + // An in-flight stage() may still be copying into (or cleaning up) its + // staging directory; close must not report the store drained while that + // filesystem work continues. Post-close stage() calls fail fast, so the + // tails converge. + let tails = [...this.#environments.values()].map((state) => state.tail); + for (;;) { + await Promise.allSettled(tails); + const current = [...this.#environments.values()].map((state) => state.tail); + if (current.every((tail, index) => tail === tails[index])) break; + tails = current; + } + while (this.#live.size > 0 || this.#pendingDeletions.size > 0) { + if (this.#pendingDeletions.size > 0) { + await Promise.allSettled([...this.#pendingDeletions]); + continue; + } + // Remaining records are pinned by an in-flight cohort; wait for release. + await new Promise((resolveRelease) => { this.#releaseWaiters.push(resolveRelease); }); + } + const leaked: string[] = []; + for (const [root, error] of [...this.#failedDeletions]) { + try { + await rm(root, { force: true, recursive: true }); + this.#failedDeletions.delete(root); + } catch { + leaked.push(`${root} (${error.message})`); + } + } + if (leaked.length > 0) { + throw new Error(`RSC environment checkpoint store could not remove staged directories: ${leaked.join(', ')}.`); + } + } +} + +export const createRscEnvironmentCheckpointStore = ( + options: RscEnvironmentCheckpointStoreOptions, +): RscEnvironmentCheckpointStore => new EnvironmentCheckpointStore(options); diff --git a/examples/rsc-agent-runtime/src/dev/generation-materializer.ts b/examples/rsc-agent-runtime/src/dev/generation-materializer.ts index 2e125148f..da8102364 100644 --- a/examples/rsc-agent-runtime/src/dev/generation-materializer.ts +++ b/examples/rsc-agent-runtime/src/dev/generation-materializer.ts @@ -1,9 +1,14 @@ import { createHash } from 'node:crypto'; -import { open, lstat, mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import { open, lstat, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import { spawn } from 'node:child_process'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { emitRuntimeArtifacts } from '../build/emit-artifacts.js'; +import type { + RscEnvironmentCheckpointValidator, + RscRuntimeEnvironmentName, + RscStagedEnvironmentCheckpoint, +} from './environment-checkpoint-store.js'; import type { RscRuntimeAppDefinition, RscRuntimeGenerationMetadata, @@ -56,76 +61,11 @@ interface RuntimeAssetsEntry { readonly initial?: Readonly<{ readonly js?: readonly string[] }>; } -export interface RscCompilerAssetCheckpointTracker { - checkpoint(compilerRoot: string): Promise; - close(): void; -} - -export interface RscCompilerAssetCheckpoint { - readonly priorAssets: ReadonlyMap; - accept(assets: ReadonlyMap): void; - discard(): void; -} - -interface CompilerAssetCheckpointRoot { - assets: ReadonlyMap; - tail: Promise; -} - -class CompilerAssetCheckpointTracker implements RscCompilerAssetCheckpointTracker { - readonly #activeDiscards = new Set<() => void>(); - readonly #roots = new Map(); - #closed = false; - - async checkpoint(compilerRoot: string): Promise { - if (this.#closed) throw new Error('RSC compiler asset checkpoint tracker is closed.'); - const root = resolve(compilerRoot); - let state = this.#roots.get(root); - if (state === undefined) { - state = { assets: new Map(), tail: Promise.resolve() }; - this.#roots.set(root, state); - } - const previous = state.tail; - let release: (() => void) | undefined; - state.tail = new Promise((resolveTail) => { release = resolveTail; }); - await previous; - if (this.#closed) { - release?.(); - throw new Error('RSC compiler asset checkpoint tracker is closed.'); - } - const priorAssets = new Map(state.assets); - let settled = false; - const settle = (assets: ReadonlyMap | undefined): void => { - if (settled) return; - settled = true; - this.#activeDiscards.delete(discard); - if (assets !== undefined && !this.#closed) state.assets = new Map(assets); - release?.(); - }; - const discard = (): void => settle(undefined); - const accept = (assets: ReadonlyMap): void => settle(assets); - this.#activeDiscards.add(discard); - return Object.freeze({ accept, discard, priorAssets }); - } - - close(): void { - if (this.#closed) return; - this.#closed = true; - this.#roots.clear(); - for (const discard of [...this.#activeDiscards]) discard(); - } -} - -export const createRscCompilerAssetCheckpointTracker = (): RscCompilerAssetCheckpointTracker => - new CompilerAssetCheckpointTracker(); - export interface RscRuntimeCapturedGenerationSnapshot { - readonly acceptCompilerAssetCheckpoint?: () => void; readonly assets: readonly RuntimeGenerationAsset[]; readonly attemptId: string; readonly candidate: RuntimeGenerationCandidate; readonly definition: SerializedRuntimeDefinition; - readonly discardCompilerAssetCheckpoint?: () => void; readonly preparedRuntime: DevRuntimePreparedProject; readonly rscCohortRevision: number; readonly sourceRevision: string; @@ -134,8 +74,8 @@ export interface RscRuntimeCapturedGenerationSnapshot { export interface CaptureRuntimeGenerationSnapshotOptions { readonly attemptId: string; readonly candidate: RuntimeGenerationCandidate; - readonly compilerAssetCheckpointTracker?: RscCompilerAssetCheckpointTracker; - readonly compilerRoot: string; + /** Immutable staged per-environment checkpoints matching this cohort's Stats hashes. */ + readonly cohort: Readonly>; readonly preparedRuntime: DevRuntimePreparedProject; readonly rscCohortRevision: number; readonly sourceRevision: string; @@ -225,8 +165,21 @@ const fsync = async (path: string): Promise => { } }; -const copyFileExclusive = async (source: string, destination: string): Promise => { +/** + * Copies one staged checkpoint file, requiring its bytes to match the digest + * recorded when the checkpoint was staged. A mismatch means the immutable + * staging area itself was disturbed, so the cohort must not be admitted. + */ +const copyCheckpointFile = async ( + checkpoint: RscStagedEnvironmentCheckpoint, + path: string, + source: string, + destination: string, +): Promise => { const bytes = await readFile(source); + if (checkpoint.files.get(path) !== digestBytes(bytes)) { + throw new Error(`Staged ${checkpoint.environment} checkpoint no longer matches its recorded digest for ${JSON.stringify(path)}.`); + } const handle = await open(destination, 'wx'); try { await handle.writeFile(bytes); @@ -236,14 +189,19 @@ const copyFileExclusive = async (source: string, destination: string): Promise => { +const copyCheckpointTree = async ( + checkpoint: RscStagedEnvironmentCheckpoint, + destinationRoot: string, +): Promise => { + const sourceRoot = checkpoint.root; const sourceStatus = await lstat(sourceRoot); if (!sourceStatus.isDirectory() || sourceStatus.isSymbolicLink()) { - throw new Error(`Compiler environment ${JSON.stringify(sourceRoot)} must be a regular directory.`); + throw new Error(`Staged ${checkpoint.environment} checkpoint must be a regular directory.`); } await mkdir(destinationRoot, { recursive: false }); + let copied = 0; - const copyDirectory = async (source: string, destination: string): Promise => { + const copyDirectory = async (source: string, destination: string, prefix: string): Promise => { assertInside(sourceRoot, source); assertInside(destinationRoot, destination); const entries = await readdir(source, { withFileTypes: true }); @@ -255,11 +213,13 @@ const copyTree = async (sourceRoot: string, destinationRoot: string): Promise | undefined, -): Promise> => { - const sourceStatus = await lstat(sourceRoot); - if (!sourceStatus.isDirectory() || sourceStatus.isSymbolicLink()) { - throw new Error(`Compiler environment ${JSON.stringify(sourceRoot)} must be a regular directory.`); - } - const currentAssets = new Set([ - ...runtimeAssets.allFiles, - ...generatedRscAssetPaths, - ]); - const sourceFiles = new Map(); - const staleAssets = new Map(); - const inspectDirectory = async (source: string, prefix: string): Promise => { - assertInside(sourceRoot, source); - const entries = await readdir(source, { withFileTypes: true }); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!isSafeSegment(entry.name)) throw new Error('Compiler output contains an unsafe path segment.'); - const sourcePath = join(source, entry.name); - assertInside(sourceRoot, sourcePath); - const status = await lstat(sourcePath); - if (status.isSymbolicLink()) throw new Error('Compiler output cannot contain symbolic links.'); - const path = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`; - if (status.isDirectory()) { - await inspectDirectory(sourcePath, path); - } else if (status.isFile()) { - if (currentAssets.has(path)) { - sourceFiles.set(path, sourcePath); - continue; - } - const priorDigest = priorAssets?.get(path); - if (priorDigest === undefined || digestBytes(await readFile(sourcePath)) !== priorDigest) { - throw new Error(`Compiler output contains an undeclared file ${JSON.stringify(path)}.`); - } - staleAssets.set(path, priorDigest); - } else { - throw new Error('Compiler output can contain only regular files and directories.'); - } - } - }; - - await inspectDirectory(sourceRoot, ''); + destinationRoot: string, +): Promise => { + const currentAssets = new Set([...runtimeAssets.allFiles, 'runtime-assets.json']); await mkdir(destinationRoot, { recursive: false }); const destinationDirectories = new Set([destinationRoot]); const rememberDirectories = (directory: string): void => { @@ -327,19 +259,43 @@ const copyCurrentRscAssets = async ( } }; for (const path of [...currentAssets].sort((left, right) => left.localeCompare(right))) { - const source = sourceFiles.get(path); - if (source === undefined) throw new Error(`runtime-assets.json references missing asset ${JSON.stringify(path)}.`); + if (!checkpoint.files.has(path)) { + throw new Error(`runtime-assets.json references missing asset ${JSON.stringify(path)}.`); + } + const source = join(checkpoint.root, ...path.split('/')); const destination = join(destinationRoot, ...path.split('/')); const directory = dirname(destination); + assertInside(checkpoint.root, source); assertInside(destinationRoot, destination); await mkdir(directory, { recursive: true }); rememberDirectories(directory); - await copyFileExclusive(source, destination); + await copyCheckpointFile(checkpoint, path, source, destination); } for (const directory of [...destinationDirectories].sort((left, right) => right.length - left.length)) { await fsync(directory); } - return staleAssets; +}; + +/** + * Staging-time guard for the RSC environment: every staged file must either + * be declared by that compilation's `runtime-assets.json`, be a definition + * artifact this runtime generated into a reused compiler root, or match a + * digest already validated by the previous checkpoint of the same + * environment (a stale asset carried over from an earlier incremental + * compile). Anything else is a foreign write into the compiler root and + * rejects the checkpoint loudly. + */ +export const validateStagedRscEnvironmentCheckpoint: RscEnvironmentCheckpointValidator = async (input) => { + const runtimeAssets = await parseRuntimeAssets(input.root); + const declared = new Set([...runtimeAssets.allFiles, ...generatedRscAssetPaths]); + const knownAssets = new Map(); + for (const [path, sha256] of input.files) { + if (!declared.has(path) && input.priorKnownAssets.get(path) !== sha256) { + throw new Error(`Compiler output contains an undeclared file ${JSON.stringify(path)}.`); + } + knownAssets.set(path, sha256); + } + return knownAssets; }; const walkRegularFiles = async (root: string): Promise => { @@ -800,56 +756,34 @@ const clonePreparedRuntime = (preparedRuntime: DevRuntimePreparedProject): DevRu export const captureRuntimeGenerationSnapshot = async ( input: CaptureRuntimeGenerationSnapshotOptions, ): Promise => { - const compilerRoot = resolve(input.compilerRoot); - const checkpoint = await input.compilerAssetCheckpointTracker?.checkpoint(compilerRoot); - try { - const rscRoot = join(compilerRoot, 'rsc'); - const definition = await runDefinitionExecutable(join(rscRoot, 'dev', 'definition.js')); - const definitionBytes = Buffer.from(canonicalJson(definition)); - const definitionPath = join(rscRoot, 'runtime-definition.json'); - await unlink(definitionPath).catch((error: unknown) => { - if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return undefined; - throw error; - }); - await writeFile(definitionPath, definitionBytes, { encoding: 'utf8', flag: 'wx' }); - await fsync(join(rscRoot, 'runtime-definition.json')); - await emitRuntimeArtifacts(rscRoot, definition); - await fsync(rscRoot); - const runtimeAssets = await parseRuntimeAssets(rscRoot); - const staleAssets = await copyCurrentRscAssets( - rscRoot, - join(input.candidate.root, 'rsc'), - runtimeAssets, - checkpoint?.priorAssets, - ); - await copyTree(join(compilerRoot, 'app'), join(input.candidate.root, 'app')); - await copyTree(join(compilerRoot, 'widget'), join(input.candidate.root, 'widget')); - await fsync(input.candidate.root); - const assets = await walkRegularFiles(input.candidate.root); - const capturedAssets = new Map(assets.map((asset) => [asset.path, asset])); - const checkpointAssets = new Map(staleAssets); - for (const path of runtimeAssets.allFiles) { - const asset = capturedAssets.get(`rsc/${path}`); - if (asset === undefined) throw new Error(`runtime-assets.json references missing asset ${JSON.stringify(path)}.`); - checkpointAssets.set(path, asset.sha256); - } - return Object.freeze({ - ...(checkpoint === undefined ? {} : { - acceptCompilerAssetCheckpoint: () => checkpoint.accept(checkpointAssets), - discardCompilerAssetCheckpoint: () => checkpoint.discard(), - }), - assets, - attemptId: input.attemptId, - candidate: input.candidate, - definition, - preparedRuntime: clonePreparedRuntime(input.preparedRuntime), - rscCohortRevision: input.rscCohortRevision, - sourceRevision: input.sourceRevision, - }); - } catch (error) { - checkpoint?.discard(); - throw error; - } + // Every byte admitted into the candidate comes from the immutable staged + // checkpoints whose Stats hashes identify this cohort - never from the + // live compiler roots, which the next parallel compile may already be + // rewriting. The definition executable and the generated definition + // artifacts likewise run against and land in the candidate's own copy. + const { app, rsc, widget } = input.cohort; + const candidateRsc = join(input.candidate.root, 'rsc'); + const runtimeAssets = await parseRuntimeAssets(rsc.root); + await copyDeclaredRscAssets(rsc, runtimeAssets, candidateRsc); + const definition = await runDefinitionExecutable(join(candidateRsc, 'dev', 'definition.js')); + const definitionBytes = Buffer.from(canonicalJson(definition)); + await writeFile(join(candidateRsc, 'runtime-definition.json'), definitionBytes, { encoding: 'utf8', flag: 'wx' }); + await fsync(join(candidateRsc, 'runtime-definition.json')); + await emitRuntimeArtifacts(candidateRsc, definition); + await fsync(candidateRsc); + await copyCheckpointTree(app, join(input.candidate.root, 'app')); + await copyCheckpointTree(widget, join(input.candidate.root, 'widget')); + await fsync(input.candidate.root); + const assets = await walkRegularFiles(input.candidate.root); + return Object.freeze({ + assets, + attemptId: input.attemptId, + candidate: input.candidate, + definition, + preparedRuntime: clonePreparedRuntime(input.preparedRuntime), + rscCohortRevision: input.rscCohortRevision, + sourceRevision: input.sourceRevision, + }); }; const decodeMetadata = (value: JsonValue): RscRuntimeGenerationMetadata => { diff --git a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts index fb7079570..da89ad1a3 100644 --- a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -8,17 +8,22 @@ import { createRsbuild, type StartDevServerResult } from '@rsbuild/core'; import { createRscRuntimeRsbuildConfig, + type RscRuntimeCompileEnvironmentHashes, type RscRuntimeCompileFailureKind, type RscRuntimeCompileSnapshot, } from '../../rsbuild.config.js'; +import { + createRscEnvironmentCheckpointStore, + type RscEnvironmentCheckpointStore, + type RscRuntimeEnvironmentName, +} from './environment-checkpoint-store.js'; import { captureRuntimeGenerationSnapshot, - createRscCompilerAssetCheckpointTracker, materializeRuntimeGeneration, rscRuntimeGenerationMetadataCodec, runtimeDefinitionDigest, validateRscRuntimeGenerationMetadata, - type RscCompilerAssetCheckpointTracker, + validateStagedRscEnvironmentCheckpoint, type RscRuntimeCapturedGenerationSnapshot, } from './generation-materializer.js'; import type { @@ -258,6 +263,7 @@ interface RunArtifact { } type LiveSessionCleanupResource = + | 'environment-checkpoints' | 'generation-store' | 'owned-runs-root' | 'rsbuild-dev-server' @@ -645,7 +651,7 @@ export interface RsbuildRuntimeSessionStartTesting { * The private compiler URL is exposed only through `clientSurface`. */ export class RsbuildRuntimeSession implements DevRuntimeSession { - readonly #checkpointTracker: RscCompilerAssetCheckpointTracker; + readonly #checkpointStore: RscEnvironmentCheckpointStore; readonly #candidatesByAttempt = new Map(); readonly #captureTasks = new Set>(); readonly #context: DevRuntimeStartContext; @@ -695,7 +701,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { #status: DevRuntimeStatus; private constructor(input: Readonly<{ - readonly checkpointTracker: RscCompilerAssetCheckpointTracker; + readonly checkpointStore: RscEnvironmentCheckpointStore; readonly context: DevRuntimeStartContext; readonly generationStore: RuntimeGenerationStore; readonly mcpRegistry: RuntimeMcpRegistry; @@ -704,7 +710,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { readonly testing: RsbuildRuntimeSessionStartTesting; }>) { this.#context = input.context; - this.#checkpointTracker = input.checkpointTracker; + this.#checkpointStore = input.checkpointStore; this.#generationStore = input.generationStore; this.#mcpRegistry = input.mcpRegistry; this.#latestPreparedRuntime = input.preparedRuntime; @@ -763,8 +769,16 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { validateMetadata: validateRscRuntimeGenerationMetadata, }); ledger.add(() => generationStore.close(), 'generation-store'); - const checkpointTracker = createRscCompilerAssetCheckpointTracker(); - ledger.add(async () => { checkpointTracker.close(); }, 'compiler-asset-checkpoints'); + // Staged checkpoints are only meaningful within one session's validated + // staging chain, so a reused storage root must not leak a crashed + // session's stale staging directories into this one. + const checkpointsRoot = join(storageRoot, 'environment-checkpoints'); + await rm(checkpointsRoot, { force: true, recursive: true }); + const checkpointStore = createRscEnvironmentCheckpointStore({ + root: checkpointsRoot, + validators: { rsc: validateStagedRscEnvironmentCheckpoint }, + }); + ledger.add(() => checkpointStore.close(), 'environment-checkpoints'); await Promise.all([ mkdir(join(storageRoot, 'compiler'), { recursive: true }), mkdir(join(storageRoot, 'state'), { recursive: true }), @@ -830,7 +844,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { }); ledger.add(() => mcpRegistry.close(), 'runtime-mcp-registry'); const session = new RsbuildRuntimeSession({ - checkpointTracker, + checkpointStore, context, generationStore, mcpRegistry, @@ -2191,12 +2205,48 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { capture: async (input) => this.#trackCapture(input), enqueue: (snapshot) => this.#enqueue(snapshot), failAttempt: (attemptId, error, kind) => { void this.#failAttempt(attemptId, error, kind); }, + stageEnvironmentCheckpoint: (input) => this.#stageEnvironmentCheckpoint(input), }); } + /** + * Never rejects into the compiler's `done` hook: a rejected environment + * hook would skip Rsbuild's global after-compile dispatch and strand the + * FIFO attempt pairing. Failures are recorded against this (environment, + * hash) instead and fail the attempt loudly at cohort acquisition. + */ + async #stageEnvironmentCheckpoint(input: Readonly<{ + readonly distPath: string; + readonly environmentName: RscRuntimeEnvironmentName; + readonly statsHash: string; + }>): Promise { + try { + if (this.#closed) throw new Error('RSC runtime session is closed.'); + // The staged copy must read the same root this session configured for + // the environment; a diverging Rsbuild distPath would silently + // checkpoint the wrong tree. + const expectedRoot = join(resolve(this.#context.storageRoot), 'compiler', input.environmentName); + if (resolve(input.distPath) !== expectedRoot) { + throw new Error(`RSC runtime ${input.environmentName} compiler emitted outside its session root.`); + } + await this.#checkpointStore.stage({ + environment: input.environmentName, + hash: input.statsHash, + sourceRoot: expectedRoot, + }); + } catch (error) { + this.#checkpointStore.recordStagingFailure({ + environment: input.environmentName, + error: error instanceof Error ? error : new Error(String(error)), + hash: input.statsHash, + }); + } + } + #trackCapture(input: Readonly<{ readonly attemptId: string; readonly cohortChanged: boolean; + readonly environmentHashes: RscRuntimeCompileEnvironmentHashes; readonly hasErrors: boolean; readonly sourceRevision: string; }>): Promise { @@ -2230,6 +2280,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { async #capture(input: Readonly<{ readonly attemptId: string; readonly cohortChanged: boolean; + readonly environmentHashes: RscRuntimeCompileEnvironmentHashes; readonly hasErrors: boolean; readonly sourceRevision: string; }>): Promise { @@ -2261,24 +2312,32 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { await this.#testing.beforeGenerationCapture?.(); if (this.#closed) throw new Error('RSC runtime session is closed.'); // Rsbuild does not guarantee that global MultiStats completion is a - // transactional snapshot of parallel writeToDisk roots. In 2.2.1 the - // files empirically correspond to this completed cohort; the checkpoint - // capture below serializes and validates a copied immutable candidate. - const snapshot = await captureRuntimeGenerationSnapshot({ - attemptId: input.attemptId, - candidate, - compilerAssetCheckpointTracker: this.#checkpointTracker, - compilerRoot: join(this.#context.storageRoot, 'compiler'), - preparedRuntime, - rscCohortRevision: cohortRevision, - sourceRevision: input.sourceRevision, - }); + // transactional snapshot of parallel writeToDisk roots, so capture + // never reads the live compiler roots. It assembles the candidate from + // the immutable per-environment checkpoints staged in each compiler's + // own after-environment-compile hook, matched exactly against this + // cohort's Stats hashes; acquisition waits for a late-staging child and + // fails fast once a newer compilation supersedes a requested hash. + const cohort = await this.#checkpointStore.acquireCohort(input.environmentHashes); + let snapshot: RscRuntimeCapturedGenerationSnapshot; + try { + snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: input.attemptId, + candidate, + cohort: cohort.checkpoints, + preparedRuntime, + rscCohortRevision: cohortRevision, + sourceRevision: input.sourceRevision, + }); + } finally { + // The candidate now owns its own copied bytes, so superseded + // checkpoints can be garbage-collected without touching it. + cohort.release(); + } if (this.#closed) throw new Error('RSC runtime session is closed.'); return Object.freeze({ - acceptCompilerAssetCheckpoint: snapshot.acceptCompilerAssetCheckpoint, attemptId: snapshot.attemptId, candidateId: snapshot.candidate.id, - discardCompilerAssetCheckpoint: snapshot.discardCompilerAssetCheckpoint, preparedRevision: snapshot.preparedRuntime.sourceRevision, rscCohortRevision: snapshot.rscCohortRevision, sourceRevision: snapshot.sourceRevision, @@ -2294,7 +2353,6 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { const captured = (snapshot as RscRuntimeCompileSnapshot & Readonly<{ readonly snapshot?: RscRuntimeCapturedGenerationSnapshot }>).snapshot; if (captured === undefined) throw new Error('RSC runtime compile snapshot was not captured by this session.'); if (this.#closed) { - snapshot.discardCompilerAssetCheckpoint?.(); return this.#failAttempt(snapshot.attemptId, new Error('RSC runtime session is closed.')).then(() => 'failed'); } return this.#append(async () => this.#activate(captured)); @@ -2432,7 +2490,6 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { ...(preparedRegistry === undefined ? [] : [this.#mcpRegistry.abortActivationReconcile(preparedRegistry)]), ]); } - snapshot.discardCompilerAssetCheckpoint?.(); await this.#failAttempt(snapshot.attemptId, error); return 'failed'; } finally { @@ -2646,7 +2703,10 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { for (const worker of this.#workers.values()) { worker.terminate(new Error('RSC runtime session is closing.')); } - this.#checkpointTracker.close(); + // Closing the checkpoint store first fails in-flight cohort acquisitions + // fast; its staged directories drain below once captures release them. + const checkpointStoreClose = this.#checkpointStore.close(); + void checkpointStoreClose.catch(() => undefined); this.#setStatus('closed'); for (const broker of this.#appBrokers.values()) broker.closedObservation?.unsubscribe(); this.#appBrokers.clear(); @@ -2679,6 +2739,10 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { () => this.#server?.close() ?? Promise.resolve(), ) }), Object.freeze({ label: 'runtime-mcp-registry' as const, close: () => mcpRegistryClose }), + Object.freeze({ label: 'environment-checkpoints' as const, close: () => this.#closeLiveSessionResource( + 'environment-checkpoints', + () => checkpointStoreClose, + ) }), Object.freeze({ label: 'generation-store' as const, close: () => this.#closeLiveSessionResource( 'generation-store', () => this.#generationStore.close(), diff --git a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts index 8e013a578..ee89f6968 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -47,8 +47,16 @@ const deferred = () => { return Object.freeze({ promise, reject, resolve }); }; -const compileObserver = (onCompile: NonNullable[0]['onCompile']>) => { - const config = createRscRuntimeRsbuildConfig({ compilerRoot: join(tmpdir(), 'rsc-provider-observer'), mode: 'development', onCompile }); +type CompileObserverContract = NonNullable[0]['onCompile']>; + +const compileObserver = ( + onCompile: Omit & Partial>, +) => { + const config = createRscRuntimeRsbuildConfig({ + compilerRoot: join(tmpdir(), 'rsc-provider-observer'), + mode: 'development', + onCompile: { stageEnvironmentCheckpoint: async () => undefined, ...onCompile }, + }); const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{ readonly name: string; setup(api: unknown): void; @@ -59,6 +67,7 @@ const compileObserver = (onCompile: NonNullable Promise) | undefined; plugin.setup({ onAfterDevCompile: (callback: unknown) => { after = callback as (input: unknown) => Promise; }, + onAfterEnvironmentCompile: () => undefined, onBeforeDevCompile: (callback: unknown) => { before = callback as () => void; }, }); const beginAttempt = (): void => { @@ -73,7 +82,13 @@ const compileObserver = (onCompile: NonNullable input.hasErrors ?? false, - toJson: () => ({ children: input.children ?? [{ hash: 'rsc-hash', name: 'rsc' }, { hash: 'widget-hash', name: 'widget' }] }), + toJson: () => ({ + children: input.children ?? [ + { hash: 'rsc-hash', name: 'rsc' }, + { hash: 'widget-hash', name: 'widget' }, + { hash: 'app-hash', name: 'app' }, + ], + }), }, }); }; @@ -100,6 +115,7 @@ const compileObserver = (onCompile: NonNullable { let before: (() => void) | undefined; let after: ((input: unknown) => Promise) | undefined; + let afterEnvironment: ((input: unknown) => Promise) | undefined; const create = (async (input: Parameters[0]) => { const plugins = (input?.config as Readonly<{ readonly plugins?: readonly unknown[] }> | undefined)?.plugins ?? []; const plugin = plugins.find((candidate): candidate is Readonly<{ @@ -110,6 +126,7 @@ const interceptCompileObserver = () => { if (plugin === undefined) throw new Error('RSC compile observer plugin is unavailable.'); plugin.setup({ onAfterDevCompile: (callback: unknown) => { after = callback as (input: unknown) => Promise; }, + onAfterEnvironmentCompile: (callback: unknown) => { afterEnvironment = callback as (input: unknown) => Promise; }, onBeforeDevCompile: (callback: unknown) => { before = callback as () => void; }, }); return createRsbuild(input); @@ -132,9 +149,39 @@ const interceptCompileObserver = () => { }); }, create, + /** + * Drives the plugin's per-environment staging hook against the session's + * real compiler output roots, staging an immutable checkpoint under a + * test-chosen hash so a synthetic attempt can assemble a real cohort. + */ + async stageEnvironment(input: Readonly<{ + readonly distPath: string; + readonly hash: string; + readonly name: string; + }>): Promise { + if (afterEnvironment === undefined) throw new Error('RSC compile observer hooks are unavailable.'); + await afterEnvironment({ + environment: { distPath: input.distPath, name: input.name }, + stats: { hasErrors: () => false, hash: input.hash }, + }); + }, }); }; +const stageSyntheticCohort = async ( + observer: ReturnType, + storageRoot: string, + suffix: string, +): Promise[]> => { + const children: Array> = []; + for (const name of ['rsc', 'widget', 'app'] as const) { + const hash = `${name}-${suffix}`; + await observer.stageEnvironment({ distPath: join(storageRoot, 'compiler', name), hash, name }); + children.push({ hash, name }); + } + return children; +}; + const snapshotFor = (attemptId: string, sourceRevision: string): RscRuntimeCompileSnapshot => Object.freeze({ attemptId, candidateId: attemptId, @@ -1426,8 +1473,9 @@ test('commits an activation while a later attempt is still live at the commit ch await waitFor(() => session.status().state === 'active'); const firstGeneration = session.status().activeVector!.runtimeGenerationId; armCommitBarrier = true; + const raceChildren = await stageSyntheticCohort(observer, join(copied.projectRoot, '.agent-bundle', 'runtime-live-attempt-commit'), 'live-race'); observer.beginAttempt(); - await observer.completeAttempt({ children: [{ hash: 'live-race-rsc', name: 'rsc' }, { hash: 'live-race-widget', name: 'widget' }] }); + await observer.completeAttempt({ children: raceChildren }); await commitReached.promise; // The #38 wedge: an undecided later attempt registers while the newest // compile sits between its final guard wait and the commit check. It @@ -1481,8 +1529,9 @@ test('commits an activation after a later broken attempt fails inside the commit await waitFor(() => session.status().state === 'active'); const firstGeneration = session.status().activeVector!.runtimeGenerationId; armCommitBarrier = true; + const raceChildren = await stageSyntheticCohort(observer, join(copied.projectRoot, '.agent-bundle', 'runtime-failed-attempt-commit'), 'failed-race'); observer.beginAttempt(); - await observer.completeAttempt({ children: [{ hash: 'failed-race-rsc', name: 'rsc' }, { hash: 'failed-race-widget', name: 'widget' }] }); + await observer.completeAttempt({ children: raceChildren }); await commitReached.promise; // A later broken compile fails and settles entirely inside the commit // window. A failed attempt produces no generation, so it must not diff --git a/examples/rsc-agent-runtime/tests/environment-checkpoint-store.test.ts b/examples/rsc-agent-runtime/tests/environment-checkpoint-store.test.ts new file mode 100644 index 000000000..00d5c91da --- /dev/null +++ b/examples/rsc-agent-runtime/tests/environment-checkpoint-store.test.ts @@ -0,0 +1,346 @@ +import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, test } from '@rstest/core'; + +import { + createRscEnvironmentCheckpointStore, + rscRuntimeEnvironmentNames, + type RscEnvironmentCheckpointStore, + type RscEnvironmentCohortHashes, +} from '../src/dev/environment-checkpoint-store.js'; +import { + captureRuntimeGenerationSnapshot, + materializeRuntimeGeneration, + rscRuntimeGenerationMetadataCodec, + validateRscRuntimeGenerationMetadata, + validateStagedRscEnvironmentCheckpoint, + type RscRuntimeGenerationMetadata, +} from '../src/dev/generation-materializer.js'; +import { RuntimeGenerationStore } from '../../../packages/agent-bundle/src/dev/runtime-generation-store.ts'; +import { writeCompilerCohort } from './support/compiler-cohort.ts'; + +const preparedRuntime = Object.freeze({ + apps: Object.freeze([]), + provider: './src/dev/provider.ts', + servers: Object.freeze([]), + sourceRevision: 'prepared-r1', +}); + +const cohortHashesFor = (suffix: string): RscEnvironmentCohortHashes => Object.freeze({ + app: `app-${suffix}`, + rsc: `rsc-${suffix}`, + widget: `widget-${suffix}`, +}); + +const createCheckpointStore = (root: string): RscEnvironmentCheckpointStore => + createRscEnvironmentCheckpointStore({ + root, + validators: { rsc: validateStagedRscEnvironmentCheckpoint }, + }); + +const stageCohort = async ( + store: RscEnvironmentCheckpointStore, + compilerRoot: string, + suffix: string, +): Promise => { + const hashes = cohortHashesFor(suffix); + for (const environment of rscRuntimeEnvironmentNames) { + await store.stage({ environment, hash: hashes[environment], sourceRoot: join(compilerRoot, environment) }); + } + return hashes; +}; + +const settled = async (): Promise => { + for (let index = 0; index < 8; index += 1) { + await new Promise((resolve) => { queueMicrotask(resolve); }); + } +}; + +const waitForRemoval = async (path: string): Promise => { + const deadline = Date.now() + 5_000; + while (true) { + try { + await readdir(path); + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return; + throw error; + } + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${path} to be removed.`); + await new Promise((resolve) => { setTimeout(resolve, 20); }); + } +}; + +test('assembles a cohort only once every environment checkpoint has landed (skewed completion)', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-checkpoints-skew-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createCheckpointStore(join(storageRoot, 'environment-checkpoints')); + try { + await writeCompilerCohort(compilerRoot); + const hashes = cohortHashesFor('one'); + // The global after-compile hook can fire before a slower environment's + // own after-environment hook finishes staging; acquisition must wait for + // the exact hash instead of reading anything mutable. + await store.stage({ environment: 'app', hash: hashes.app, sourceRoot: join(compilerRoot, 'app') }); + await store.stage({ environment: 'rsc', hash: hashes.rsc, sourceRoot: join(compilerRoot, 'rsc') }); + let acquired = false; + const pending = store.acquireCohort(hashes).then((cohort) => { + acquired = true; + return cohort; + }); + await settled(); + expect(acquired).toBe(false); + + await store.stage({ environment: 'widget', hash: hashes.widget, sourceRoot: join(compilerRoot, 'widget') }); + const cohort = await pending; + expect(acquired).toBe(true); + expect(cohort.checkpoints.rsc.hash).toBe(hashes.rsc); + expect(cohort.checkpoints.widget.files.get('rsc/index.html')).toMatch(/^[a-f0-9]{64}$/u); + cohort.release(); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('fails a waiting cohort fast once a newer compilation supersedes the awaited hash', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-checkpoints-supersede-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createCheckpointStore(join(storageRoot, 'environment-checkpoints')); + try { + await writeCompilerCohort(compilerRoot); + await store.stage({ environment: 'app', hash: 'app-one', sourceRoot: join(compilerRoot, 'app') }); + await store.stage({ environment: 'rsc', hash: 'rsc-one', sourceRoot: join(compilerRoot, 'rsc') }); + const waiting = store.acquireCohort(cohortHashesFor('one')); + const observed = waiting.catch((error: unknown) => error); + await settled(); + + await store.stage({ environment: 'widget', hash: 'widget-two', sourceRoot: join(compilerRoot, 'widget') }); + await expect(observed).resolves.toMatchObject({ + message: expect.stringContaining('superseded by a newer compilation'), + }); + + // A cohort naming an already-superseded hash rejects immediately. + await store.stage({ environment: 'widget', hash: 'widget-three', sourceRoot: join(compilerRoot, 'widget') }); + await expect(store.acquireCohort({ app: 'app-one', rsc: 'rsc-one', widget: 'widget-two' })) + .rejects.toThrow('superseded by a newer compilation'); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('rejects cohorts whose environment checkpoint failed to stage', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-checkpoints-staging-failure-')); + const compilerRoot = join(storageRoot, 'compiler'); + const store = createCheckpointStore(join(storageRoot, 'environment-checkpoints')); + try { + await writeCompilerCohort(compilerRoot, { rscFiles: { 'undeclared.js': 'foreign-write' } }); + await store.stage({ environment: 'app', hash: 'app-one', sourceRoot: join(compilerRoot, 'app') }); + await store.stage({ environment: 'widget', hash: 'widget-one', sourceRoot: join(compilerRoot, 'widget') }); + await expect(store.stage({ environment: 'rsc', hash: 'rsc-one', sourceRoot: join(compilerRoot, 'rsc') })) + .rejects.toThrow('undeclared'); + await expect(store.acquireCohort(cohortHashesFor('one'))).rejects.toThrow('failed to stage'); + + // Failures recorded before staging could run reject waiters the same way. + store.recordStagingFailure({ environment: 'rsc', error: new Error('emitted outside its session root'), hash: 'rsc-two' }); + await expect(store.acquireCohort({ app: 'app-one', rsc: 'rsc-two', widget: 'widget-one' })) + .rejects.toThrow('failed to stage'); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('keeps a pinned cohort immutable while newer compiles land, then garbage-collects it on release', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-checkpoints-gc-')); + const compilerRoot = join(storageRoot, 'compiler'); + const checkpointsRoot = join(storageRoot, 'environment-checkpoints'); + const store = createCheckpointStore(checkpointsRoot); + const generationStore = new RuntimeGenerationStore({ + metadataCodec: rscRuntimeGenerationMetadataCodec, + storageRoot: join(storageRoot, 'generation-store'), + validateMetadata: validateRscRuntimeGenerationMetadata, + }); + try { + await writeCompilerCohort(compilerRoot); + const firstHashes = await stageCohort(store, compilerRoot, 'one'); + const cohort = await store.acquireCohort(firstHashes); + const pinnedRscRoot = cohort.checkpoints.rsc.root; + + // Invalidation during assembly: a newer compile rewrites the live root + // and stages fresh checkpoints while the acquired cohort is still being + // copied. The pinned checkpoints stay intact. + await writeFile(join(compilerRoot, 'rsc', 'rsc', 'index.js'), 'rewritten-by-next-compile', 'utf8'); + await stageCohort(store, compilerRoot, 'two'); + expect(await readFile(join(pinnedRscRoot, 'rsc', 'index.js'), 'utf8')).toBe('rsc-entry'); + + const candidate = await generationStore.begin({ id: 'gc-generation', sourceRevision: 'source-gc' }); + const snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: 'attempt-gc', + candidate, + cohort: cohort.checkpoints, + preparedRuntime, + rscCohortRevision: 1, + sourceRevision: 'source-gc', + }); + const prepared = await materializeRuntimeGeneration({ snapshot, store: generationStore }); + expect(await readFile(join(prepared.generation.root, 'rsc', 'rsc', 'index.js'), 'utf8')).toBe('rsc-entry'); + + // Releasing the pins garbage-collects the superseded checkpoints without + // touching the admitted generation candidate's copied bytes. + cohort.release(); + await waitForRemoval(pinnedRscRoot); + expect(await readFile(join(prepared.generation.root, 'rsc', 'rsc', 'index.js'), 'utf8')).toBe('rsc-entry'); + const remaining = await readdir(checkpointsRoot); + expect(remaining.some((entry) => entry.startsWith('rsc-'))).toBe(true); + + const secondCohort = await store.acquireCohort(cohortHashesFor('two')); + expect(await readFile(join(secondCohort.checkpoints.rsc.root, 'rsc', 'index.js'), 'utf8')).toBe('rewritten-by-next-compile'); + secondCohort.release(); + } finally { + await generationStore.close().catch(() => undefined); + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('deduplicates unchanged-hash restaging onto the same immutable checkpoint', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-checkpoints-dedupe-')); + const compilerRoot = join(storageRoot, 'compiler'); + const checkpointsRoot = join(storageRoot, 'environment-checkpoints'); + const store = createCheckpointStore(checkpointsRoot); + try { + await writeCompilerCohort(compilerRoot); + await store.stage({ environment: 'widget', hash: 'widget-one', sourceRoot: join(compilerRoot, 'widget') }); + // An unchanged environment reports the same hash on later watch cycles; + // restaging must reuse the existing checkpoint instead of copying again. + await store.stage({ environment: 'widget', hash: 'widget-one', sourceRoot: join(compilerRoot, 'widget') }); + const staged = await readdir(checkpointsRoot); + expect(staged.filter((entry) => entry.startsWith('widget-'))).toHaveLength(1); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('close rejects when a garbage-collected checkpoint directory cannot be removed', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-checkpoints-rm-failure-')); + const compilerRoot = join(storageRoot, 'compiler'); + const checkpointsRoot = join(storageRoot, 'environment-checkpoints'); + const store = createCheckpointStore(checkpointsRoot); + let pinnedRoot: string | undefined; + try { + await writeCompilerCohort(compilerRoot); + await store.stage({ environment: 'widget', hash: 'widget-one', sourceRoot: join(compilerRoot, 'widget') }); + const staged = await readdir(checkpointsRoot); + pinnedRoot = join(checkpointsRoot, staged.find((entry) => entry.startsWith('widget-'))!); + // A read-only subdirectory makes every removal of this checkpoint fail, + // both the supersession GC attempt and the close-time retry. + await chmod(join(pinnedRoot, 'rsc'), 0o555); + await store.stage({ environment: 'widget', hash: 'widget-two', sourceRoot: join(compilerRoot, 'widget') }); + await expect(store.close()).rejects.toThrow('could not remove staged directories'); + } finally { + if (pinnedRoot !== undefined) await chmod(join(pinnedRoot, 'rsc'), 0o755).catch(() => undefined); + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('close retries a transiently failed checkpoint removal before reporting a clean drain', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-checkpoints-rm-retry-')); + const compilerRoot = join(storageRoot, 'compiler'); + const checkpointsRoot = join(storageRoot, 'environment-checkpoints'); + const store = createCheckpointStore(checkpointsRoot); + try { + await writeCompilerCohort(compilerRoot); + await store.stage({ environment: 'widget', hash: 'widget-one', sourceRoot: join(compilerRoot, 'widget') }); + const staged = await readdir(checkpointsRoot); + const supersededRoot = join(checkpointsRoot, staged.find((entry) => entry.startsWith('widget-'))!); + await chmod(join(supersededRoot, 'rsc'), 0o555); + await store.stage({ environment: 'widget', hash: 'widget-two', sourceRoot: join(compilerRoot, 'widget') }); + // Let the failed GC removal settle while the directory is still + // read-only, then clear the transient condition: the close-time retry + // must remove the directory and report a clean drain. + await new Promise((resolve) => { setTimeout(resolve, 200); }); + expect((await readdir(checkpointsRoot)).includes(supersededRoot.slice(checkpointsRoot.length + 1))).toBe(true); + await chmod(join(supersededRoot, 'rsc'), 0o755); + await store.close(); + const remaining = await readdir(checkpointsRoot).catch(() => []); + expect(remaining.filter((entry) => entry.startsWith('widget-'))).toEqual([]); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('close waits for in-flight staging work before reporting the store drained', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-checkpoints-inflight-close-')); + const compilerRoot = join(storageRoot, 'compiler'); + const checkpointsRoot = join(storageRoot, 'environment-checkpoints'); + let enteredValidator!: () => void; + const entered = new Promise((resolve) => { enteredValidator = resolve; }); + let releaseValidator!: () => void; + const hold = new Promise((resolve) => { releaseValidator = resolve; }); + const store = createRscEnvironmentCheckpointStore({ + root: checkpointsRoot, + validators: { + widget: async (input) => { + enteredValidator(); + await hold; + return input.files; + }, + }, + }); + try { + await writeCompilerCohort(compilerRoot); + const staging = store.stage({ environment: 'widget', hash: 'widget-one', sourceRoot: join(compilerRoot, 'widget') }); + const stagingOutcome = staging.catch((error: unknown) => error); + await entered; + let closed = false; + const closing = store.close().then(() => { closed = true; }); + await settled(); + await new Promise((resolve) => { setTimeout(resolve, 50); }); + // The staging copy (held inside its validator) still owns filesystem + // work; close must not report the store drained yet. + expect(closed).toBe(false); + releaseValidator(); + await closing; + await expect(stagingOutcome).resolves.toMatchObject({ + message: expect.stringContaining('closed'), + }); + // The in-flight staging directory was cleaned before close resolved. + const remaining = await readdir(checkpointsRoot).catch(() => []); + expect(remaining).toEqual([]); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); + +test('close rejects pending cohort waiters and removes staged checkpoints', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-checkpoints-close-')); + const compilerRoot = join(storageRoot, 'compiler'); + const checkpointsRoot = join(storageRoot, 'environment-checkpoints'); + const store = createCheckpointStore(checkpointsRoot); + try { + await writeCompilerCohort(compilerRoot); + await stageCohort(store, compilerRoot, 'one'); + const waiting = store.acquireCohort(cohortHashesFor('two')); + const observed = waiting.catch((error: unknown) => error); + await store.close(); + await expect(observed).resolves.toMatchObject({ + message: expect.stringContaining('closed'), + }); + for (const entry of await readdir(checkpointsRoot).catch(() => [])) { + throw new Error(`Staged checkpoint ${entry} survived close.`); + } + await expect(store.stage({ environment: 'widget', hash: 'widget-three', sourceRoot: join(compilerRoot, 'widget') })) + .rejects.toThrow('closed'); + await expect(store.acquireCohort(cohortHashesFor('one'))).rejects.toThrow('closed'); + } finally { + await store.close().catch(() => undefined); + await rm(storageRoot, { force: true, recursive: true }); + } +}); diff --git a/examples/rsc-agent-runtime/tests/generation-materializer.test.ts b/examples/rsc-agent-runtime/tests/generation-materializer.test.ts index b7a8a1fff..a82739380 100644 --- a/examples/rsc-agent-runtime/tests/generation-materializer.test.ts +++ b/examples/rsc-agent-runtime/tests/generation-materializer.test.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; -import { mkdtemp, mkdir, readFile, rm, symlink, unlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, symlink, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { createRsbuild } from '@rsbuild/core'; import { expect, test } from '@rstest/core'; @@ -10,80 +10,29 @@ import { createRscRuntimeRsbuildConfig, type RscRuntimeCompileSnapshot, } from '../rsbuild.config.js'; +import { + createRscEnvironmentCheckpointStore, + rscRuntimeEnvironmentNames, + type RscEnvironmentCheckpointStore, + type RscEnvironmentCohortHashes, +} from '../src/dev/environment-checkpoint-store.js'; import { captureRuntimeGenerationSnapshot, - createRscCompilerAssetCheckpointTracker, materializeRuntimeGeneration, rscRuntimeGenerationMetadataCodec, runtimeDefinitionDigest, validateRscRuntimeGenerationMetadata, - type RscCompilerAssetCheckpointTracker, + validateStagedRscEnvironmentCheckpoint, type RscRuntimeCapturedGenerationSnapshot, type RscRuntimeGenerationMetadata, } from '../src/dev/generation-materializer.js'; import { digest, stableJson } from '../../../packages/agent-bundle/src/core/digest.ts'; -import { RuntimeGenerationStore } from '../../../packages/agent-bundle/src/dev/runtime-generation-store.ts'; +import { RuntimeGenerationStore, type RuntimeGenerationCandidate } from '../../../packages/agent-bundle/src/dev/runtime-generation-store.ts'; import type { DevRuntimePreparedProject } from '../../../packages/agent-bundle/src/dev/runtime-provider.ts'; +import { writeCompilerCohort } from './support/compiler-cohort.ts'; const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); -const definitionJson = '{"nativeHooks":[],"resources":[],"tools":[]}'; - -const runtimeFiles = { - 'chunks/101.js': 'async-chunk', - 'dev/definition.js': `process.stdout.write(${JSON.stringify(`${definitionJson}\n`)});\n`, - 'dev/invoke.js': 'invoke-worker', - 'hook/index.js': 'hook-entry', - 'mcp/http.js': 'http-entry', - 'mcp/stdio.js': 'stdio-entry', - 'rsc/index.js': 'rsc-entry', -} as const; - -const widgetFiles = { - 'rsc/index.html': '', - 'static/js/rsc/index.js': 'client-reference', -} as const; - -const appFiles = { - 'edit-timeline-v1.html': '
Timeline
', - 'edit-timeline-v2.html': '
Timeline v2
', - 'activity-v1.html': '
Activity
', -} as const; - -const writeTree = async (root: string, files: Readonly>): Promise => { - await Promise.all(Object.entries(files).map(async ([path, contents]) => { - const destination = join(root, ...path.split('/')); - await mkdir(dirname(destination), { recursive: true }); - await writeFile(destination, contents, 'utf8'); - })); -}; - -const writeCompilerCohort = async ( - compilerRoot: string, - options: Readonly<{ - readonly appFiles?: Readonly>; - readonly rscFiles?: Readonly>; - readonly widgetFiles?: Readonly>; - }> = {}, -): Promise => { - const rscRoot = join(compilerRoot, 'rsc'); - await writeTree(rscRoot, { ...runtimeFiles, ...options.rscFiles }); - await mkdir(join(compilerRoot, 'app'), { recursive: true }); - await writeTree(join(compilerRoot, 'app'), options.appFiles ?? appFiles); - await writeTree(join(compilerRoot, 'widget'), { ...widgetFiles, ...options.widgetFiles }); - await writeFile(join(rscRoot, 'runtime-assets.json'), JSON.stringify({ - allFiles: Object.keys(runtimeFiles).map((path) => `/${path}`), - entries: { - 'dev/definition': { initial: { js: ['/dev/definition.js'] } }, - 'dev/invoke': { initial: { js: ['/dev/invoke.js'] } }, - 'hook/index': { initial: { js: ['/hook/index.js'] } }, - 'mcp/http': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/mcp/http.js'] } }, - 'mcp/stdio': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/mcp/stdio.js'] } }, - 'rsc/index': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/rsc/index.js'] } }, - }, - }), 'utf8'); -}; - const preparedRuntime = Object.freeze({ apps: Object.freeze([]), provider: './src/dev/provider.ts', @@ -144,19 +93,71 @@ const rewriteGenerationManifest = async ( await writeFile(manifestPath, stableJson({ ...updated, manifestDigest: digest(updated) }), 'utf8'); }; -const acceptCompilerAssetCheckpoint = (snapshot: RscRuntimeCapturedGenerationSnapshot): void => { - expect(snapshot.acceptCompilerAssetCheckpoint).toBeTypeOf('function'); - snapshot.acceptCompilerAssetCheckpoint?.(); -}; +const createCheckpointStore = (root: string): RscEnvironmentCheckpointStore => + createRscEnvironmentCheckpointStore({ + root, + validators: { rsc: validateStagedRscEnvironmentCheckpoint }, + }); -const captureWithCompilerAssetCheckpoint = async ( - input: Parameters[0], - tracker: RscCompilerAssetCheckpointTracker, -) => captureRuntimeGenerationSnapshot({ - ...input, - compilerAssetCheckpointTracker: tracker, +const cohortHashesFor = (suffix: string): RscEnvironmentCohortHashes => Object.freeze({ + app: `app-${suffix}`, + rsc: `rsc-${suffix}`, + widget: `widget-${suffix}`, }); +const stageCompilerCohort = async ( + store: RscEnvironmentCheckpointStore, + compilerRoot: string, + suffix: string, +): Promise => { + const hashes = cohortHashesFor(suffix); + for (const environment of rscRuntimeEnvironmentNames) { + await store.stage({ environment, hash: hashes[environment], sourceRoot: join(compilerRoot, environment) }); + } + return hashes; +}; + +let ephemeralCheckpointSequence = 0; + +/** + * Stages the current live compiler trees as immutable checkpoints and + * captures the candidate from them, mirroring the session's staged-cohort + * flow. Passing `checkpointStore` keeps one validated staging chain across + * successive captures; otherwise an ephemeral store is used. + */ +const captureCompilerCohort = async (input: Readonly<{ + readonly attemptId: string; + readonly candidate: RuntimeGenerationCandidate; + readonly checkpointStore?: RscEnvironmentCheckpointStore; + readonly cohortSuffix?: string; + readonly compilerRoot: string; + readonly preparedRuntime: DevRuntimePreparedProject; + readonly rscCohortRevision: number; + readonly sourceRevision: string; +}>): Promise => { + const store = input.checkpointStore ?? createCheckpointStore( + join(input.compilerRoot, '..', `environment-checkpoints-${String(++ephemeralCheckpointSequence)}`), + ); + try { + const hashes = await stageCompilerCohort(store, input.compilerRoot, input.cohortSuffix ?? input.sourceRevision); + const cohort = await store.acquireCohort(hashes); + try { + return await captureRuntimeGenerationSnapshot({ + attemptId: input.attemptId, + candidate: input.candidate, + cohort: cohort.checkpoints, + preparedRuntime: input.preparedRuntime, + rscCohortRevision: input.rscCohortRevision, + sourceRevision: input.sourceRevision, + }); + } finally { + cohort.release(); + } + } finally { + if (input.checkpointStore === undefined) await store.close(); + } +}; + const isProcessAlive = (pid: number): boolean => { try { process.kill(pid, 0); @@ -167,11 +168,18 @@ const isProcessAlive = (pid: number): boolean => { } }; -const activateCompilerObserver = (onCompile: NonNullable[0]['onCompile']>) => { +type CompileObserverContract = NonNullable[0]['onCompile']>; + +const activateCompilerObserver = ( + onCompile: Omit & Partial>, +) => { const config = createRscRuntimeRsbuildConfig({ compilerRoot: join(tmpdir(), 'rsc-agent-runtime-observer'), mode: 'development', - onCompile, + onCompile: { + stageEnvironmentCheckpoint: async () => undefined, + ...onCompile, + }, }); const plugin = (config.plugins as readonly unknown[]).find((value): value is Readonly<{ readonly name: string; @@ -181,8 +189,10 @@ const activateCompilerObserver = (onCompile: NonNullable void) | undefined; let after: ((input: unknown) => Promise) | undefined; + let afterEnvironment: ((input: unknown) => Promise) | undefined; plugin.setup({ onAfterDevCompile: (callback: unknown) => { after = callback as (input: unknown) => Promise; }, + onAfterEnvironmentCompile: (callback: unknown) => { afterEnvironment = callback as (input: unknown) => Promise; }, onBeforeDevCompile: (callback: unknown) => { before = callback as () => void; }, }); return Object.freeze({ @@ -195,9 +205,26 @@ const activateCompilerObserver = (onCompile: NonNullable): Promise { + await afterEnvironment?.({ + environment: { distPath: input.distPath, name: input.name }, + stats: { + hasErrors: () => input.hasErrors ?? false, + hash: input.hash, + }, + }); + }, }); }; +const cohortChildren = (suffix: string): readonly Readonly<{ readonly hash: string; readonly name: string }>[] => + rscRuntimeEnvironmentNames.map((name) => ({ hash: `${name}-${suffix}`, name })); + const compilerObserver = (input: Readonly<{ readonly capture: Array>>; readonly enqueued: string[]; @@ -279,7 +306,7 @@ test('captures immutable paired compiler outputs and records every digested asse try { await writeCompilerCohort(compilerRoot); const candidate = await store.begin({ id: 'g1', sourceRevision: 'source-r1' }); - const snapshot = await captureRuntimeGenerationSnapshot({ + const snapshot = await captureCompilerCohort({ attemptId: 'attempt-1', candidate, compilerRoot, @@ -330,7 +357,7 @@ test('includes prepared App definitions in the captured runtime definition diges sourceRevision = 'captured-r1', ) => { const candidate = await store.begin({ id, sourceRevision }); - const snapshot = await captureRuntimeGenerationSnapshot({ + const snapshot = await captureCompilerCohort({ attemptId: `attempt-${id}`, candidate, compilerRoot, @@ -427,7 +454,7 @@ test('captures the canonical generated HTML asset for each prepared App surface' try { await writeCompilerCohort(compilerRoot, { appFiles: { 'edit-timeline-v1.html': html } }); const candidate = await store.begin({ id: 'app-html', sourceRevision: 'source-app-html' }); - const snapshot = await captureRuntimeGenerationSnapshot({ + const snapshot = await captureCompilerCohort({ attemptId: 'attempt-app-html', candidate, compilerRoot, @@ -457,7 +484,7 @@ test('rejects a traversal-normalized App URI even when a matching generated HTML try { await writeCompilerCohort(compilerRoot, { appFiles: { 'escaped.html': '
Escaped
' } }); const candidate = await store.begin({ id: 'app-html-traversal', sourceRevision: 'source-app-html-traversal' }); - const snapshot = await captureRuntimeGenerationSnapshot({ + const snapshot = await captureCompilerCohort({ attemptId: 'attempt-app-html-traversal', candidate, compilerRoot, @@ -479,7 +506,7 @@ test('rejects missing, duplicate, and symbolic-link App HTML capture inputs', as try { await writeCompilerCohort(compilerRoot, { appFiles: {} }); const missingCandidate = await store.begin({ id: 'app-html-missing', sourceRevision: 'source-app-html-missing' }); - const missingSnapshot = await captureRuntimeGenerationSnapshot({ + const missingSnapshot = await captureCompilerCohort({ attemptId: 'attempt-app-html-missing', candidate: missingCandidate, compilerRoot, preparedRuntime: preparedRuntimeWithApp(), rscCohortRevision: 1, sourceRevision: 'source-app-html-missing', }); await expect(materializeRuntimeGeneration({ snapshot: missingSnapshot, store })).rejects.toThrow('no unique captured HTML asset'); @@ -488,7 +515,7 @@ test('rejects missing, duplicate, and symbolic-link App HTML capture inputs', as const [timelineApp] = preparedRuntimeWithApp().apps; if (timelineApp === undefined) throw new Error('Timeline App fixture was unavailable.'); const duplicateCandidate = await store.begin({ id: 'app-html-duplicate', sourceRevision: 'source-app-html-duplicate' }); - const duplicateSnapshot = await captureRuntimeGenerationSnapshot({ + const duplicateSnapshot = await captureCompilerCohort({ attemptId: 'attempt-app-html-duplicate', candidate: duplicateCandidate, compilerRoot, @@ -503,7 +530,7 @@ test('rejects missing, duplicate, and symbolic-link App HTML capture inputs', as await symlink(join(compilerRoot, 'app', 'edit-timeline-v1.html'), join(compilerRoot, 'app', 'linked.html')); const linkedCandidate = await store.begin({ id: 'app-html-link', sourceRevision: 'source-app-html-link' }); - await expect(captureRuntimeGenerationSnapshot({ + await expect(captureCompilerCohort({ attemptId: 'attempt-app-html-link', candidate: linkedCandidate, compilerRoot, preparedRuntime: preparedRuntimeWithApp(), rscCohortRevision: 3, sourceRevision: 'source-app-html-link', })).rejects.toThrow('symbolic links'); } finally { @@ -519,7 +546,7 @@ test('rejects a rewritten prepared App definition manifest on post-rename reload try { await writeCompilerCohort(compilerRoot); const candidate = await store.begin({ id: 'persisted-app', sourceRevision: 'source-persisted-app' }); - const snapshot = await captureRuntimeGenerationSnapshot({ + const snapshot = await captureCompilerCohort({ attemptId: 'attempt-persisted-app', candidate, compilerRoot, @@ -560,7 +587,7 @@ test('rejects a persisted App surface manifest without its declared canonical HT try { await writeCompilerCohort(compilerRoot); const candidate = await store.begin({ id: 'persisted-app-surface', sourceRevision: 'source-persisted-app-surface' }); - const snapshot = await captureRuntimeGenerationSnapshot({ + const snapshot = await captureCompilerCohort({ attemptId: 'attempt-persisted-app-surface', candidate, compilerRoot, @@ -599,21 +626,21 @@ test('rejects a removed or replaced paired compiler asset after capture', async try { await writeCompilerCohort(compilerRoot); const missingCandidate = await store.begin({ id: 'missing', sourceRevision: 'source-missing' }); - const missingSnapshot = await captureRuntimeGenerationSnapshot({ + const missingSnapshot = await captureCompilerCohort({ attemptId: 'attempt-missing', candidate: missingCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-missing', }); await unlink(join(missingCandidate.root, 'widget', 'rsc', 'index.html')); await expect(materializeRuntimeGeneration({ snapshot: missingSnapshot, store })).rejects.toThrow('captured cohort'); const replacedCandidate = await store.begin({ id: 'replaced', sourceRevision: 'source-replaced' }); - const replacedSnapshot = await captureRuntimeGenerationSnapshot({ + const replacedSnapshot = await captureCompilerCohort({ attemptId: 'attempt-replaced', candidate: replacedCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-replaced', }); await writeFile(join(replacedCandidate.root, 'widget', 'static', 'js', 'rsc', 'index.js'), 'replaced-client-reference', 'utf8'); await expect(materializeRuntimeGeneration({ snapshot: replacedSnapshot, store })).rejects.toThrow('captured cohort'); const appCandidate = await store.begin({ id: 'app-replaced', sourceRevision: 'source-app-replaced' }); - const appSnapshot = await captureRuntimeGenerationSnapshot({ + const appSnapshot = await captureCompilerCohort({ attemptId: 'attempt-app-replaced', candidate: appCandidate, compilerRoot, preparedRuntime: preparedRuntimeWithApp(), rscCohortRevision: 3, sourceRevision: 'source-app-replaced', }); await writeFile(join(appCandidate.root, 'app', 'edit-timeline-v1.html'), 'replaced-App-HTML', 'utf8'); @@ -635,7 +662,7 @@ test('bounds and redacts a definition executable stderr flood', async () => { }, }); const candidate = await store.begin({ id: 'stderr', sourceRevision: 'source-stderr' }); - const error = await captureRuntimeGenerationSnapshot({ + const error = await captureCompilerCohort({ attemptId: 'attempt-stderr', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-stderr', }).then( () => new Error('Definition stderr flood unexpectedly captured.'), @@ -663,7 +690,7 @@ test('waits for grace-to-SIGKILL termination of a SIGTERM-ignoring definition ch }, }); const candidate = await store.begin({ id: 'ignores-term', sourceRevision: 'source-ignores-term' }); - await expect(captureRuntimeGenerationSnapshot({ + await expect(captureCompilerCohort({ attemptId: 'attempt-ignores-term', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-ignores-term', })).rejects.toThrow('exceeded 5 seconds'); childPid = Number(await readFile(marker, 'utf8')); @@ -676,11 +703,13 @@ test('waits for grace-to-SIGKILL termination of a SIGTERM-ignoring definition ch } }, 10_000); -test('fails compile attempts unless stats contain one nonempty RSC and widget hash', async () => { +test('fails compile attempts unless stats contain one nonempty RSC, widget, and App hash', async () => { for (const children of [ [{ name: 'rsc', hash: 'rsc-hash' }], - [{ name: 'rsc', hash: 'rsc-hash' }, { name: 'rsc', hash: 'second-rsc-hash' }, { name: 'widget', hash: 'widget-hash' }], - [{ name: 'rsc', hash: 'rsc-hash' }, { name: 'widget' }], + [{ name: 'rsc', hash: 'rsc-hash' }, { name: 'widget', hash: 'widget-hash' }], + [{ name: 'rsc', hash: 'rsc-hash' }, { name: 'rsc', hash: 'second-rsc-hash' }, { name: 'widget', hash: 'widget-hash' }, { name: 'app', hash: 'app-hash' }], + [{ name: 'rsc', hash: 'rsc-hash' }, { name: 'widget' }, { name: 'app', hash: 'app-hash' }], + [{ name: 'rsc', hash: 'rsc-hash' }, { name: 'widget', hash: 'widget-hash' }, { name: 'app', hash: '' }], ]) { const capture: Array>> = []; const enqueued: string[] = []; @@ -692,23 +721,59 @@ test('fails compile attempts unless stats contain one nonempty RSC and widget ha } }); -test('accepts a compiler checkpoint only after enqueue and discards it after an enqueue failure', async () => { +test('passes exact per-environment hashes to capture alongside the rsc and widget source revision', async () => { + const capture: Array>> = []; + const enqueued: string[] = []; + const failed: unknown[] = []; + const observer = compilerObserver({ capture, enqueued, failed }); + await observer.compile([...cohortChildren('one'), { name: 'ignored-extra', hash: 'ignored' }]); + + expect(failed).toEqual([]); + expect(enqueued).toEqual(['attempt-1']); + expect(capture).toHaveLength(1); + expect(capture[0]).toMatchObject({ + cohortChanged: true, + environmentHashes: { app: 'app-one', rsc: 'rsc-one', widget: 'widget-one' }, + }); + // The App environment ships through its own dev-server surface, so only + // rsc and widget hashes define the source revision. + expect(capture[0]?.sourceRevision).toBe(sha256(JSON.stringify([['rsc', 'rsc-one'], ['widget', 'widget-one']]))); +}); + +test('stages a checkpoint for every successful environment compilation and skips unusable ones', async () => { + const staged: Array>> = []; + const observer = activateCompilerObserver({ + beforeAttempt: () => 'attempt-stage', + capture: async () => undefined, + enqueue: () => undefined, + failAttempt: () => undefined, + stageEnvironmentCheckpoint: async (input) => { staged.push(input); }, + }); + + await observer.completeEnvironment({ distPath: '/compiler/rsc', hash: 'rsc-one', name: 'rsc' }); + await observer.completeEnvironment({ distPath: '/compiler/widget', hash: 'widget-one', name: 'widget' }); + await observer.completeEnvironment({ distPath: '/compiler/app', hash: 'app-one', name: 'app' }); + // Failed compilations, unexpected environments, and missing hashes stage + // nothing; the global after-compile hook is the loud failure path. + await observer.completeEnvironment({ distPath: '/compiler/rsc', hash: 'rsc-two', hasErrors: true, name: 'rsc' }); + await observer.completeEnvironment({ distPath: '/compiler/other', hash: 'other-one', name: 'other' }); + await observer.completeEnvironment({ distPath: '/compiler/widget', name: 'widget' }); + + expect(staged).toEqual([ + { distPath: '/compiler/rsc', environmentName: 'rsc', statsHash: 'rsc-one' }, + { distPath: '/compiler/widget', environmentName: 'widget', statsHash: 'widget-one' }, + { distPath: '/compiler/app', environmentName: 'app', statsHash: 'app-one' }, + ]); +}); + +test('recaptures an identical cohort from its immutable checkpoints after an enqueue failure', async () => { const lifecycle: string[] = []; const captures: Array> = []; const failed: unknown[] = []; const snapshots = [ - Object.freeze({ - acceptCompilerAssetCheckpoint: () => lifecycle.push('accept-a'), - attemptId: 'a', candidateId: 'a', discardCompilerAssetCheckpoint: () => lifecycle.push('discard-a'), preparedRevision: 'prepared-a', rscCohortRevision: 1, sourceRevision: 'a', - }), - Object.freeze({ - acceptCompilerAssetCheckpoint: () => lifecycle.push('accept-b'), - attemptId: 'b', candidateId: 'b', discardCompilerAssetCheckpoint: () => lifecycle.push('discard-b'), preparedRevision: 'prepared-b', rscCohortRevision: 2, sourceRevision: 'b', - }), - Object.freeze({ - acceptCompilerAssetCheckpoint: () => lifecycle.push('accept-b-retry'), - attemptId: 'b-retry', candidateId: 'b-retry', discardCompilerAssetCheckpoint: () => lifecycle.push('discard-b-retry'), preparedRevision: 'prepared-b', rscCohortRevision: 3, sourceRevision: 'b', - }), + Object.freeze({ attemptId: 'a', candidateId: 'a', preparedRevision: 'prepared-a', rscCohortRevision: 1, sourceRevision: 'a' }), + Object.freeze({ attemptId: 'b', candidateId: 'b', preparedRevision: 'prepared-b', rscCohortRevision: 2, sourceRevision: 'b' }), + Object.freeze({ attemptId: 'b-retry', candidateId: 'b-retry', preparedRevision: 'prepared-b', rscCohortRevision: 3, sourceRevision: 'b' }), ] as const satisfies readonly RscRuntimeCompileSnapshot[]; let index = 0; let enqueueCount = 0; @@ -728,15 +793,11 @@ test('accepts a compiler checkpoint only after enqueue and discards it after an failAttempt: (_attemptId, error) => failed.push(error), }); - await observer.compile([{ name: 'rsc', hash: 'rsc-a' }, { name: 'widget', hash: 'widget-a' }]); - await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); - await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); + await observer.compile(cohortChildren('a')); + await observer.compile(cohortChildren('b')); + await observer.compile(cohortChildren('b')); - expect(lifecycle).toEqual([ - 'enqueue-a', 'accept-a', - 'enqueue-b', 'discard-b', - 'enqueue-b-retry', 'accept-b-retry', - ]); + expect(lifecycle).toEqual(['enqueue-a', 'enqueue-b', 'enqueue-b-retry']); expect(captures).toEqual([ { cohortChanged: true }, { cohortChanged: true }, @@ -756,7 +817,7 @@ test('requires every executable entry to declare its async cohort assets', async delete manifest.entries['mcp/http']?.async; await writeFile(manifestPath, JSON.stringify(manifest), 'utf8'); const candidate = await store.begin({ id: 'missing-async', sourceRevision: 'source-missing-async' }); - const snapshot = await captureRuntimeGenerationSnapshot({ + const snapshot = await captureCompilerCohort({ attemptId: 'attempt-missing-async', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-missing-async', }); await expect(materializeRuntimeGeneration({ snapshot, store })).rejects.toThrow('async'); @@ -773,7 +834,7 @@ test('rejects a genuinely undeclared RSC file outside the known compiler cohort' try { await writeCompilerCohort(compilerRoot, { rscFiles: { 'undeclared.js': 'not-in-runtime-assets' } }); const candidate = await store.begin({ id: 'undeclared', sourceRevision: 'source-undeclared' }); - await expect(captureRuntimeGenerationSnapshot({ + await expect(captureCompilerCohort({ attemptId: 'attempt-undeclared', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-undeclared', })).rejects.toThrow('undeclared'); } finally { @@ -786,17 +847,19 @@ test('reconciles a stale known async chunk from a prior incremental compiler coh const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); const compilerRoot = join(storageRoot, 'compiler'); const store = createStore(storageRoot); - const tracker = createRscCompilerAssetCheckpointTracker(); + const checkpointStore = createCheckpointStore(join(storageRoot, 'environment-checkpoints')); try { await writeCompilerCohort(compilerRoot); const firstCandidate = await store.begin({ id: 'first', sourceRevision: 'source-first' }); - const firstSnapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-first', candidate: firstCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-first', - }, tracker); + const firstSnapshot = await captureCompilerCohort({ + attemptId: 'attempt-first', candidate: firstCandidate, checkpointStore, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-first', + }); const firstPrepared = await materializeRuntimeGeneration({ snapshot: firstSnapshot, store }); await store.abort(firstPrepared); - acceptCompilerAssetCheckpoint(firstSnapshot); + // An incremental compile leaves the previous cohort's chunk on disk; the + // next staged checkpoint tolerates it because the previous checkpoint of + // the same environment validated exactly those bytes. const rscRoot = join(compilerRoot, 'rsc'); await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); const manifestPath = join(rscRoot, 'runtime-assets.json'); @@ -805,9 +868,9 @@ test('reconciles a stale known async chunk from a prior incremental compiler coh expect(await readFile(join(rscRoot, 'chunks', '101.js'), 'utf8')).toBe('async-chunk'); const secondCandidate = await store.begin({ id: 'second', sourceRevision: 'source-second' }); - const snapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-second', candidate: secondCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-second', - }, tracker); + const snapshot = await captureCompilerCohort({ + attemptId: 'attempt-second', candidate: secondCandidate, checkpointStore, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-second', + }); const prepared = await materializeRuntimeGeneration({ snapshot, store }); expect(prepared.generation.manifest.assets.map((asset) => asset.path)).toEqual(expect.arrayContaining([ @@ -817,17 +880,17 @@ test('reconciles a stale known async chunk from a prior incremental compiler coh expect(await readFile(join(prepared.generation.root, 'rsc', 'chunks', '202.js'), 'utf8')).toBe('replacement-async-chunk'); await expect(readFile(join(prepared.generation.root, 'rsc', 'chunks', '101.js'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); } finally { - tracker.close(); + await checkpointStore.close().catch(() => undefined); await store.close().catch(() => undefined); await rm(storageRoot, { force: true, recursive: true }); } }); -test('retries a stale known compiler chunk after enqueue discards the prior capture checkpoint', async () => { +test('recaptures a stale known compiler chunk cohort through the observer after an enqueue failure', async () => { const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); const compilerRoot = join(storageRoot, 'compiler'); const store = createStore(storageRoot); - const tracker = createRscCompilerAssetCheckpointTracker(); + const checkpointStore = createCheckpointStore(join(storageRoot, 'environment-checkpoints')); const snapshots: RscRuntimeCapturedGenerationSnapshot[] = []; const failed: unknown[] = []; let candidateNumber = 0; @@ -839,19 +902,27 @@ test('retries a stale known compiler chunk after enqueue discards the prior capt capture: async (input) => { candidateNumber += 1; const candidate = await store.begin({ id: `candidate-${String(candidateNumber)}`, sourceRevision: input.sourceRevision }); - const snapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: input.attemptId, - candidate, - compilerRoot, - preparedRuntime, - rscCohortRevision: candidateNumber, - sourceRevision: input.sourceRevision, - }, tracker); + const cohort = await checkpointStore.acquireCohort(input.environmentHashes); + let snapshot: RscRuntimeCapturedGenerationSnapshot; + try { + snapshot = await captureRuntimeGenerationSnapshot({ + attemptId: input.attemptId, + candidate, + cohort: cohort.checkpoints, + preparedRuntime, + rscCohortRevision: candidateNumber, + sourceRevision: input.sourceRevision, + }); + } finally { + cohort.release(); + } snapshots.push(snapshot); return Object.freeze({ - ...snapshot, + attemptId: snapshot.attemptId, candidateId: candidate.id, preparedRevision: snapshot.preparedRuntime.sourceRevision, + rscCohortRevision: snapshot.rscCohortRevision, + sourceRevision: snapshot.sourceRevision, }); }, enqueue: () => { @@ -859,16 +930,35 @@ test('retries a stale known compiler chunk after enqueue discards the prior capt if (enqueueNumber === 2) throw new Error('enqueue rejects B'); }, failAttempt: (_attemptId, error) => failed.push(error), + stageEnvironmentCheckpoint: (input) => checkpointStore.stage({ + environment: input.environmentName, + hash: input.statsHash, + sourceRoot: join(compilerRoot, input.environmentName), + }), }); + const stageCohortThroughObserver = async (suffix: string): Promise => { + for (const environment of rscRuntimeEnvironmentNames) { + await observer.completeEnvironment({ + distPath: join(compilerRoot, environment), + hash: `${environment}-${suffix}`, + name: environment, + }); + } + }; - await observer.compile([{ name: 'rsc', hash: 'rsc-a' }, { name: 'widget', hash: 'widget-a' }]); + await stageCohortThroughObserver('a'); + await observer.compile(cohortChildren('a')); const rscRoot = join(compilerRoot, 'rsc'); await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); const manifestPath = join(rscRoot, 'runtime-assets.json'); await writeFile(manifestPath, (await readFile(manifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); - await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); - await observer.compile([{ name: 'rsc', hash: 'rsc-b' }, { name: 'widget', hash: 'widget-b' }]); + await stageCohortThroughObserver('b'); + await observer.compile(cohortChildren('b')); + // The unchanged-hash restage deduplicates and the identical cohort is + // reassembled from the same immutable checkpoints for the retry. + await stageCohortThroughObserver('b'); + await observer.compile(cohortChildren('b')); expect(failed).toHaveLength(1); expect(snapshots).toHaveLength(3); @@ -877,94 +967,50 @@ test('retries a stale known compiler chunk after enqueue discards the prior capt expect(snapshot.assets.map((asset) => asset.path)).not.toContain('rsc/chunks/101.js'); } } finally { - tracker.close(); + await checkpointStore.close().catch(() => undefined); await store.close().catch(() => undefined); await rm(storageRoot, { force: true, recursive: true }); } }); -test('isolates roots between tracker sessions and revokes checkpoint provenance on close', async () => { +test('rejects stale compiler output in a fresh checkpoint store without a validated predecessor', async () => { const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); const compilerRoot = join(storageRoot, 'compiler'); const otherCompilerRoot = join(storageRoot, 'other-compiler'); const store = createStore(storageRoot); - const firstTracker = createRscCompilerAssetCheckpointTracker(); - let secondTracker: RscCompilerAssetCheckpointTracker | undefined; + const firstCheckpointStore = createCheckpointStore(join(storageRoot, 'first-checkpoints')); + const secondCheckpointStore = createCheckpointStore(join(storageRoot, 'second-checkpoints')); try { await writeCompilerCohort(compilerRoot); const firstCandidate = await store.begin({ id: 'first', sourceRevision: 'source-first' }); - const firstSnapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-first', candidate: firstCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-first', - }, firstTracker); - acceptCompilerAssetCheckpoint(firstSnapshot); + await captureCompilerCohort({ + attemptId: 'attempt-first', candidate: firstCandidate, checkpointStore: firstCheckpointStore, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-first', + }); const rscRoot = join(compilerRoot, 'rsc'); await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); const manifestPath = join(rscRoot, 'runtime-assets.json'); await writeFile(manifestPath, (await readFile(manifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); - firstTracker.close(); + await firstCheckpointStore.close(); - secondTracker = createRscCompilerAssetCheckpointTracker(); + // The stale-asset tolerance chain lives inside one store's validated + // staging history; a fresh store treats the leftover chunk as foreign. const reusedRootCandidate = await store.begin({ id: 'reused-root', sourceRevision: 'source-reused-root' }); - await expect(captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-reused-root', candidate: reusedRootCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-reused-root', - }, secondTracker as RscCompilerAssetCheckpointTracker)).rejects.toThrow('undeclared'); + await expect(captureCompilerCohort({ + attemptId: 'attempt-reused-root', candidate: reusedRootCandidate, checkpointStore: secondCheckpointStore, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-reused-root', + })).rejects.toThrow('undeclared'); await writeCompilerCohort(otherCompilerRoot); const otherRootManifestPath = join(otherCompilerRoot, 'rsc', 'runtime-assets.json'); await writeFile(join(otherCompilerRoot, 'rsc', 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); await writeFile(otherRootManifestPath, (await readFile(otherRootManifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); const otherRootCandidate = await store.begin({ id: 'other-root', sourceRevision: 'source-other-root' }); - await expect(captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-other-root', candidate: otherRootCandidate, compilerRoot: otherCompilerRoot, preparedRuntime, rscCohortRevision: 3, sourceRevision: 'source-other-root', - }, secondTracker as RscCompilerAssetCheckpointTracker)).rejects.toThrow('undeclared'); - } finally { - secondTracker?.close(); - firstTracker.close(); - await store.close().catch(() => undefined); - await rm(storageRoot, { force: true, recursive: true }); - } -}); - -test('serializes concurrent same-root captures and commits checkpoints in capture order', async () => { - const storageRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-generations-')); - const compilerRoot = join(storageRoot, 'compiler'); - const store = createStore(storageRoot); - const tracker = createRscCompilerAssetCheckpointTracker(); - try { - await writeCompilerCohort(compilerRoot); - const firstCandidate = await store.begin({ id: 'first', sourceRevision: 'source-first' }); - const firstSnapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-first', candidate: firstCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-first', - }, tracker); - acceptCompilerAssetCheckpoint(firstSnapshot); - - const rscRoot = join(compilerRoot, 'rsc'); - await writeFile(join(rscRoot, 'chunks', '202.js'), 'replacement-async-chunk', 'utf8'); - const manifestPath = join(rscRoot, 'runtime-assets.json'); - await writeFile(manifestPath, (await readFile(manifestPath, 'utf8')).replaceAll('/chunks/101.js', '/chunks/202.js'), 'utf8'); - const secondCandidate = await store.begin({ id: 'second', sourceRevision: 'source-second' }); - const thirdCandidate = await store.begin({ id: 'third', sourceRevision: 'source-third' }); - const secondSnapshot = await captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-second', candidate: secondCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 2, sourceRevision: 'source-second', - }, tracker); - let thirdSettled = false; - const thirdSnapshotPromise = captureWithCompilerAssetCheckpoint({ - attemptId: 'attempt-third', candidate: thirdCandidate, compilerRoot, preparedRuntime, rscCohortRevision: 3, sourceRevision: 'source-third', - }, tracker).then((snapshot) => { - thirdSettled = true; - return snapshot; - }); - await new Promise((resolveMicrotask) => queueMicrotask(resolveMicrotask)); - expect(thirdSettled).toBe(false); - - acceptCompilerAssetCheckpoint(secondSnapshot); - const thirdSnapshot = await thirdSnapshotPromise; - expect(thirdSnapshot.assets.map((asset) => asset.path)).toContain('rsc/chunks/202.js'); - expect(thirdSnapshot.assets.map((asset) => asset.path)).not.toContain('rsc/chunks/101.js'); - acceptCompilerAssetCheckpoint(thirdSnapshot); + await expect(captureCompilerCohort({ + attemptId: 'attempt-other-root', candidate: otherRootCandidate, checkpointStore: secondCheckpointStore, compilerRoot: otherCompilerRoot, preparedRuntime, rscCohortRevision: 3, sourceRevision: 'source-other-root', + })).rejects.toThrow('undeclared'); } finally { - tracker.close(); + await secondCheckpointStore.close().catch(() => undefined); + await firstCheckpointStore.close().catch(() => undefined); await store.close().catch(() => undefined); await rm(storageRoot, { force: true, recursive: true }); } @@ -979,7 +1025,7 @@ test('rejects a client entry document that points at a different client-referenc widgetFiles: { 'rsc/index.html': '' }, }); const candidate = await store.begin({ id: 'mismatched-client', sourceRevision: 'source-mismatched-client' }); - const snapshot = await captureRuntimeGenerationSnapshot({ + const snapshot = await captureCompilerCohort({ attemptId: 'attempt-mismatched-client', candidate, compilerRoot, preparedRuntime, rscCohortRevision: 1, sourceRevision: 'source-mismatched-client', }); await expect(materializeRuntimeGeneration({ snapshot, store })).rejects.toThrow('client reference relationship'); diff --git a/examples/rsc-agent-runtime/tests/state-and-definition.test.ts b/examples/rsc-agent-runtime/tests/state-and-definition.test.ts index e48ff7ec9..3f077a55e 100644 --- a/examples/rsc-agent-runtime/tests/state-and-definition.test.ts +++ b/examples/rsc-agent-runtime/tests/state-and-definition.test.ts @@ -470,8 +470,16 @@ test('excludes a live heartbeat owner and recovers its stale lock only after SIG owner.kill('SIGKILL'); await new Promise((resolve) => owner.once('close', () => resolve())); await wait(2_100); + // This test asserts stale-lock recovery, not the release/settlement + // budgets (dedicated tests pin those with explicit adapter values). The + // test-kernel 100ms defaults poison a recovered mutation whenever one + // lock-directory fs operation stalls on a contended runner, so the + // recovery kernel uses the scaled production budgets instead. await expect( - createTestFileRuntimeKernel({ stateFile }).recordEdit({ + createTestFileRuntimeKernel({ + adapter: { ownerSettlementMs: 10_000 * timeScale, releaseMs: 10_000 * timeScale }, + stateFile, + }).recordEdit({ host: 'codex', idempotencyKey: 'test:state:stale-recovery', path: 'recovered.ts', diff --git a/examples/rsc-agent-runtime/tests/support/compiler-cohort.ts b/examples/rsc-agent-runtime/tests/support/compiler-cohort.ts new file mode 100644 index 000000000..e7a88f5b7 --- /dev/null +++ b/examples/rsc-agent-runtime/tests/support/compiler-cohort.ts @@ -0,0 +1,61 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +/** Minimal on-disk compiler cohort fixture shared by generation and checkpoint suites. */ + +export const definitionJson = '{"nativeHooks":[],"resources":[],"tools":[]}'; + +export const runtimeFiles = { + 'chunks/101.js': 'async-chunk', + 'dev/definition.js': `process.stdout.write(${JSON.stringify(`${definitionJson}\n`)});\n`, + 'dev/invoke.js': 'invoke-worker', + 'hook/index.js': 'hook-entry', + 'mcp/http.js': 'http-entry', + 'mcp/stdio.js': 'stdio-entry', + 'rsc/index.js': 'rsc-entry', +} as const; + +export const widgetFiles = { + 'rsc/index.html': '', + 'static/js/rsc/index.js': 'client-reference', +} as const; + +export const appFiles = { + 'edit-timeline-v1.html': '
Timeline
', + 'edit-timeline-v2.html': '
Timeline v2
', + 'activity-v1.html': '
Activity
', +} as const; + +export const writeTree = async (root: string, files: Readonly>): Promise => { + await Promise.all(Object.entries(files).map(async ([path, contents]) => { + const destination = join(root, ...path.split('/')); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, contents, 'utf8'); + })); +}; + +export const writeCompilerCohort = async ( + compilerRoot: string, + options: Readonly<{ + readonly appFiles?: Readonly>; + readonly rscFiles?: Readonly>; + readonly widgetFiles?: Readonly>; + }> = {}, +): Promise => { + const rscRoot = join(compilerRoot, 'rsc'); + await writeTree(rscRoot, { ...runtimeFiles, ...options.rscFiles }); + await mkdir(join(compilerRoot, 'app'), { recursive: true }); + await writeTree(join(compilerRoot, 'app'), options.appFiles ?? appFiles); + await writeTree(join(compilerRoot, 'widget'), { ...widgetFiles, ...options.widgetFiles }); + await writeFile(join(rscRoot, 'runtime-assets.json'), JSON.stringify({ + allFiles: Object.keys(runtimeFiles).map((path) => `/${path}`), + entries: { + 'dev/definition': { initial: { js: ['/dev/definition.js'] } }, + 'dev/invoke': { initial: { js: ['/dev/invoke.js'] } }, + 'hook/index': { initial: { js: ['/hook/index.js'] } }, + 'mcp/http': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/mcp/http.js'] } }, + 'mcp/stdio': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/mcp/stdio.js'] } }, + 'rsc/index': { async: { js: ['/chunks/101.js'] }, initial: { js: ['/rsc/index.js'] } }, + }, + }), 'utf8'); +};