@vzn/vx 0.0.120 → 0.0.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.120",
3
+ "version": "0.0.122",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.120",
21
- "@vzn/vx-linux-arm64": "0.0.120",
22
- "@vzn/vx-darwin-x64": "0.0.120",
23
- "@vzn/vx-darwin-arm64": "0.0.120"
20
+ "@vzn/vx-linux-x64": "0.0.122",
21
+ "@vzn/vx-linux-arm64": "0.0.122",
22
+ "@vzn/vx-darwin-x64": "0.0.122",
23
+ "@vzn/vx-darwin-arm64": "0.0.122"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -32,7 +32,7 @@ export interface ArmedWatcher {
32
32
  }
33
33
 
34
34
  /** How often the fallback re-walks a watched tree. */
35
- export const POLL_INTERVAL_MS = 250
35
+ const POLL_INTERVAL_MS = 250
36
36
 
37
37
  /**
38
38
  * Directory names the fallback never descends into WHEN no better filter is
@@ -246,3 +246,94 @@ export function fsClockNow(dir: string): number {
246
246
  return Date.now()
247
247
  }
248
248
  }
249
+
250
+ /** A handle that watches nothing: a slot a drop emptied, an arm not made. */
251
+ export const CLOSED: WatchHandle = { close() {} }
252
+
253
+ /**
254
+ * Every watcher `vx watch` arms. Each OS watcher proves delivery or is
255
+ * swapped for the poller in its slot; an OS watch limit (ENOSPC, EMFILE)
256
+ * goes straight to the poller; `VX_WATCH_POLL=1` polls from the start.
257
+ * `skip` is what the poller leaves unsampled, asked on each walk.
258
+ */
259
+ export class WatcherPool {
260
+ private readonly watchers: WatchHandle[] = []
261
+ private readonly proofs: Promise<void>[] = []
262
+ // `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
263
+ // to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
264
+ // network mount, a container bind — the attempt costs a denied syscall
265
+ // and a two-second wait before the fallback takes over anyway.
266
+ private readonly forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
267
+
268
+ constructor(private readonly skip: (dir: string, rel: string) => boolean) {
269
+ if (this.forcePoll)
270
+ process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
271
+ }
272
+
273
+ private poll(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
274
+ return pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, (rel) => this.skip(dir, rel))
275
+ }
276
+
277
+ // By slot, not by handle: an OS watcher that never proves delivery is
278
+ // swapped for a poller in place, and a drop must close what is there.
279
+ arm(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
280
+ const watchers = this.watchers
281
+ const at = watchers.length
282
+ const handle: WatchHandle = {
283
+ close: () => {
284
+ watchers[at]?.close()
285
+ watchers[at] = CLOSED
286
+ },
287
+ }
288
+ if (this.forcePoll) {
289
+ watchers.push(this.poll(dir, recursive, onEvent))
290
+ return handle
291
+ }
292
+ let armed: ArmedWatcher
293
+ try {
294
+ armed = armWatcher(dir, recursive, onEvent)
295
+ } catch (err) {
296
+ // The OS's watch limit, not the directory: the loop said "watching"
297
+ // and never fired. The poller needs no watch slot.
298
+ const code = (err as NodeJS.ErrnoException).code
299
+ if (code !== 'ENOSPC' && code !== 'EMFILE') throw err
300
+ watchers.push(this.poll(dir, recursive, onEvent))
301
+ process.stderr.write(
302
+ `vx watch: ${dir}: the OS watch limit is reached (${code}); polling every ${POLL_INTERVAL_MS} ms instead — raise it (Linux: sysctl fs.inotify.max_user_watches) to watch natively\n`,
303
+ )
304
+ return handle
305
+ }
306
+ watchers.push(armed.watcher)
307
+ this.proofs.push(
308
+ armed.ready.then((ok) => {
309
+ if (ok) return
310
+ armed.watcher.close()
311
+ // Dropped by a rearm before its proof settled: nothing to swap in.
312
+ if (watchers[at] !== armed.watcher) return
313
+ // The watcher never proved delivery, so it is not one: an FSEvents
314
+ // stream the OS refused, a filesystem that reports nothing. Swap in
315
+ // the poller rather than run a loop that silently never fires.
316
+ watchers[at] = this.poll(dir, recursive, onEvent)
317
+ process.stderr.write(
318
+ `vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
319
+ )
320
+ }),
321
+ )
322
+ return handle
323
+ }
324
+
325
+ /** Settles once every arm so far has proved delivery or fallen back. */
326
+ async proved(): Promise<void> {
327
+ await Promise.all(this.proofs)
328
+ }
329
+
330
+ closeAll(): void {
331
+ for (const w of this.watchers) {
332
+ try {
333
+ w.close()
334
+ } catch {
335
+ // ignore
336
+ }
337
+ }
338
+ }
339
+ }
package/src/cli/watch.ts CHANGED
@@ -41,14 +41,7 @@ import {
41
41
  makeWatchIgnore,
42
42
  shapesWatchedSet,
43
43
  } from './watch-filter.js'
44
- import {
45
- armWatcher,
46
- fsClockNow,
47
- pollWatcher,
48
- POLL_INTERVAL_MS,
49
- WATCH_PROBE_TIMEOUT_MS,
50
- type WatchHandle,
51
- } from './watch-fs.js'
44
+ import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
52
45
  import { ChangeJudge } from './watch-judge.js'
53
46
  import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
54
47
 
@@ -313,8 +306,6 @@ interface Rediscovered {
313
306
  packageDirs: ReadonlySet<string>
314
307
  }
315
308
 
316
- const CLOSED: WatchHandle = { close() {} }
317
-
318
309
  async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
319
310
  const { opts, stop, workspaceRoot, projects, cacheDir } = args
320
311
  // The watched set as of the last cycle: `rearm` replaces these after an
@@ -414,73 +405,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
414
405
  /** Since the last cycle, a member came or went, or a file that shapes the watched set changed (`shapesWatchedSet`). */
415
406
  let reread = false
416
407
 
417
- const watchers: WatchHandle[] = []
418
- const proofs: Promise<void>[] = []
419
- // `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
420
- // to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
421
- // network mount, a container bind — the attempt costs a denied syscall
422
- // and a two-second wait before the fallback takes over anyway.
423
- const forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
424
- if (forcePoll)
425
- process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
426
408
  // The poller skips exactly what the event filter would drop: the
427
409
  // unconditional segments AND this run's declared output containers. Read
428
410
  // through `isIgnoredPath` rather than captured, because `rearm` replaces
429
411
  // the filter when the selection changes.
430
- const skipUnder =
431
- (dir: string) =>
432
- (rel: string): boolean =>
433
- isIgnoredPath(dir, rel)
434
- // By slot, not by handle: an OS watcher that never proves delivery is
435
- // swapped for a poller in place, and a drop must close what is there.
436
- const arm = (
437
- dir: string,
438
- recursive: boolean,
439
- onEvent: (filename: string) => void,
440
- ): WatchHandle => {
441
- const at = watchers.length
442
- const handle: WatchHandle = {
443
- close: () => {
444
- watchers[at]?.close()
445
- watchers[at] = CLOSED
446
- },
447
- }
448
- if (forcePoll) {
449
- watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
450
- return handle
451
- }
452
- let armed: ReturnType<typeof armWatcher>
453
- try {
454
- armed = armWatcher(dir, recursive, onEvent)
455
- } catch (err) {
456
- // The OS's watch limit, not the directory: the loop said "watching"
457
- // and never fired. The poller needs no watch slot.
458
- const code = (err as NodeJS.ErrnoException).code
459
- if (code !== 'ENOSPC' && code !== 'EMFILE') throw err
460
- watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
461
- process.stderr.write(
462
- `vx watch: ${dir}: the OS watch limit is reached (${code}); polling every ${POLL_INTERVAL_MS} ms instead — raise it (Linux: sysctl fs.inotify.max_user_watches) to watch natively\n`,
463
- )
464
- return handle
465
- }
466
- watchers.push(armed.watcher)
467
- proofs.push(
468
- armed.ready.then((ok) => {
469
- if (ok) return
470
- armed.watcher.close()
471
- // Dropped by a rearm before its proof settled: nothing to swap in.
472
- if (watchers[at] !== armed.watcher) return
473
- // The watcher never proved delivery, so it is not one: an FSEvents
474
- // stream the OS refused, a filesystem that reports nothing. Swap in
475
- // the poller rather than run a loop that silently never fires.
476
- watchers[at] = pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir))
477
- process.stderr.write(
478
- `vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
479
- )
480
- }),
481
- )
482
- return handle
483
- }
412
+ const pool = new WatcherPool((dir, rel) => isIgnoredPath(dir, rel))
413
+ const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle =>
414
+ pool.arm(dir, recursive, onEvent)
484
415
 
485
416
  /** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
486
417
  const armedAt = fsClockNow(cacheDir)
@@ -704,7 +635,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
704
635
  armImports()
705
636
  // A new arm proves delivery like the first ones: an edit in the new
706
637
  // package right after this cycle is seen, not lost in the gap.
707
- await Promise.all(proofs)
638
+ await pool.proved()
708
639
  process.stdout.write(`${watchingLine(next.projects.length)}\n`)
709
640
  }
710
641
 
@@ -763,19 +694,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
763
694
 
764
695
  // "watching" is a promise that an edit from now on is seen; every
765
696
  // watcher has proved (or been given 2 s to prove) delivery first.
766
- await Promise.all(proofs)
697
+ await pool.proved()
767
698
 
768
699
  process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
769
700
 
770
701
  return await new Promise<number>((resolve) => {
771
702
  const cleanup = async (): Promise<void> => {
772
- for (const w of watchers) {
773
- try {
774
- w.close()
775
- } catch {
776
- // ignore
777
- }
778
- }
703
+ pool.closeAll()
779
704
  if (debounceTimer) clearTimeout(debounceTimer)
780
705
  // The aborted cycle is tearing its children down; resolve only once
781
706
  // it has returned, so the process never exits over a live child.