diff --git a/src/dev/dev-watcher.ts b/src/dev/dev-watcher.ts index f8a8dab..bded64e 100644 --- a/src/dev/dev-watcher.ts +++ b/src/dev/dev-watcher.ts @@ -38,7 +38,11 @@ export type DevWatcher = { export function createDevWatcher(input: { root: string - onChange: (path: string) => void + /** + * Return `false` to leave the poll fingerprint unadvanced so the same + * change is observed again on the next tick (defer, do not drop). + */ + onChange: (path: string) => void | boolean /** Extra root-relative path prefixes to ignore. */ ignore?: ReadonlyArray pollIntervalMs?: number @@ -90,8 +94,12 @@ export function createDevWatcher(input: { if (tracked !== map || generation !== trackGeneration) return if (!map.has(path)) continue if (previous !== null && next !== previous) { + if (tracked !== map || generation !== trackGeneration) return + // Defer (false): do not advance the fingerprint — the next + // poll must still see this change (e.g. failed-prebuild absorb). + if (input.onChange(path) === false) continue + if (tracked !== map || generation !== trackGeneration) return map.set(path, next) - if (tracked === map && generation === trackGeneration) input.onChange(path) } else if (previous === null) { map.set(path, next) } diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 5731253..81712ad 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -201,6 +201,12 @@ type BuildFailure = { const defaultDebounceMs = 30 /** Watcher events this soon after a prebuild finishes are its own writes, not edits. */ const prebuildSettleMs = 150 +/** + * After a *failed* prebuild, ignore watcher events this long. Chokidar/poll + * echoes of partial output can arrive well after {@link prebuildSettleMs} on + * slower CI and would otherwise restart the rebuild loop. + */ +const failedPrebuildAbsorbMs = 1500 /** Longest a request waits for an in-progress rebuild before the old build answers. */ const rebuildGateMs = 10_000 /** Longest a retired generation may keep draining in-flight requests. */ @@ -378,6 +384,8 @@ export async function startPackageAppDev( const prebuild = options.manifest.dev?.prebuild let prebuildRunning = false let prebuildSettledAt = 0 + /** `performance.now()` until which onChange ignores events (failed-prebuild absorb). */ + let ignoreWatcherUntil = 0 let prebuildAbort: AbortController | undefined const workerdEnv: NodeJS.ProcessEnv = { ...process.env, [runSecretEnvVar]: runSecret } delete workerdEnv[apiTokenEnvVar] @@ -516,6 +524,7 @@ export async function startPackageAppDev( changedFiles.clear() rebuildAgain = false clearWatcherPending?.() + ignoreWatcherUntil = performance.now() + failedPrebuildAbsorbMs } } } @@ -896,12 +905,18 @@ export async function startPackageAppDev( ignore: options.manifest.dev?.ignore, onChange: (path) => { if (closed) return - if ( - prebuildRunning || - performance.now() - prebuildSettledAt < prebuildSettleMs - ) { + const now = performance.now() + // Soft absorb: fingerprint already advanced by the + // poller — prebuild's own writes / brief settle chatter. + if (prebuildRunning || now - prebuildSettledAt < prebuildSettleMs) { return } + // Hard defer after a *failed* prebuild: leave the + // poll fingerprint so a real save in this window is + // seen again after absorb (Bugbot: do not drop). + if (now < ignoreWatcherUntil) { + return false + } // Fold mid-build watcher events into the active loop // (including another save of a file already building). // Debouncing them instead schedules a second rebuild diff --git a/test/dev-ports-assets.test.ts b/test/dev-ports-assets.test.ts index 85369d4..e9da0b4 100644 --- a/test/dev-ports-assets.test.ts +++ b/test/dev-ports-assets.test.ts @@ -276,7 +276,9 @@ test('kody.dev.prebuild runs before every build, without retriggering itself', a assert.match(failed.body, /kody\.dev\.prebuild failed \(exit 3\): sh gen\.sh/) assert.match(failed.body, /boom from prebuild/) const rebuildStartsAfterFailure = events.filter((event) => event.type === 'rebuild_start').length - await new Promise((resolve) => setTimeout(resolve, 1200)) + // Longer than failedPrebuildAbsorbMs so late chokidar/poll echoes are + // covered on slow CI (lts/* runners have shown +1 rebuild_start at ~1s). + await new Promise((resolve) => setTimeout(resolve, 2000)) assert.equal( events.filter((event) => event.type === 'rebuild_start').length, rebuildStartsAfterFailure, diff --git a/test/dev-watcher.test.ts b/test/dev-watcher.test.ts index c92d948..b9f2348 100644 --- a/test/dev-watcher.test.ts +++ b/test/dev-watcher.test.ts @@ -15,7 +15,9 @@ test('track detaches the poll map so a concurrent poll cannot echo a rebuild', a await writeFile(file, 'export const n = 1\n') const watcher = createDevWatcher({ root, - onChange: (path) => changes.push(path), + onChange: (path) => { + changes.push(path) + }, pollIntervalMs: 15, }) await watcher.ready