@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 +5 -5
- package/src/cli/watch-fs.ts +92 -1
- package/src/cli/watch.ts +7 -82
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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"
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface ArmedWatcher {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** How often the fallback re-walks a watched tree. */
|
|
35
|
-
|
|
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
|
|
431
|
-
|
|
432
|
-
(
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|