diff --git a/packages/bundler-utoopack/src/adapter/dev-worker-client.ts b/packages/bundler-utoopack/src/adapter/dev-worker-client.ts index 79e218ca..ef080d4b 100644 --- a/packages/bundler-utoopack/src/adapter/dev-worker-client.ts +++ b/packages/bundler-utoopack/src/adapter/dev-worker-client.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; import { Worker } from "node:worker_threads"; import type { ConfigComplete, @@ -56,8 +55,6 @@ export interface UtoopackDevWorkerHandle { /** Rejects on unexpected exit and remains pending after an intentional close. */ failure: Promise; throwIfFailed(): void; - /** Notify the persistent compiler after Core finishes generated input. */ - invalidate(files: readonly string[]): Promise; close(): Promise; } @@ -155,13 +152,6 @@ export function startUtoopackDevWorker( throwIfFailed() { if (failureReason !== undefined) throw failureReason; }, - async invalidate(files) { - for (const file of new Set(files)) { - const stats = await fs.promises.stat(file); - const nextMtimeMs = Math.max(Date.now() + 1_000, stats.mtimeMs + 1_000); - await fs.promises.utimes(file, stats.atime, new Date(nextMtimeMs)); - } - }, close() { closePromise ??= (async () => { closing = true; diff --git a/packages/bundler-utoopack/src/adapter/index.ts b/packages/bundler-utoopack/src/adapter/index.ts index f67c6a34..d0a8e0cf 100644 --- a/packages/bundler-utoopack/src/adapter/index.ts +++ b/packages/bundler-utoopack/src/adapter/index.ts @@ -166,95 +166,6 @@ function waitForPollingDelay( }); } -function collectGeneratedEntryInvalidation( - cwd: string, - plan: BuildPlan, -): { - files: string[]; - statsPaths: string[]; -} { - const generatedRoot = path.resolve(cwd, ".ev"); - const environments = new Set<"client" | "server">(); - const files = new Set(); - for (const entry of plan.entries) { - if (!entry.import.startsWith(".") && !path.isAbsolute(entry.import)) { - continue; - } - const absolute = path.resolve(cwd, entry.import); - const relative = path.relative(generatedRoot, absolute); - if ( - relative === "" || - relative.startsWith(`..${path.sep}`) || - path.isAbsolute(relative) - ) { - continue; - } - files.add(absolute); - environments.add(entry.environment); - } - - const outputPaths = resolveBuildOutputPaths(cwd, plan); - return { - files: [...files], - statsPaths: [ - ...(environments.has("client") - ? [path.join(outputPaths.clientDir, "stats.json")] - : []), - ...(environments.has("server") - ? [path.join(outputPaths.serverDir, "stats.json")] - : []), - ], - }; -} - -async function readStatsVersions( - statsPaths: readonly string[], -): Promise> { - return new Map( - await Promise.all( - statsPaths.map( - async (statsPath) => - [statsPath, await readServerStatsVersion(statsPath)] as const, - ), - ), - ); -} - -async function waitForStatsVersionsToAdvance( - statsPaths: readonly string[], - previous: ReadonlyMap, - timeoutMs: number, - signal?: AbortSignal, -): Promise { - const deadline = Date.now() + timeoutMs; - while (true) { - throwIfPollingAborted( - signal, - "[evjs] Utoopack development session closed during update.", - ); - const current = await readStatsVersions(statsPaths); - if ( - statsPaths.every((statsPath) => { - const version = current.get(statsPath); - return version !== undefined && version !== previous.get(statsPath); - }) - ) { - return; - } - if (Date.now() >= deadline) { - throw new Error( - `[evjs] Timed out waiting for Utoopack to compile final generated input (${statsPaths - .map((statsPath) => JSON.stringify(statsPath)) - .join(", ")}). Restart ev dev to recover safely.`, - ); - } - await waitForPollingDelay( - signal, - "[evjs] Utoopack development session closed during update.", - ); - } -} - function requireUtoopack(): UtoopackRuntime { // @utoo/pack's import condition targets ESM .js files; Node 18 parses them as CJS. return require("@utoo/pack") as UtoopackRuntime; @@ -394,6 +305,7 @@ async function startUtoopackDev( "[evjs] Core discarded the initial Utoopack facts snapshot.", ); } + controller.markBuildPublished(initialFacts, initialServerStatsVersion); worker.throwIfFailed(); if (hasRuntimeServerEntry(plan)) { @@ -401,7 +313,6 @@ async function startUtoopackDev( worker.throwIfFailed(); } if (hasServerEntries(plan)) { - controller.markServerStatsPublished(initialServerStatsVersion); const monitor = startUtoopackServerStatsMonitor({ statsPath: serverStatsPath, initialVersion: initialServerStatsVersion, @@ -473,6 +384,7 @@ class UtoopackDevController implements BundlerDevController { private serverStatsMonitor: UtoopackServerStatsMonitor | undefined; private devWorkQueue: Promise = Promise.resolve(); private pendingPlanTransition: UtoopackDevPlanTransition | undefined; + private publishedFacts: BundlerBuildFacts | undefined; private publishedServerStatsVersion: string | undefined; private closing = false; private closed = false; @@ -502,8 +414,12 @@ class UtoopackDevController implements BundlerDevController { this.serverStatsMonitor = monitor; } - markServerStatsPublished(version: string | undefined): void { - this.publishedServerStatsVersion = version; + markBuildPublished( + facts: BundlerBuildFacts, + serverStatsVersion: string | undefined, + ): void { + this.publishedFacts = facts; + this.publishedServerStatsVersion = serverStatsVersion; } async close(): Promise { @@ -547,14 +463,14 @@ class UtoopackDevController implements BundlerDevController { const initialGeneration = this.options.generation; const transition = createUtoopackDevPlanTransition({ onOpenAccept: () => async () => { - const publish = await this.prepareBuildPublication( + const publish = this.prepareArtifactPublication( initialPlan, initialGeneration, ); await publish(); }, onOpenRollback: () => async () => { - const publish = await this.prepareBuildPublication( + const publish = this.prepareArtifactPublication( initialPlan, initialGeneration, ); @@ -637,8 +553,9 @@ class UtoopackDevController implements BundlerDevController { return false; }); } - // The observed stats may come from any intermediate `.ev` snapshot. Drop - // and acknowledge it only after Core selects and opens the final state. + // The observed stats may come from any intermediate `.ev` snapshot. Defer + // it until Core selects the final state, then leave the monitor baseline + // unchanged so that state is collected on the next polling cycle. return transition.defer(); } @@ -657,6 +574,9 @@ class UtoopackDevController implements BundlerDevController { { isRebuild: true }, facts, ); + if (disposition === "published") { + this.publishedFacts = facts; + } if ( disposition === "published" && hasRuntimeServerEntry(plan) && @@ -688,7 +608,7 @@ class UtoopackDevController implements BundlerDevController { this.options.generation = options.generation; transition.stage({ publish: async () => { - const publish = await this.prepareBuildPublication( + const publish = this.prepareArtifactPublication( update.next, options.generation, ); @@ -699,7 +619,7 @@ class UtoopackDevController implements BundlerDevController { this.options.generation = previousGeneration; if (this.closed) return async () => {}; return async () => { - const publish = await this.prepareBuildPublication( + const publish = this.prepareArtifactPublication( previousPlan, previousGeneration, ); @@ -716,29 +636,6 @@ class UtoopackDevController implements BundlerDevController { } } - private async collectFinalBuildFacts(plan: BuildPlan): Promise<{ - facts: BundlerBuildFacts; - serverStatsVersion: string | undefined; - }> { - await this.waitForFinalCompilerState(plan); - // A later stats version must never be acknowledged for an earlier facts - // snapshot. Reading the version first is deliberately conservative: a - // concurrent rebuild may cause one duplicate cycle, but cannot be lost. - const serverStatsVersion = hasServerEntries(plan) - ? await readServerStatsVersion( - path.join( - resolveBuildOutputPaths(this.options.cwd, plan).serverDir, - "stats.json", - ), - ) - : undefined; - const facts = await this.waitForReadableStats( - plan, - INITIAL_DEV_STATS_TIMEOUT_MS, - ); - return { facts, serverStatsVersion }; - } - waitForReadableStats( plan: BuildPlan, timeoutMs: number, @@ -754,12 +651,19 @@ class UtoopackDevController implements BundlerDevController { ]); } - private async prepareBuildPublication( + private prepareArtifactPublication( plan: BuildPlan, generation: BundlerDevGeneration, - ): Promise<() => Promise> { - const { facts, serverStatsVersion } = - await this.collectFinalBuildFacts(plan); + ): () => Promise { + const facts = this.publishedFacts; + if (!facts) { + throw new Error( + "[evjs] Utoopack cannot relink development artifacts before its initial build facts are published.", + ); + } + // Artifact-only updates preserve entry and output identity. Reuse the last + // published asset inventory to relink framework-owned HTML/manifests while + // Utoopack independently watches and rebuilds generated `.ev` input. return async () => { if (this.closed) return; const disposition = await generateDevArtifacts( @@ -775,39 +679,9 @@ class UtoopackDevController implements BundlerDevController { "[evjs] Core discarded the selected Utoopack facts snapshot before publication completed.", ); } - if (hasRuntimeServerEntry(plan) && !this.closed) { - await this.options.onServerBundleReady(generation); - } - if (hasServerEntries(plan) && !this.closed) { - this.publishedServerStatsVersion = serverStatsVersion; - this.serverStatsMonitor?.advance(serverStatsVersion); - } }; } - private async waitForFinalCompilerState(plan: BuildPlan): Promise { - const invalidation = collectGeneratedEntryInvalidation( - this.options.cwd, - plan, - ); - if (invalidation.files.length === 0) return; - for (let pass = 0; pass < 2; pass += 1) { - this.options.worker.throwIfFailed(); - const versions = await readStatsVersions(invalidation.statsPaths); - await this.options.worker.invalidate(invalidation.files); - await Promise.race([ - waitForStatsVersionsToAdvance( - invalidation.statsPaths, - versions, - INITIAL_DEV_STATS_TIMEOUT_MS, - this.closingController.signal, - ), - this.options.worker.failure, - ]); - } - this.options.worker.throwIfFailed(); - } - private enqueueDevWork(work: () => Promise): Promise { const result = this.devWorkQueue.then(work); this.devWorkQueue = result.then( @@ -857,8 +731,8 @@ function createUtoopackDevPlanTransition(options: { let selectedPublish: (() => void | Promise) | undefined; let aborted = false; const deferred: Array<(consumed: boolean) => void> = []; - const releaseDeferred = () => { - for (const resolve of deferred.splice(0)) resolve(true); + const releaseDeferred = (consumed: boolean) => { + for (const resolve of deferred.splice(0)) resolve(consumed); }; const assertSelectable = (operation: string) => { if ( @@ -909,7 +783,7 @@ function createUtoopackDevPlanTransition(options: { if (aborted || state === "settled") return; aborted = true; options.onSettled(); - releaseDeferred(); + releaseDeferred(true); }, stage(next) { assertSelectable("stage a candidate"); @@ -962,7 +836,10 @@ function createUtoopackDevPlanTransition(options: { } state = "settled"; if (!aborted) options.onSettled(); - releaseDeferred(); + // Accepted state must retry an observation that may have been produced + // before its final `.ev` snapshot was visible. Rollback discards that + // candidate observation and waits for a later restored-state rebuild. + releaseDeferred(outcome !== "accept"); }, }; } diff --git a/packages/bundler-utoopack/tests/adapter.test.ts b/packages/bundler-utoopack/tests/adapter.test.ts index 870e503d..a3d4e28d 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -40,7 +40,6 @@ const utoopackMock = vi.hoisted(() => ({ initialClientStats: undefined as string | undefined, clientStats: undefined as string | undefined, omitClientStats: false, - workerInvalidate: vi.fn(), workerClose: vi.fn(async () => {}), startUtoopackDevWorker: vi.fn( ({ config, server }: { config: ConfigComplete; server: unknown }) => { @@ -61,7 +60,6 @@ const utoopackMock = vi.hoisted(() => ({ rejectReady = reject; }); const runtime = utoopackMock.requireUtoopack(); - let invalidation = 0; void runtime .serve({ config }, undefined, undefined, { ...(server as object), @@ -91,24 +89,6 @@ const utoopackMock = vi.hoisted(() => ({ done: new Promise(() => {}), failure: new Promise(() => {}), throwIfFailed() {}, - async invalidate() { - invalidation += 1; - utoopackMock.workerInvalidate(invalidation); - const outputPaths = [ - config.output?.path, - config.server?.output?.path, - ].filter((outputPath): outputPath is string => Boolean(outputPath)); - for (const outputPath of outputPaths) { - const statsPath = path.join(outputPath, "stats.json"); - const stats = JSON.parse( - await fs.promises.readFile(statsPath, "utf-8"), - ) as Record; - await fs.promises.writeFile( - statsPath, - JSON.stringify({ ...stats, __testInvalidation: invalidation }), - ); - } - }, close: utoopackMock.workerClose, }; }, @@ -272,7 +252,6 @@ afterEach(async () => { utoopackMock.initialClientStats = undefined; utoopackMock.clientStats = undefined; utoopackMock.omitClientStats = false; - utoopackMock.workerInvalidate.mockClear(); utoopackMock.workerClose.mockClear(); utoopackMock.startUtoopackDevWorker.mockClear(); await Promise.all( @@ -565,7 +544,7 @@ describe("utoopackAdapter dev", () => { } }); - it("emits CSR deployment metadata and nested client output", async () => { + it("relinks CSR updates from published facts while the compiler watches independently", async () => { const cwd = await makeProject(); utoopackMock.initialClientStats = "{}"; utoopackMock.clientStatsDelayMs = 75; @@ -608,19 +587,20 @@ describe("utoopackAdapter dev", () => { }, ]; + const framework = createFrameworkCallbacks({ + config, + cwd, + ...buildContext, + hooks, + onBuildOutput, + onDevServerReady, + }); const controller = await utoopackAdapter.dev({ config, cwd, generation: createDevGeneration(), plan: buildContext.plan, - callbacks: createFrameworkCallbacks({ - config, - cwd, - ...buildContext, - hooks, - onBuildOutput, - onDevServerReady, - }), + callbacks: framework, hooks, }); @@ -674,6 +654,18 @@ describe("utoopackAdapter dev", () => { expect(fs.existsSync(path.join(cwd, "dist/client"))).toBe(true); expect(controller).toBeDefined(); if (!controller) throw new Error("Expected Utoopack dev controller"); + const statsPath = path.join(cwd, "dist/client/stats.json"); + const staleStats = JSON.stringify({ + entrypoints: { + main: { assets: [{ name: "stale-main.js" }] }, + }, + }); + await fs.promises.writeFile( + path.join(cwd, "dist/client/stale-main.js"), + "", + "utf-8", + ); + await fs.promises.writeFile(statsPath, staleStats, "utf-8"); const rejectedUpdate = await createDevUpdateOptions( controller, config, @@ -689,17 +681,32 @@ describe("utoopackAdapter dev", () => { await settleDevUpdate(rejectedUpdate, "rollback"); expect(onBuildOutput).toHaveBeenCalledTimes(2); + const nextPlan = structuredClone(buildContext.plan); + if (!nextPlan.generated) { + throw new Error("Expected generated framework plan."); + } + nextPlan.generated.coreGraphHash = "updated-core-graph"; + const update = diffBuildPlan( + buildContext.plan, + nextPlan, + "route-declaration", + ); const appliedUpdate = await createDevUpdateOptions(controller, config); + framework.update(buildContext.graph, nextPlan); await expect( - controller.updatePlan( - diffBuildPlan(buildContext.plan, buildContext.plan, "config"), - appliedUpdate.options, - ), + controller.updatePlan(update, appliedUpdate.options), ).resolves.toBeUndefined(); + expect(update.generatedChanged).toBe(true); expect(appliedUpdate.activate).toHaveBeenCalledOnce(); await settleDevUpdate(appliedUpdate, "accept"); expect(onBuildOutput).toHaveBeenCalledTimes(3); - expect(utoopackMock.workerInvalidate).toHaveBeenCalledTimes(4); + expect(onBuildOutput.mock.calls.at(-1)?.[0].assets.main).toEqual({ + js: ["main.js"], + css: ["main.css"], + }); + await expect(fs.promises.readFile(statsPath, "utf-8")).resolves.toBe( + staleStats, + ); await controller.close?.(); }); @@ -942,6 +949,20 @@ describe("utoopackAdapter dev", () => { const planUpdate = await createDevUpdateOptions(controller, config); framework.update(nextGraph, nextPlan); await controller.updatePlan(update, planUpdate.options); + const serverStatsPath = path.join(cwd, "dist/server/stats.json"); + await fs.promises.writeFile( + serverStatsPath, + JSON.stringify({ + revision: 2, + assets: [{ name: "server.js" }], + entrypoints: { + server: { assets: [{ name: "server.js" }] }, + }, + }), + "utf-8", + ); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(onServerBundleReady).not.toHaveBeenCalled(); await settleDevUpdate(planUpdate, "accept"); expect(update.entries.added).toHaveLength(0); @@ -952,7 +973,12 @@ describe("utoopackAdapter dev", () => { expect(update.serverDocumentsChanged).toBe(false); expect(update.devRoutingChanged).toBe(false); expect(onBuildOutput).toHaveBeenCalledTimes(2); - expect(onServerBundleReady).toHaveBeenCalledTimes(1); + expect(onServerBundleReady).not.toHaveBeenCalled(); + await vi.waitFor( + () => expect(onServerBundleReady).toHaveBeenCalledTimes(1), + { timeout: 2_000 }, + ); + expect(onBuildOutput).toHaveBeenCalledTimes(3); expect(onServerBundleReady).toHaveBeenCalledWith( planUpdate.options.generation, ); @@ -966,6 +992,85 @@ describe("utoopackAdapter dev", () => { } }); + it("drops server stats observed during a rolled-back plan transition", async () => { + const cwd = await makeProject("home"); + const config = await resolveProjectConfig(cwd, { + output: { client: "dist/client", server: "dist/server" }, + routing: { mode: "mpa", html: "./index.html" }, + }); + const baseContext = await createBuildContext(config, cwd); + const serverRuntimeEntry = { + name: "server", + import: "@evjs/ev/_internal/server/fetch", + environment: "server" as const, + runtime: "node" as const, + kind: "server-runtime" as const, + }; + const plan: BuildPlan = { + ...baseContext.plan, + entries: [...baseContext.plan.entries, serverRuntimeEntry], + server: { entry: serverRuntimeEntry.import }, + }; + const generation = createDevGeneration(); + const onBuildOutput = vi.fn(); + const onServerBundleReady = vi.fn(); + const controller = await utoopackAdapter.dev({ + config, + cwd, + generation, + plan, + callbacks: createFrameworkCallbacks({ + config, + cwd, + graph: baseContext.graph, + plan, + onBuildOutput, + onServerBundleReady, + }), + hooks: [], + }); + if (!controller) throw new Error("Expected Utoopack dev controller"); + + try { + onServerBundleReady.mockClear(); + const planUpdate = await createDevUpdateOptions(controller, config); + await controller.updatePlan( + diffBuildPlan(plan, plan, "route-declaration"), + planUpdate.options, + ); + const statsPath = path.join(cwd, "dist/server/stats.json"); + const writeServerStats = (revision: number) => + fs.promises.writeFile( + statsPath, + JSON.stringify({ + revision, + assets: [{ name: "server.js" }], + entrypoints: { + server: { assets: [{ name: "server.js" }] }, + }, + }), + "utf-8", + ); + + await writeServerStats(2); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(onServerBundleReady).not.toHaveBeenCalled(); + await settleDevUpdate(planUpdate, "rollback"); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(onServerBundleReady).not.toHaveBeenCalled(); + + await writeServerStats(3); + await vi.waitFor( + () => expect(onServerBundleReady).toHaveBeenCalledTimes(1), + { timeout: 2_000 }, + ); + expect(onServerBundleReady).toHaveBeenCalledWith(generation); + expect(onBuildOutput).toHaveBeenCalledTimes(3); + } finally { + await controller.close?.(); + } + }); + it("fails clearly for entry-changing dev plan updates", async () => { const cwd = await makeProject("home"); const config = await resolveProjectConfig(cwd, { diff --git a/packages/ev/src/_internal/build/bundler.ts b/packages/ev/src/_internal/build/bundler.ts index 6f4874c9..4e67a2a6 100644 --- a/packages/ev/src/_internal/build/bundler.ts +++ b/packages/ev/src/_internal/build/bundler.ts @@ -218,7 +218,9 @@ export interface BundlerDevContext */ onDevServerReady?: (context: { origin: string }) => void | Promise; /** - * Called by the bundler adapter after a dev compile has fresh build facts. + * Called by the bundler adapter after a dev compile has fresh build facts, + * or with previously published facts that remain valid across a proven + * topology-preserving artifact update. * The ev orchestrator owns beforeBuild, framework output linking, * transformOutput, manifest emission, and HTML emission. Adapters may * acknowledge facts or notify server readiness only after `published`; @@ -262,7 +264,9 @@ export interface BundlerDevUpdateOptions { * inputs. Core explicitly accepts the final input or rolls back only after it * has restored the previous generated state. Adapters must drop any compile * that could have observed input while this boundary was active, then obtain - * fresh facts for the selected state. + * fresh facts for the selected state. A topology-preserving artifact update + * may instead relink with the last published facts while the compiler handles + * its generated-input rebuild independently. */ export interface BundlerDevUpdateTransition { /** Select the final generated input while keeping the current generation. */ diff --git a/packages/ev/src/_internal/build/commands.ts b/packages/ev/src/_internal/build/commands.ts index 9cbba0ec..33854868 100644 --- a/packages/ev/src/_internal/build/commands.ts +++ b/packages/ev/src/_internal/build/commands.ts @@ -66,6 +66,7 @@ import { type PreparedWatchFilesPlan, prepareWatchFilesPlan, type RouteDirectoryWatchState, + resolveInitialDevWatchMode, type WatchFilesPlan, watchFiles, } from "./dev-watch.js"; @@ -1442,7 +1443,7 @@ async function runDevSession( resolveDevWatchFailure = resolve; }); let devWatchFailed = false; - let devWatchMode: "events" | "polling" = "events"; + let devWatchMode = resolveInitialDevWatchMode(); const reportDevWatchFailure = (failure: unknown) => { if (devWatchFailed) return; devWatchFailed = true; diff --git a/packages/ev/src/_internal/build/dev-watch.ts b/packages/ev/src/_internal/build/dev-watch.ts index b5ad1a08..8a902259 100644 --- a/packages/ev/src/_internal/build/dev-watch.ts +++ b/packages/ev/src/_internal/build/dev-watch.ts @@ -9,8 +9,10 @@ export interface RouteDirectoryWatchState { unsafeBoundary?: string; } +export type WatchFilesMode = "events" | "polling"; + export interface WatchFilesOptions { - readonly mode?: "events" | "polling"; + readonly mode?: WatchFilesMode; readonly onError: (error: Error) => void; readonly onFallback?: (error: Error) => void; readonly recoverableMissingTargets?: ReadonlySet; @@ -72,9 +74,25 @@ interface StartPollingOptions { readonly invalidateChangedTargets?: boolean; } +interface PollingResourceRetryState { + readonly failureCount: number; + readonly retryAt: number; +} + const POLLING_INTERVAL_MS = 100; +const MAX_POLLING_RESOURCE_RETRY_MS = 2_000; const POLLING_READ_CANCELLED = Symbol("polling-read-cancelled"); +export function resolveInitialDevWatchMode( + platform: NodeJS.Platform = process.platform, + sandbox: string | undefined = process.env.CODEX_SANDBOX, +): WatchFilesMode { + // Codex's macOS Seatbelt profile currently denies the FSEvents service used + // by directory fs.watch(), which Node reports as a misleading EMFILE error. + // Skip that known-to-fail probe and use EVJS's existing polling backend. + return platform === "darwin" && sandbox === "seatbelt" ? "polling" : "events"; +} + export function listConfigDependencyFiles(cwd: string): string[] { return ["ev.config.ts", "ev.config.js", "ev.config.mjs"].map((file) => path.resolve(cwd, file), @@ -91,7 +109,7 @@ export function createWatchFilesPlan( const groups = new Map(); const logicalFiles = [ ...new Set(files.map((authoredFile) => path.resolve(authoredFile))), - ]; + ].sort(); const logicalTargets: string[] = []; const signatures: string[] = []; const watchTargetIdentities = new Map(); @@ -273,6 +291,7 @@ export function watchFiles( plan; const watchers: fs.FSWatcher[] = []; const pollingSnapshots = new Map(); + const pollingResourceRetries = new Map(); let eventWatchersClosed = false; let polling = false; let pollingTask: Promise | undefined; @@ -301,6 +320,7 @@ export function watchFiles( if (pollingTimer) clearTimeout(pollingTimer); pollingTimer = undefined; pollingSnapshots.clear(); + pollingResourceRetries.clear(); return []; }; @@ -386,6 +406,8 @@ export function watchFiles( const nextSnapshots = new Map(); for (const file of logicalTargets) { if (stopped || !polling) return; + const resourceRetry = pollingResourceRetries.get(file); + if (resourceRetry && resourceRetry.retryAt > Date.now()) continue; try { const snapshot = await readPollingSnapshotAsync( file, @@ -393,9 +415,21 @@ export function watchFiles( () => !stopped && polling, ); if (snapshot === POLLING_READ_CANCELLED) return; + pollingResourceRetries.delete(file); nextSnapshots.set(file, snapshot); } catch (error) { - if (isWatchResourceError(error)) continue; + if (isWatchResourceError(error)) { + const failureCount = (resourceRetry?.failureCount ?? 0) + 1; + const retryDelay = Math.min( + POLLING_INTERVAL_MS * 2 ** Math.min(failureCount, 5), + MAX_POLLING_RESOURCE_RETRY_MS, + ); + pollingResourceRetries.set(file, { + failureCount, + retryAt: Date.now() + retryDelay, + }); + continue; + } if (!stopped && polling) reportFailure(file, error); return; } @@ -583,9 +617,20 @@ export function watchFiles( }; const watcher = fs.watch(watchTarget, listener); watchers.push(watcher); + watcher.once("close", () => { + if (eventWatchersClosed || stopped || polling) return; + startPolling({ + fallbackError: createWatchError( + watchTarget, + new Error("Native filesystem watcher closed unexpectedly."), + ), + forceInvalidateTargets: new Set(targets.map((target) => target.file)), + invalidateChangedTargets: true, + }); + }); watcher.on("error", (error) => { if (eventWatchersClosed) return; - if (isWatchResourceError(error)) { + if (isNativeWatchUnavailableError(error)) { startPolling({ fallbackError: createWatchError(watchTarget, error), invalidateChangedTargets: true, @@ -605,7 +650,7 @@ export function watchFiles( if (recovery.failure) throw recovery.failure; break; } - if (isWatchResourceError(error)) { + if (isNativeWatchUnavailableError(error)) { const pollingFailure = startPolling({ fallbackError: createWatchError(watchTarget, error), invalidateChangedTargets: true, @@ -850,7 +895,13 @@ function readSymlinkResolutionChain(target: string): SymlinkBoundary[] { visitedStates.add(state); followedLinks += 1; - const linkTarget = fs.readlinkSync(candidate); + let linkTarget: string; + try { + linkTarget = fs.readlinkSync(candidate); + } catch (error) { + if (isReadlinkTopologyRaceError(error)) return boundaries; + throw error; + } if (!boundaryPaths.has(candidate)) { const ownIdentity = serializeWatchIdentity(own); boundaries.push({ @@ -902,6 +953,21 @@ function isWatchResourceError(error: unknown): boolean { return code === "EMFILE" || code === "ENFILE" || code === "ENOSPC"; } +function isNativeWatchUnavailableError(error: unknown): boolean { + if (isWatchResourceError(error)) return true; + const code = (error as NodeJS.ErrnoException).code; + return ( + code === "ENOSYS" || + code === "ENOTSUP" || + code === "EOPNOTSUPP" || + code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM" + ); +} + +function isReadlinkTopologyRaceError(error: unknown): boolean { + return isMissingPathError(error) || isErrnoCode(error, "EINVAL"); +} + function readPollingSnapshot(file: string): string { let own: fs.BigIntStats; try { @@ -945,7 +1011,11 @@ function readMissingPollingSnapshot(file: string): string { let linkTarget: string | undefined; let target: fs.BigIntStats | undefined; if (own.isSymbolicLink()) { - linkTarget = fs.readlinkSync(ancestor); + try { + linkTarget = fs.readlinkSync(ancestor); + } catch (error) { + if (!isReadlinkTopologyRaceError(error)) throw error; + } try { target = fs.statSync(ancestor, { bigint: true }); } catch (error) { @@ -1155,7 +1225,7 @@ function readCachedReadlink( const cached = cache.readlinks.get(file); if (cached) return cached; const pending = fs.promises.readlink(file).catch((error: unknown) => { - if (isMissingPathError(error)) return undefined; + if (isReadlinkTopologyRaceError(error)) return undefined; throw error; }); cache.readlinks.set(file, pending); diff --git a/packages/ev/tests/commands.test.ts b/packages/ev/tests/commands.test.ts index 9c5b64b9..03e8d357 100644 --- a/packages/ev/tests/commands.test.ts +++ b/packages/ev/tests/commands.test.ts @@ -12,7 +12,7 @@ import { } from "@evjs/shared/manifest"; import { configureSync, resetSync } from "@logtape/logtape"; import { execa } from "execa"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPageClientBuildEntryName, createPageServerBuildEntryName, @@ -78,6 +78,16 @@ const fullBundlerCapabilities = { const BUILD_OUTPUT_HOOK_OWNERSHIP_ERROR = "[evjs] transformOutput hooks cannot change non-asset BuildOutput fields. Hooks may only adjust existing AssetGroup contents or deployment metadata."; +beforeEach(() => { + // Exercise native event watching by default regardless of whether the test + // runner itself is hosted in Codex's macOS Seatbelt sandbox. + vi.stubEnv("CODEX_SANDBOX", ""); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + type TestDevTransitionOutcome = "accept" | "rollback"; function createTestDevController( @@ -8839,6 +8849,62 @@ describe("dev", { timeout: devUpdateTimeoutMs + 5_000 }, () => { } }); + it.runIf(process.platform === "darwin")( + "starts with polling in the Codex Seatbelt sandbox", + async () => { + vi.stubEnv("CODEX_SANDBOX", "seatbelt"); + const cwd = await createSpaProject(); + const events: string[] = []; + const watchSpy = vi.spyOn(fs, "watch").mockImplementation((() => { + throw new Error("fs.watch should not run in the Seatbelt sandbox"); + }) as never); + const bundler = createRouteUpdateBundler(cwd, events, "/admin"); + const running = dev( + { + output: { client: "dist/client", server: "dist/server" }, + routing: { mode: "spa" }, + }, + { cwd, bundler }, + ); + let settled = false; + void running.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + let timeout: ReturnType | undefined; + + try { + await waitForEvent(events, "initial:/"); + await writeFile( + path.join(cwd, "src/pages/admin/page.tsx"), + "export default function Admin() { return null; }", + "utf-8", + ); + await Promise.race([ + running, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("Seatbelt polling update timed out")), + devUpdateTimeoutMs, + ); + }), + ]); + expect(watchSpy).not.toHaveBeenCalled(); + } finally { + if (timeout) clearTimeout(timeout); + if (!settled) { + process.emit("SIGINT"); + await running.catch(() => {}); + } + watchSpy.mockRestore(); + } + }, + ); + it("falls back to polling when dependency event watchers exhaust resources", async () => { const cwd = await createSpaProject(); const events: string[] = []; @@ -12819,6 +12885,7 @@ describe("dev", { timeout: devUpdateTimeoutMs + 5_000 }, () => { const cwd = await createSpaProject(); const dependency = path.join(cwd, "bundler-plugin.config.json"); await writeFile(dependency, '{"mode":"initial"}', "utf-8"); + const controlledWatch = installControlledFsWatch(); const events: string[] = []; const createSnapshotPlugin = ( @@ -12943,6 +13010,7 @@ describe("dev", { timeout: devUpdateTimeoutMs + 5_000 }, () => { await waitForEvent(events, "bundler.dev"); currentConfig = nextConfig; await writeFile(dependency, '{"mode":"changed"}', "utf-8"); + await controlledWatch.dispatchFileChange(dependency); await waitForEvent(events, "dispose:old:initial"); const emitRetired = emitRetiredFacts; @@ -12958,6 +13026,7 @@ describe("dev", { timeout: devUpdateTimeoutMs + 5_000 }, () => { process.emit("SIGINT"); await running.catch(() => {}); } + controlledWatch.restore(); } expect(events).toEqual([ diff --git a/packages/ev/tests/dev-watch.test.ts b/packages/ev/tests/dev-watch.test.ts index 5b6ff925..1726768b 100644 --- a/packages/ev/tests/dev-watch.test.ts +++ b/packages/ev/tests/dev-watch.test.ts @@ -9,6 +9,7 @@ import { createWatchFilesPlan, listConfigDependencyFiles, prepareWatchFilesPlan, + resolveInitialDevWatchMode, watchFiles, } from "../src/_internal/build/dev-watch.js"; import { resolveConfig } from "../src/config/index.js"; @@ -35,6 +36,7 @@ class FakeWatcher extends EventEmitter { const temporaryDirectories: string[] = []; afterEach(async () => { + vi.useRealTimers(); vi.restoreAllMocks(); await Promise.all( temporaryDirectories @@ -90,6 +92,15 @@ async function waitForChange( } } +describe("resolveInitialDevWatchMode", () => { + it("preselects polling only for the macOS Codex Seatbelt sandbox", () => { + expect(resolveInitialDevWatchMode("darwin", "seatbelt")).toBe("polling"); + expect(resolveInitialDevWatchMode("darwin", "")).toBe("events"); + expect(resolveInitialDevWatchMode("darwin", "landlock")).toBe("events"); + expect(resolveInitialDevWatchMode("linux", "seatbelt")).toBe("events"); + }); +}); + describe("watchFiles", () => { it("lists every supported config candidate even when none exists", async () => { const root = await createTemporaryDirectory(); @@ -116,6 +127,32 @@ describe("watchFiles", () => { expect(collectWatchFilesChangedSince(current, current)).toEqual([]); }); + it("canonicalizes dependency order without erasing recovery semantics", async () => { + const root = await createTemporaryDirectory(); + const first = path.join(root, "first.ts"); + const second = path.join(root, "second.ts"); + await writeFile(first); + await writeFile(second); + + const firstPlan = createWatchFilesPlan( + [second, first, second], + new Set([first]), + ); + const reorderedPlan = createWatchFilesPlan( + [first, second, first], + new Set([first]), + ); + const changedRecoveryPlan = createWatchFilesPlan( + [first, second], + new Set([second]), + ); + + expect(firstPlan.logicalTargets).toEqual([first, second]); + expect(reorderedPlan.logicalTargets).toEqual(firstPlan.logicalTargets); + expect(reorderedPlan.key).toBe(firstPlan.key); + expect(changedRecoveryPlan.key).not.toBe(firstPlan.key); + }); + it("reconciles a resource-unknown snapshot once it becomes readable", async () => { const root = await createTemporaryDirectory(); const directory = path.join(root, "pages"); @@ -558,6 +595,113 @@ describe("watchFiles", () => { }, ); + it.runIf(process.platform !== "win32")( + "survives a missing target's symlink ancestor being atomically replaced", + async () => { + const root = await createTemporaryDirectory(); + const target = path.join(root, "target"); + const link = path.join(root, "link"); + const missing = path.join(link, "dependency.ts"); + await fs.promises.mkdir(target); + await fs.promises.symlink(target, link, "dir"); + + const originalRealpath = fs.promises.realpath; + let replaced = false; + vi.spyOn(fs.promises, "realpath").mockImplementation((async ( + ...args: unknown[] + ) => { + const result = await Reflect.apply(originalRealpath, fs.promises, args); + if (!replaced && path.resolve(String(args[0])) === link) { + replaced = true; + await fs.promises.unlink(link); + await fs.promises.mkdir(link); + } + return result; + }) as typeof fs.promises.realpath); + + const changes: string[] = []; + const errors: Error[] = []; + const stop = watchFiles( + [missing], + (changedFile) => changes.push(changedFile), + { + mode: "polling", + onError: (error) => errors.push(error), + recoverableMissingTargets: new Set([missing]), + }, + ); + + try { + await vi.waitFor(() => expect(replaced).toBe(true), { + interval: 20, + timeout: 2_000, + }); + await waitForChange(changes, missing); + await new Promise((resolve) => setTimeout(resolve, 250)); + changes.length = 0; + + await fs.promises.writeFile(missing, "created", "utf-8"); + await waitForChange(changes, missing); + expect(errors).toEqual([]); + } finally { + stop(); + } + }, + ); + + it("backs off resource-limited polling targets without delaying healthy ones", async () => { + vi.useFakeTimers(); + const root = await createTemporaryDirectory(); + const limited = path.join(root, "limited.ts"); + const healthy = path.join(root, "healthy.ts"); + await writeFile(limited); + await writeFile(healthy); + + let resourceLimited = true; + let limitedReads = 0; + let healthyReads = 0; + vi.spyOn(fs.promises, "lstat").mockImplementation((async ( + ...args: unknown[] + ) => { + const target = path.resolve(String(args[0])); + if (target === limited) { + limitedReads += 1; + if (resourceLimited) throw createErrnoError("EMFILE"); + } + if (target === healthy) healthyReads += 1; + return fs.lstatSync(target, { bigint: true }); + }) as typeof fs.promises.lstat); + + const changes: string[] = []; + const errors: Error[] = []; + const stop = watchFiles( + [limited, healthy], + (changedFile) => changes.push(changedFile), + { + mode: "polling", + onError: (error) => errors.push(error), + }, + ); + + try { + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(400); + expect(limitedReads).toBe(3); + expect(healthyReads).toBe(7); + expect(changes).toEqual([]); + + resourceLimited = false; + await fs.promises.writeFile(limited, "changed after pressure", "utf-8"); + await vi.advanceTimersByTimeAsync(900); + expect(limitedReads).toBeGreaterThanOrEqual(4); + expect(changes).toEqual([limited]); + expect(errors).toEqual([]); + } finally { + stop(); + } + }); + it.each([ "EACCES", "EPERM", @@ -599,7 +743,11 @@ describe("watchFiles", () => { "EMFILE", "ENFILE", "ENOSPC", - ])("falls back to polling after setup resource error %s", async (code) => { + "ENOSYS", + "ENOTSUP", + "EOPNOTSUPP", + "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM", + ])("falls back to polling after native watch setup error %s", async (code) => { const root = await createTemporaryDirectory(); const firstDirectory = path.join(root, "first"); const secondDirectory = path.join(root, "second"); @@ -716,7 +864,9 @@ describe("watchFiles", () => { }); expect(onError).not.toHaveBeenCalled(); - expect(records).toEqual([]); + expect(records).toHaveLength(1); + expect(records[0]?.target).toBe(liveDirectory); + expect(records[0]?.watcher.closeCalls).toBe(1); expect(changes).toEqual([missingRace]); changes.length = 0; try { @@ -766,7 +916,11 @@ describe("watchFiles", () => { "EMFILE", "ENFILE", "ENOSPC", - ])("falls back to polling after asynchronous resource error %s", async (code) => { + "ENOSYS", + "ENOTSUP", + "EOPNOTSUPP", + "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM", + ])("falls back to polling after asynchronous native watch error %s", async (code) => { const root = await createTemporaryDirectory(); const first = path.join(root, "first", "first.ts"); const second = path.join(root, "second", "second.ts"); @@ -801,6 +955,48 @@ describe("watchFiles", () => { expect(changes).toEqual([second]); }); + it("falls back to polling when a native watcher closes unexpectedly", async () => { + const root = await createTemporaryDirectory(); + const first = path.join(root, "first", "first.ts"); + const second = path.join(root, "second", "second.ts"); + await writeFile(first); + await writeFile(second); + + const records = mockWatch(); + const changes: string[] = []; + const errors: Error[] = []; + const onFallback = vi.fn(); + const stop = watchFiles( + [first, second], + (changedFile) => changes.push(changedFile), + { + onError: (error) => errors.push(error), + onFallback, + }, + ); + + expect(records).toHaveLength(2); + records[0]?.watcher.emit("close"); + expect(onFallback).toHaveBeenCalledTimes(1); + expect(onFallback.mock.calls[0]?.[0].message).toContain( + "closed unexpectedly", + ); + expect(records.map((record) => record.watcher.closeCalls)).toEqual([1, 1]); + expect(changes).toEqual([first]); + changes.length = 0; + + try { + await fs.promises.writeFile(second, "changed", "utf-8"); + await waitForChange(changes, second); + expect(errors).toEqual([]); + } finally { + stop(); + } + + records[1]?.watcher.emit("close"); + expect(onFallback).toHaveBeenCalledTimes(1); + }); + it("recovers an asynchronous EPERM after watch target replacement", async () => { const root = await createTemporaryDirectory(); const staleDirectory = path.join(root, "stale");