@vzn/vx 0.0.435 → 0.0.437

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.
Files changed (2) hide show
  1. package/package.json +5 -5
  2. package/src/cli/watch-fs.ts +100 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.435",
3
+ "version": "0.0.437",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.435",
25
- "@vzn/vx-linux-arm64": "0.0.435",
26
- "@vzn/vx-darwin-x64": "0.0.435",
27
- "@vzn/vx-darwin-arm64": "0.0.435"
24
+ "@vzn/vx-linux-x64": "0.0.437",
25
+ "@vzn/vx-linux-arm64": "0.0.437",
26
+ "@vzn/vx-darwin-x64": "0.0.437",
27
+ "@vzn/vx-darwin-arm64": "0.0.437"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -26,7 +26,7 @@ export interface WatchHandle {
26
26
  }
27
27
 
28
28
  export interface ArmedWatcher {
29
- watcher: fs.FSWatcher
29
+ watcher: WatchHandle
30
30
  /** Resolves `true` once the watcher reported the probe, `false` on timeout. */
31
31
  ready: Promise<boolean>
32
32
  }
@@ -157,7 +157,7 @@ export function armWatcher(
157
157
  const seen = new Promise<boolean>((resolve) => {
158
158
  markReady = resolve
159
159
  })
160
- const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
160
+ const listener = (filename: string | null): void => {
161
161
  if (filename == null || typeof filename !== 'string') return
162
162
  // An event naming the watched directory itself (macOS reports the
163
163
  // directory a write landed in as its own item) carries nothing a key
@@ -175,7 +175,11 @@ export function armWatcher(
175
175
  // proves this stream live.
176
176
  if (path.basename(filename) === WATCH_PROBE) return
177
177
  onEvent(filename)
178
- })
178
+ }
179
+ const watcher: WatchHandle =
180
+ recursive && process.platform === 'linux'
181
+ ? treeWatcher(dir, listener)
182
+ : fs.watch(dir, { recursive, persistent: true }, (_event, filename) => listener(filename))
179
183
  const probe = path.join(dir, WATCH_PROBE)
180
184
  const ready = (async (): Promise<boolean> => {
181
185
  // The probe is subject to the very race it detects: a write that lands
@@ -217,6 +221,99 @@ export function armWatcher(
217
221
  return { watcher, ready }
218
222
  }
219
223
 
224
+ /**
225
+ * A recursive watch on Linux, built from one non-recursive watch per
226
+ * directory, that never enters `IGNORED_SEGMENTS`. Bun's recursive form is
227
+ * the same inotify watch per directory and descended into all of them:
228
+ * this repo's root arm held 4,657 watches where 438 directories can
229
+ * matter, 40–55 ms of the arm, and a monorepo's `node_modules` met the OS
230
+ * watch limit (8,192 on many distros) and fell back to polling — for
231
+ * events the loop drops by name. A directory that appears is watched, and
232
+ * what it already holds is reported (it may have landed before its watch);
233
+ * one that goes, or moves, is dropped with everything under it, since an
234
+ * inotify watch follows the inode and would report the old name. The
235
+ * arming walk throws at the watch limit, so the pool's fallback still
236
+ * applies; a subdirectory that vanished mid-walk is simply not watched.
237
+ */
238
+ function treeWatcher(root: string, listener: (filename: string) => void): WatchHandle {
239
+ const watchers = new Map<string, fs.FSWatcher>()
240
+ let closed = false
241
+ let armed = false
242
+ let warned = false
243
+ const drop = (rel: string): void => {
244
+ for (const [key, w] of watchers) {
245
+ if (key !== rel && !key.startsWith(rel + '/')) continue
246
+ w.close()
247
+ watchers.delete(key)
248
+ }
249
+ }
250
+ const watchDir = (rel: string, report: boolean): void => {
251
+ if (closed || watchers.has(rel)) return
252
+ const abs = rel === '' ? root : path.join(root, rel)
253
+ let w: fs.FSWatcher
254
+ try {
255
+ w = fs.watch(abs, { persistent: true }, (_event, name) => {
256
+ if (name == null || typeof name !== 'string' || name === '' || name === '.') return
257
+ const child = rel === '' ? name : `${rel}/${name}`
258
+ listener(child)
259
+ if (IGNORED_SEGMENTS.includes(name)) return
260
+ let st: fs.Stats
261
+ try {
262
+ st = fs.statSync(path.join(abs, name))
263
+ } catch {
264
+ drop(child)
265
+ return
266
+ }
267
+ if (!st.isDirectory()) return
268
+ if (watchers.has(child)) return
269
+ watchDir(child, true)
270
+ })
271
+ } catch (err) {
272
+ const code = (err as NodeJS.ErrnoException).code
273
+ const limit = code === 'ENOSPC' || code === 'EMFILE'
274
+ // The walk that arms is all or nothing at the watch limit, as the
275
+ // recursive form was, so the pool's fallback polls instead of a
276
+ // tree half watched without a word.
277
+ if (rel === '' || (limit && !armed)) throw err
278
+ if (limit && !warned) {
279
+ warned = true
280
+ process.stderr.write(
281
+ `vx watch: ${abs}: the OS watch limit is reached (${code}); directories made from here on are not watched — raise it (Linux: sysctl fs.inotify.max_user_watches)\n`,
282
+ )
283
+ }
284
+ return
285
+ }
286
+ w.on('error', () => drop(rel))
287
+ watchers.set(rel, w)
288
+ let entries: fs.Dirent[]
289
+ try {
290
+ entries = fs.readdirSync(abs, { withFileTypes: true })
291
+ } catch {
292
+ return
293
+ }
294
+ for (const e of entries) {
295
+ const child = rel === '' ? e.name : `${rel}/${e.name}`
296
+ if (e.isDirectory()) {
297
+ if (!IGNORED_SEGMENTS.includes(e.name)) watchDir(child, report)
298
+ } else if (report) listener(child)
299
+ }
300
+ }
301
+ try {
302
+ watchDir('', false)
303
+ } catch (err) {
304
+ for (const w of watchers.values()) w.close()
305
+ throw err
306
+ }
307
+ armed = true
308
+ return {
309
+ close(): void {
310
+ closed = true
311
+ for (const w of watchers.values()) w.close()
312
+ watchers.clear()
313
+ },
314
+ }
315
+ }
316
+
220
317
  /** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
221
318
  export function modifiedBefore(abs: string, t: number): boolean {
222
319
  try {