@@ -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+
2528export 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
4044export 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. */
7388export 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