Skip to content

Commit fd00dd6

Browse files
committed
fix(watch): bound observer startup readiness
1 parent d36c33f commit fd00dd6

3 files changed

Lines changed: 235 additions & 12 deletions

File tree

src/core/watchController.test.ts

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
22

33
import {
44
createWatchController,
5+
WATCH_EVENT_SOURCE_STARTUP_TIMEOUT_CODE,
56
type WatchControllerClock,
67
type WatchEventSourceCallbacks,
78
} from "./watchController";
@@ -326,6 +327,118 @@ describe("createWatchController", () => {
326327
expect(checks).toBe(1);
327328
});
328329

330+
test("degrades a stalled source after the default startup deadline", async () => {
331+
const clock = new FakeWatchClock();
332+
const source = fakeSource();
333+
const errors: unknown[] = [];
334+
let checks = 0;
335+
const controller = createWatchController({
336+
initialSignature: "same",
337+
clock,
338+
createEventSource: source.create,
339+
getSignature: () => (++checks, "same"),
340+
refresh: () => {},
341+
reportError: (error) => errors.push(error),
342+
});
343+
344+
clock.advance(1_999);
345+
expect(controller.getState().degraded).toBe(false);
346+
expect(source.closes).toBe(0);
347+
clock.advance(1);
348+
expect(controller.getState().degraded).toBe(true);
349+
expect(source.closes).toBe(1);
350+
expect(errors).toHaveLength(1);
351+
expect(errors[0]).toMatchObject({ code: WATCH_EVENT_SOURCE_STARTUP_TIMEOUT_CODE });
352+
353+
clock.advance(1_999);
354+
expect(checks).toBe(0);
355+
clock.advance(1);
356+
await settle();
357+
expect(checks).toBe(1);
358+
clock.advance(2_000);
359+
await settle();
360+
expect(checks).toBe(2);
361+
});
362+
363+
test("accepts readiness just before an injected startup deadline", async () => {
364+
const clock = new FakeWatchClock();
365+
const source = fakeSource();
366+
let checks = 0;
367+
const controller = createWatchController({
368+
initialSignature: "same",
369+
clock,
370+
createEventSource: source.create,
371+
getSignature: () => (++checks, "same"),
372+
refresh: () => {},
373+
startupTimeoutMs: 500,
374+
});
375+
376+
clock.advance(499);
377+
source.ready();
378+
await settle();
379+
clock.advance(1);
380+
expect(checks).toBe(1);
381+
expect(source.closes).toBe(0);
382+
expect(controller.getState().degraded).toBe(false);
383+
});
384+
385+
test("ignores readiness, events, and duplicate errors after startup degradation", async () => {
386+
const clock = new FakeWatchClock();
387+
const source = fakeSource();
388+
const errors: unknown[] = [];
389+
let checks = 0;
390+
const controller = createWatchController({
391+
initialSignature: "same",
392+
clock,
393+
createEventSource: source.create,
394+
getSignature: () => (++checks, "same"),
395+
refresh: () => {},
396+
reportError: (error) => errors.push(error),
397+
startupTimeoutMs: 500,
398+
});
399+
400+
clock.advance(500);
401+
source.ready();
402+
source.event();
403+
source.error(Object.assign(new Error("late resource error"), { code: "ENOSPC" }));
404+
source.error(Object.assign(new Error("duplicate late error"), { code: "ENOSPC" }));
405+
clock.advance(1_999);
406+
await settle();
407+
expect(checks).toBe(0);
408+
expect(source.closes).toBe(1);
409+
expect(errors).toHaveLength(1);
410+
expect(errors[0]).toMatchObject({ code: WATCH_EVENT_SOURCE_STARTUP_TIMEOUT_CODE });
411+
expect(controller.getState().degraded).toBe(true);
412+
413+
clock.advance(1);
414+
await settle();
415+
expect(checks).toBe(1);
416+
});
417+
418+
test("closing during startup cancels the deadline and closes the source once", () => {
419+
const clock = new FakeWatchClock();
420+
const source = fakeSource();
421+
const errors: unknown[] = [];
422+
const controller = createWatchController({
423+
initialSignature: "same",
424+
clock,
425+
createEventSource: source.create,
426+
getSignature: () => "same",
427+
refresh: () => {},
428+
reportError: (error) => errors.push(error),
429+
startupTimeoutMs: 500,
430+
});
431+
432+
controller.close();
433+
source.ready();
434+
source.event();
435+
source.error(new Error("late"));
436+
clock.advance(10_000);
437+
expect(source.closes).toBe(1);
438+
expect(errors).toEqual([]);
439+
expect(clock.timers.size).toBe(0);
440+
});
441+
329442
test("runs a healthy safety check without an event", async () => {
330443
const clock = new FakeWatchClock();
331444
let checks = 0;
@@ -472,6 +585,7 @@ describe("createWatchController", () => {
472585
const clock = new FakeWatchClock();
473586
const oldSource = fakeSource();
474587
const newSource = fakeSource();
588+
const errors: unknown[] = [];
475589
let oldChecks = 0;
476590
let newChecks = 0;
477591
const oldController = createWatchController({
@@ -480,6 +594,8 @@ describe("createWatchController", () => {
480594
createEventSource: oldSource.create,
481595
getSignature: () => (++oldChecks, "old"),
482596
refresh: () => {},
597+
reportError: (error) => errors.push(error),
598+
startupTimeoutMs: 500,
483599
});
484600
oldController.close();
485601
createWatchController({
@@ -488,13 +604,19 @@ describe("createWatchController", () => {
488604
createEventSource: newSource.create,
489605
getSignature: () => (++newChecks, "new"),
490606
refresh: () => {},
607+
startupTimeoutMs: 500,
491608
});
492609

610+
oldSource.ready();
493611
oldSource.event();
494-
newSource.event();
495-
clock.advance(200);
612+
newSource.ready();
613+
await settle();
614+
clock.advance(500);
496615
await settle();
497616
expect(oldChecks).toBe(0);
498617
expect(newChecks).toBe(1);
618+
expect(oldSource.closes).toBe(1);
619+
expect(newSource.closes).toBe(0);
620+
expect(errors).toEqual([]);
499621
});
500622
});

src/core/watchController.ts

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export interface WatchEventSourceCallbacks {
2222
onReady?(): void;
2323
}
2424

25+
export const DEFAULT_WATCH_EVENT_SOURCE_STARTUP_TIMEOUT_MS = 2_000;
26+
export const WATCH_EVENT_SOURCE_STARTUP_TIMEOUT_CODE = "HUNK_WATCH_EVENT_SOURCE_STARTUP_TIMEOUT";
27+
2528
export interface WatchControllerOptions {
2629
initialSignature: string;
2730
getSignature: () => string | Promise<string>;
@@ -35,6 +38,7 @@ export interface WatchControllerOptions {
3538
healthyCheckMs?: number;
3639
degradedCheckMs?: number;
3740
duplicateErrorIntervalMs?: number;
41+
startupTimeoutMs?: number;
3842
}
3943

4044
export interface WatchControllerState {
@@ -69,6 +73,17 @@ function getErrorKey(error: unknown) {
6973
return String(error);
7074
}
7175

76+
/** Build the stable diagnostic reported when an event source cannot establish readiness. */
77+
function createEventSourceStartupTimeoutError(timeoutMs: number) {
78+
return Object.assign(
79+
new Error(`The watch event source did not become ready within ${timeoutMs} ms.`),
80+
{
81+
code: WATCH_EVENT_SOURCE_STARTUP_TIMEOUT_CODE,
82+
name: "WatchEventSourceStartupTimeoutError",
83+
},
84+
);
85+
}
86+
7287
/** Coordinate event hints and periodic checks without coupling to a watcher backend. */
7388
export function createWatchController(options: WatchControllerOptions): WatchController {
7489
const clock = options.clock ?? defaultClock;
@@ -77,6 +92,8 @@ export function createWatchController(options: WatchControllerOptions): WatchCon
7792
const healthyCheckMs = options.healthyCheckMs ?? 10_000;
7893
const degradedCheckMs = options.degradedCheckMs ?? 2_000;
7994
const duplicateErrorIntervalMs = options.duplicateErrorIntervalMs ?? 10_000;
95+
const startupTimeoutMs =
96+
options.startupTimeoutMs ?? DEFAULT_WATCH_EVENT_SOURCE_STARTUP_TIMEOUT_MS;
8097

8198
const state: WatchControllerState = {
8299
phase: "starting",
@@ -85,8 +102,10 @@ export function createWatchController(options: WatchControllerOptions): WatchCon
85102
appliedSignature: options.initialSignature,
86103
};
87104
let eventSource: WatchEventSource | undefined;
105+
let sourceStatus: "none" | "starting" | "ready" | "closed" = "none";
88106
let timer: unknown;
89107
let timerDeadline: number | undefined;
108+
let startupDeadline: number | undefined;
90109
let quietDeadline: number | undefined;
91110
let maximumDeadline: number | undefined;
92111
let safetyDeadline: number | undefined;
@@ -116,7 +135,7 @@ export function createWatchController(options: WatchControllerOptions): WatchCon
116135
if (state.phase === "closed" || state.phase === "checking" || state.phase === "refreshing") {
117136
return;
118137
}
119-
const deadlines = [quietDeadline, maximumDeadline, safetyDeadline].filter(
138+
const deadlines = [startupDeadline, quietDeadline, maximumDeadline, safetyDeadline].filter(
120139
(deadline): deadline is number => deadline !== undefined,
121140
);
122141
if (deadlines.length === 0) return;
@@ -130,6 +149,19 @@ export function createWatchController(options: WatchControllerOptions): WatchCon
130149
/** Test closure across async boundaries without relying on narrowed phase state. */
131150
const isClosed = () => state.phase === "closed";
132151

152+
/** Test source closure after construction callbacks that TypeScript cannot observe. */
153+
const isSourceClosed = () => sourceStatus === "closed";
154+
155+
/** Close the current source once and invalidate all of its later callbacks. */
156+
const closeEventSource = () => {
157+
if (sourceStatus === "none" || sourceStatus === "closed") return;
158+
sourceStatus = "closed";
159+
startupDeadline = undefined;
160+
const source = eventSource;
161+
eventSource = undefined;
162+
source?.close();
163+
};
164+
133165
/** Finish work and honor all in-flight hints as one trailing check. */
134166
const finishCheck = () => {
135167
if (isClosed()) return;
@@ -183,12 +215,29 @@ export function createWatchController(options: WatchControllerOptions): WatchCon
183215
finishCheck();
184216
};
185217

186-
/** Consume a due debounce, maximum-delay, or safety deadline as one check. */
218+
/** Degrade one source that failed to establish readiness before its deadline. */
219+
const degradeStalledSource = () => {
220+
if (sourceStatus !== "starting") return;
221+
closeEventSource();
222+
state.degraded = true;
223+
state.phase = "idle";
224+
quietDeadline = undefined;
225+
maximumDeadline = undefined;
226+
safetyDeadline = clock.now() + degradedCheckMs;
227+
reportError(createEventSourceStartupTimeoutError(startupTimeoutMs));
228+
schedule();
229+
};
230+
231+
/** Consume a due startup, debounce, maximum-delay, or safety deadline as one check. */
187232
function onTimer() {
188233
timer = undefined;
189234
timerDeadline = undefined;
190235
if (state.phase === "closed") return;
191236
const now = clock.now();
237+
if (startupDeadline !== undefined && startupDeadline <= now) {
238+
degradeStalledSource();
239+
return;
240+
}
192241
const eventDue =
193242
(quietDeadline !== undefined && quietDeadline <= now) ||
194243
(maximumDeadline !== undefined && maximumDeadline <= now);
@@ -202,7 +251,9 @@ export function createWatchController(options: WatchControllerOptions): WatchCon
202251

203252
/** Treat an event as a hint and retain the first event's maximum deadline. */
204253
const onEvent = () => {
205-
if (state.phase === "closed") return;
254+
if (state.phase === "closed" || (sourceStatus !== "starting" && sourceStatus !== "ready")) {
255+
return;
256+
}
206257
if (state.phase === "checking" || state.phase === "refreshing") {
207258
state.dirty = true;
208259
return;
@@ -216,7 +267,9 @@ export function createWatchController(options: WatchControllerOptions): WatchCon
216267

217268
/** Close the startup scan race with an immediate signature check after watcher readiness. */
218269
const onSourceReady = () => {
219-
if (state.phase === "closed") return;
270+
if (state.phase === "closed" || sourceStatus !== "starting") return;
271+
sourceStatus = "ready";
272+
startupDeadline = undefined;
220273
if (state.phase === "checking" || state.phase === "refreshing") {
221274
state.dirty = true;
222275
return;
@@ -226,12 +279,11 @@ export function createWatchController(options: WatchControllerOptions): WatchCon
226279

227280
/** Degrade only for watcher resource exhaustion; other source errors stay nonfatal. */
228281
const onSourceError = (error: unknown) => {
229-
if (state.phase === "closed") return;
282+
if (state.phase === "closed" || sourceStatus === "closed") return;
230283
const code = getErrorCode(error);
231284
if (code === "ENOSPC" || code === "EMFILE") {
232285
state.degraded = true;
233-
eventSource?.close();
234-
eventSource = undefined;
286+
closeEventSource();
235287
safetyDeadline = Math.min(
236288
safetyDeadline ?? Number.POSITIVE_INFINITY,
237289
clock.now() + degradedCheckMs,
@@ -244,13 +296,22 @@ export function createWatchController(options: WatchControllerOptions): WatchCon
244296
state.phase = "idle";
245297
safetyDeadline = clock.now() + safetyInterval();
246298
if (options.createEventSource && !options.pollOnly) {
299+
sourceStatus = "starting";
300+
startupDeadline = clock.now() + startupTimeoutMs;
301+
// This JS timer is a secondary guard: FSEvents lock contention can also delay timers, so
302+
// bounded native registration remains the primary macOS protection.
303+
schedule();
247304
try {
248-
eventSource = options.createEventSource({
305+
const createdSource = options.createEventSource({
249306
onEvent,
250307
onError: onSourceError,
251308
onReady: onSourceReady,
252309
});
310+
if (isSourceClosed()) createdSource.close();
311+
else eventSource = createdSource;
253312
} catch (error) {
313+
sourceStatus = "closed";
314+
startupDeadline = undefined;
254315
state.degraded = true;
255316
safetyDeadline = clock.now() + degradedCheckMs;
256317
reportError(error);
@@ -268,8 +329,7 @@ export function createWatchController(options: WatchControllerOptions): WatchCon
268329
maximumDeadline = undefined;
269330
safetyDeadline = undefined;
270331
clearTimer();
271-
eventSource?.close();
272-
eventSource = undefined;
332+
closeEventSource();
273333
},
274334
/** Expose a snapshot for diagnostics without allowing state mutation. */
275335
getState() {

0 commit comments

Comments
 (0)