@dbx-tools/projen 0.6.12 → 0.6.14

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 +1 -1
  2. package/src/watch.ts +55 -8
package/package.json CHANGED
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "main": "index.ts",
34
34
  "license": "Apache-2.0",
35
- "version": "0.6.12",
35
+ "version": "0.6.14",
36
36
  "types": "index.ts",
37
37
  "type": "module",
38
38
  "exports": {
package/src/watch.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * `watchLoop` wraps `@dbx-tools/path`'s chokidar watcher with the
5
5
  * behavior every dbx-tools watcher wants: it debounces bursts, serializes runs (a
6
6
  * change mid-run re-runs once afterwards), drops generated paths (barrels/manifests/
7
- * decls - reacting to our own output would loop), and shuts down on SIGINT. Callers
7
+ * decls - reacting to our own output would loop), exits on a watcher error chokidar
8
+ * cannot recover from ({@link FATAL_WATCH_ERROR_CODES}), and shuts down on SIGINT. Callers
8
9
  * pass the paths to watch and an `onBatch` handler; the concern-specific glue - which
9
10
  * barrels to rebuild, when to regenerate openapi, when to re-synth - lives in the task
10
11
  * that owns it (`tasks/barrels.ts`, `tasks/openapi.ts`, `tasks/projenrc.ts`), each
@@ -16,12 +17,36 @@
16
17
  */
17
18
  import { isAbsolute, resolve } from "node:path";
18
19
  import { watch as fileScan } from "@dbx-tools/path";
19
- import { log } from "@dbx-tools/shared-core";
20
+ import { async, log } from "@dbx-tools/shared-core";
20
21
  import { isGeneratedFile, recordedRoots, repoRoot } from "./packages.ts";
21
22
 
22
23
  const logger = log.logger("projen:watch");
23
24
  const DEBOUNCE_MS = 250;
24
25
 
26
+ /** How long to let chokidar release its watches before exiting regardless. */
27
+ const CLOSE_GRACE_MS = 2_000;
28
+
29
+ /**
30
+ * Watcher errors no amount of waiting recovers from: the process is out of file
31
+ * descriptors (`EMFILE`/`ENFILE`) or the kernel watch table is full (`ENOSPC`).
32
+ *
33
+ * These have to end the process, because chokidar only EMITS them - it never closes
34
+ * itself or marks the instance dead (its own source carries a
35
+ * `TODO: emit errors properly. Example: EMFILE on Macos.`). A handler that just logs
36
+ * therefore leaves the watcher open, still holding every descriptor it took, while
37
+ * silently delivering no further events; and since the process stays alive, `sync`'s
38
+ * `restartTries: -1` supervisor never sees an exit to respawn. The watcher looks
39
+ * healthy and its outputs quietly go stale. Exiting non-zero instead hands the
40
+ * restart to `concurrently`, which is what makes a transient exhaustion recover on
41
+ * its own and a persistent one loud (a respawn every `RESTART_DELAY_MS`).
42
+ *
43
+ * Every other error is left as a logged warning: chokidar has already filtered the
44
+ * benign `ENOENT`/`ENOTDIR` cases, and what remains (an unreadable path, say) costs
45
+ * that one path while the rest of the tree keeps working - a restart would only hit
46
+ * it again.
47
+ */
48
+ const FATAL_WATCH_ERROR_CODES = new Set(["EMFILE", "ENFILE", "ENOSPC"]);
49
+
25
50
  /** node-path's built-in ignore-group toggles (`{ dot, temp, test, lock, defaults }`). */
26
51
  export type IgnoreGroupOptions = NonNullable<
27
52
  Parameters<typeof fileScan.watchFiles>[1]
@@ -45,7 +70,11 @@ function ignoredPath(path: string): boolean {
45
70
  * Shared debounce/flush machinery backed by `watchFiles`. Watches `paths` and, on
46
71
  * each debounced batch of non-generated changes, calls `onBatch` with the absolute
47
72
  * changed paths. Runs are serialized (a change during a run re-runs once afterwards);
48
- * watches until SIGINT.
73
+ * watches until SIGINT, or until a {@link FATAL_WATCH_ERROR_CODES} error makes the
74
+ * watcher blind, which exits non-zero so `sync` respawns it.
75
+ *
76
+ * A failing `onBatch` is NOT fatal - the offending edit is usually the one the next
77
+ * save fixes - so it is logged and the watch continues.
49
78
  *
50
79
  * `ignoreOptions` toggles node-path's built-in ignore groups for this watcher only
51
80
  * (e.g. the projenrc watcher passes `{ dot: false }` so its lone dotfile target,
@@ -92,16 +121,34 @@ export function watchLoop(
92
121
  ignore: (path) => ignoredPath(path),
93
122
  ignoreOptions,
94
123
  });
124
+ let closing = false;
125
+
126
+ /**
127
+ * Release the watches and leave with `code`. Closing is raced against
128
+ * {@link CLOSE_GRACE_MS} so a wedged chokidar cannot hold up a Ctrl-C, and exiting
129
+ * reclaims the descriptors either way.
130
+ */
131
+ async function shutdown(code: number): Promise<void> {
132
+ if (closing) return;
133
+ closing = true;
134
+ clearTimeout(timer);
135
+ await Promise.race([watcher.close().catch(() => {}), async.sleep(CLOSE_GRACE_MS)]);
136
+ process.exit(code);
137
+ }
138
+
95
139
  watcher.on("all", (_event, path) => {
96
140
  pending.add(path);
97
141
  clearTimeout(timer);
98
142
  timer = setTimeout(() => void flush(), DEBOUNCE_MS);
99
143
  });
100
- watcher.on("error", (err) => logger.error(`${tag} watcher error:`, err));
144
+ watcher.on("error", (err) => {
145
+ logger.error(`${tag} watcher error:`, err);
146
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
147
+ if (code === undefined || !FATAL_WATCH_ERROR_CODES.has(code)) return;
148
+ logger.error(`${tag}: ${code} leaves the watcher blind - exiting so it is restarted`);
149
+ void shutdown(1);
150
+ });
101
151
  watcher.on("ready", () => logger.info(`${tag}: watching for changes … (Ctrl-C to stop)`));
102
152
 
103
- process.on("SIGINT", () => {
104
- void watcher.close();
105
- process.exit(0);
106
- });
153
+ process.on("SIGINT", () => void shutdown(0));
107
154
  }