Skip to content
This repository was archived by the owner on Oct 16, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/dev/dev-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
pollIntervalMs?: number
Expand Down Expand Up @@ -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)
}
Expand Down
23 changes: 19 additions & 4 deletions src/package-app-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -516,6 +524,7 @@ export async function startPackageAppDev(
changedFiles.clear()
rebuildAgain = false
clearWatcherPending?.()
ignoreWatcherUntil = performance.now() + failedPrebuildAbsorbMs
}
}
}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion test/dev-ports-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion test/dev-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading