@reventlessdev/reventless-gwt 1.0.0-alpha.74 → 1.0.0-alpha.76

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 (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +8 -8
  3. package/src/Bind.res.mjs +2 -2
  4. package/src/Cancellation.res +6 -0
  5. package/src/Cancellation.res.mjs +3 -2
  6. package/src/ChildProcess.res +19 -1
  7. package/src/ChildProcess.res.mjs +16 -1
  8. package/src/Cli.res +115 -9
  9. package/src/Cli.res.mjs +101 -17
  10. package/src/Collector.res +11 -0
  11. package/src/Collector.res.mjs +6 -3
  12. package/src/DomainGraph.res +0 -0
  13. package/src/DomainGraph.res.mjs +0 -0
  14. package/src/Filter.res +11 -2
  15. package/src/Filter.res.mjs +10 -3
  16. package/src/FormatterVsCode.res +1 -1
  17. package/src/FormatterVsCode.res.mjs +2 -1
  18. package/src/JestBind.res +1 -1
  19. package/src/JestBind.res.mjs +2 -2
  20. package/src/PlatformRunner.res +27 -6
  21. package/src/PlatformRunner.res.mjs +27 -6
  22. package/src/ProcessManager.res +51 -7
  23. package/src/ProcessManager.res.mjs +43 -4
  24. package/src/RunWorker.res +24 -0
  25. package/src/RunWorker.res.mjs +16 -0
  26. package/src/Watch.res +46 -30
  27. package/src/Watch.res.mjs +34 -32
  28. package/src/WatcherProbe.res +11 -3
  29. package/src/WatcherProbe.res.mjs +15 -2
  30. package/src/Worker.res +24 -0
  31. package/src/Worker.res.mjs +9 -0
  32. package/tests/CliRunEntryTest.res +57 -0
  33. package/tests/CliRunEntryTest.res.mjs +65 -0
  34. package/tests/DomainGraphTest.res +79 -2
  35. package/tests/DomainGraphTest.res.mjs +87 -6
  36. package/tests/FlowGwtTest.res.mjs +2 -1
  37. package/tests/InboundTranslationGwtTest.res.mjs +2 -1
  38. package/tests/OutboundTranslationGwtTest.res.mjs +2 -1
  39. package/tests/WatchDebounceTest.res +35 -7
  40. package/tests/WatchDebounceTest.res.mjs +37 -6
@@ -14,7 +14,7 @@ function only() {
14
14
  function xtest(param, name, _body) {
15
15
  if (Collector$ReventlessGwt.isActive()) {
16
16
  Collector$ReventlessGwt.markSkipNext();
17
- return Collector$ReventlessGwt.push(undefined, undefined, name, () => Promise.resolve(Outcome$ReventlessGwt.pass));
17
+ return Collector$ReventlessGwt.push(undefined, undefined, undefined, name, () => Promise.resolve(Outcome$ReventlessGwt.pass));
18
18
  } else {
19
19
  globalThis.xtest(name, () => {});
20
20
  return;
@@ -24,10 +24,17 @@ function xtest(param, name, _body) {
24
24
  function xdescribe(label, body) {
25
25
  if (Collector$ReventlessGwt.isActive()) {
26
26
  Collector$ReventlessGwt.skipDepth.contents = Collector$ReventlessGwt.skipDepth.contents + 1 | 0;
27
- Collector$ReventlessGwt.pushDescribe(label, body);
28
- Collector$ReventlessGwt.skipDepth.contents = Collector$ReventlessGwt.skipDepth.contents - 1 | 0;
27
+ try {
28
+ Collector$ReventlessGwt.pushDescribe(label, body);
29
+ Collector$ReventlessGwt.skipDepth.contents = Collector$ReventlessGwt.skipDepth.contents - 1 | 0;
30
+ return;
31
+ } catch (e) {
32
+ Collector$ReventlessGwt.skipDepth.contents = Collector$ReventlessGwt.skipDepth.contents - 1 | 0;
33
+ throw e;
34
+ }
29
35
  } else {
30
36
  globalThis.xdescribe(label, body);
37
+ return;
31
38
  }
32
39
  }
33
40
 
@@ -262,7 +262,7 @@ let graph = (g: DomainGraph.graph) =>
262
262
  label: n.label,
263
263
  plugin: n.plugin,
264
264
  }),
265
- edges: g.edges->Array.map((e): P.graphEdge => {from: e.from, to_: e.to, kind: e.kind}),
265
+ edges: g.edges->Array.map((e): P.graphEdge => {from: e.from, to_: e.to, kind: e.kind, label: ?e.label}),
266
266
  }),
267
267
  )
268
268
 
@@ -291,7 +291,8 @@ function graph(g) {
291
291
  let e_1 = g.edges.map(e => ({
292
292
  from: e.from,
293
293
  to: e.to,
294
- kind: e.kind
294
+ kind: e.kind,
295
+ label: e.label
295
296
  }));
296
297
  let e = {
297
298
  event: "graph",
package/src/JestBind.res CHANGED
@@ -60,7 +60,7 @@ let testPromise = (
60
60
  ) =>
61
61
  if Collector.isActive() {
62
62
  let location = Collector.captureLocation(1)
63
- Collector.push(~slice?, ~location?, name, body)
63
+ Collector.push(~slice?, ~location?, ~timeout?, name, body)
64
64
  } else {
65
65
  switch timeout {
66
66
  | Some(t) =>
@@ -35,7 +35,7 @@ function todo(label) {
35
35
  function test(slice, name, body) {
36
36
  if (Collector$ReventlessGwt.isActive()) {
37
37
  let location = Collector$ReventlessGwt.captureLocation(1);
38
- return Collector$ReventlessGwt.push(slice, location, name, () => Promise.resolve(body()));
38
+ return Collector$ReventlessGwt.push(slice, location, undefined, name, () => Promise.resolve(body()));
39
39
  }
40
40
  globalThis.test(name, () => assertOutcome(slice, body()));
41
41
  }
@@ -50,7 +50,7 @@ function testPromise(slice, name, timeout, body) {
50
50
  return;
51
51
  }
52
52
  let location = Collector$ReventlessGwt.captureLocation(1);
53
- Collector$ReventlessGwt.push(slice, location, name, body);
53
+ Collector$ReventlessGwt.push(slice, location, timeout, name, body);
54
54
  }
55
55
 
56
56
  export {
@@ -60,6 +60,9 @@ let openEphemeral = (): promise<(netServer, int)> =>
60
60
  s->_listen(0, "127.0.0.1", () => resolve((s, (s->_address)["port"])))
61
61
  })
62
62
 
63
+ let closeServer = (s: netServer): promise<unit> =>
64
+ Promise.make((resolve, _reject) => s->_close(() => resolve()))
65
+
63
66
  // Hold all n open while collecting ports so they're guaranteed distinct, then
64
67
  // release them just before the child binds.
65
68
  let allocFreePorts = async (n: int): array<int> => {
@@ -70,7 +73,10 @@ let allocFreePorts = async (n: int): array<int> => {
70
73
  servers->Array.push(s)
71
74
  ports->Array.push(p)
72
75
  }
73
- servers->Array.forEach(s => s->_close(() => ()))
76
+ // Await every close so the ports are actually released before the child binds
77
+ // them. Closing fire-and-forget returned the ports while the sockets were
78
+ // still bound, racing the child's bind into EADDRINUSE.
79
+ let _ = await Promise.all(servers->Array.map(closeServer))
74
80
  ports
75
81
  }
76
82
 
@@ -150,11 +156,26 @@ let run = async (
150
156
  }
151
157
  ChildProcess.onStdoutLine(proc, handle)
152
158
  ChildProcess.onStderrLine(proc, handle)
153
- ChildProcess.onClose(proc, code => callbacks.onStop(code))
154
159
 
155
- // Keep the process alive until cancelled like runWatch, returning here would
156
- // let the bin wrapper process.exit and tear down the child immediately.
157
- await Promise.make((resolve, _reject) => Cancellation.onCancel(() => resolve()))
158
- 0
160
+ // Keep the parent alive until either the operator cancels or the child exits.
161
+ // The previous shape awaited cancellation *forever* and always returned 0, so
162
+ // a crashed platform child left a zombie parent that reported success. Resolve
163
+ // on child close (or a spawn error) too, and surface the child's exit code.
164
+ let exitCode = ref(0)
165
+ await Promise.make((resolve, _reject) => {
166
+ Cancellation.onCancel(() => resolve())
167
+ ChildProcess.onError(proc, msg => {
168
+ callbacks.onLog("platform spawn error: " ++ msg)
169
+ callbacks.onStop(None)
170
+ exitCode := 1
171
+ resolve()
172
+ })
173
+ ChildProcess.onClose(proc, code => {
174
+ callbacks.onStop(code)
175
+ exitCode := code->Option.getOr(0)
176
+ resolve()
177
+ })
178
+ })
179
+ exitCode.contents
159
180
  }
160
181
  }
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Nodenet from "node:net";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
5
  import * as Cancellation$ReventlessGwt from "./Cancellation.res.mjs";
5
6
  import * as ChildProcess$ReventlessGwt from "./ChildProcess.res.mjs";
6
7
  import * as PlatformScan$ReventlessGwt from "./PlatformScan.res.mjs";
@@ -44,6 +45,12 @@ function openEphemeral() {
44
45
  });
45
46
  }
46
47
 
48
+ function closeServer(s) {
49
+ return new Promise((resolve, _reject) => {
50
+ s.close(() => resolve());
51
+ });
52
+ }
53
+
47
54
  async function allocFreePorts(n) {
48
55
  let servers = [];
49
56
  let ports = [];
@@ -52,9 +59,7 @@ async function allocFreePorts(n) {
52
59
  servers.push(match[0]);
53
60
  ports.push(match[1]);
54
61
  }
55
- servers.forEach(s => {
56
- s.close(() => {});
57
- });
62
+ await Promise.all(servers.map(closeServer));
58
63
  return ports;
59
64
  }
60
65
 
@@ -136,9 +141,24 @@ async function run(roots, backend, fixedPortsOpt, callbacks) {
136
141
  };
137
142
  ChildProcess$ReventlessGwt.onStdoutLine(proc, handle);
138
143
  ChildProcess$ReventlessGwt.onStderrLine(proc, handle);
139
- ChildProcess$ReventlessGwt.onClose(proc, code => callbacks.onStop(code));
140
- await new Promise((resolve, _reject) => Cancellation$ReventlessGwt.onCancel(() => resolve()));
141
- return 0;
144
+ let exitCode = {
145
+ contents: 0
146
+ };
147
+ await new Promise((resolve, _reject) => {
148
+ Cancellation$ReventlessGwt.onCancel(() => resolve());
149
+ ChildProcess$ReventlessGwt.onError(proc, msg => {
150
+ callbacks.onLog("platform spawn error: " + msg);
151
+ callbacks.onStop(undefined);
152
+ exitCode.contents = 1;
153
+ resolve();
154
+ });
155
+ ChildProcess$ReventlessGwt.onClose(proc, code => {
156
+ callbacks.onStop(code);
157
+ exitCode.contents = Stdlib_Option.getOr(code, 0);
158
+ resolve();
159
+ });
160
+ });
161
+ return exitCode.contents;
142
162
  }
143
163
  callbacks.onLog("no reventless-local platform package (src/Main.res.mjs + @reventlessdev/reventless-local) found under: " + roots.join(", "));
144
164
  return 1;
@@ -148,6 +168,7 @@ export {
148
168
  tapSentinel,
149
169
  classifyLine,
150
170
  openEphemeral,
171
+ closeServer,
151
172
  allocFreePorts,
152
173
  fixedPlatformPorts,
153
174
  run,
@@ -10,6 +10,13 @@ type managed = {
10
10
 
11
11
  let running: Dict.t<managed> = Dict.make()
12
12
 
13
+ // Per-dir count of unexpected watcher exits, so a build-watcher that crashes on
14
+ // startup (bad config, missing binary) is respawned a bounded number of times
15
+ // rather than either freezing the build status forever or spinning in a tight
16
+ // respawn loop.
17
+ let restarts: Dict.t<int> = Dict.make()
18
+ let maxRestarts = 3
19
+
13
20
  let isManaging = (dir: string): bool =>
14
21
  switch running->Dict.get(dir) {
15
22
  | Some(_) => true
@@ -19,12 +26,33 @@ let isManaging = (dir: string): bool =>
19
26
  // Spawn `pnpm run start` (the package's `rescript build -w`) in `pkg.dir`,
20
27
  // unless one is already managed there. `onStdout` receives each output line so
21
28
  // the caller can classify compiler markers (Phase 5).
22
- let spawnWatcher = (pkg: PackageScan.pkg, ~onStdout: (string, string) => unit): unit =>
29
+ let rec spawnWatcher = (pkg: PackageScan.pkg, ~onStdout: (string, string) => unit): unit =>
23
30
  switch running->Dict.get(pkg.dir) {
24
31
  | Some(_) => ()
25
32
  | None => {
26
33
  let proc = ChildProcess.spawn("pnpm", ["run", "start"], ~cwd=pkg.dir)
27
34
  ChildProcess.onStdoutLine(proc, line => onStdout(pkg.dir, line))
35
+ // stderr carries compiler/pnpm crash output; without this a watcher that
36
+ // dies on stderr was invisible until the caller's 120s watchdog.
37
+ ChildProcess.onStderrLine(proc, line => onStdout(pkg.dir, line))
38
+ // A spawn failure ("error") never reaches "close"; surface it as output.
39
+ ChildProcess.onError(proc, msg => onStdout(pkg.dir, msg))
40
+ // Detect an unexpected exit. We compare the stored proc identity: an
41
+ // intentional `stop` deletes the entry synchronously before "close" fires,
42
+ // so if we still own this exact proc, the watcher crashed — respawn it up
43
+ // to `maxRestarts` times so build status doesn't freeze.
44
+ ChildProcess.onClose(proc, _code =>
45
+ switch running->Dict.get(pkg.dir) {
46
+ | Some(m) if m.proc === proc =>
47
+ running->Dict.delete(pkg.dir)
48
+ let n = restarts->Dict.get(pkg.dir)->Option.getOr(0)
49
+ if n < maxRestarts {
50
+ restarts->Dict.set(pkg.dir, n + 1)
51
+ spawnWatcher(pkg, ~onStdout)
52
+ }
53
+ | _ => ()
54
+ }
55
+ )
28
56
  running->Dict.set(pkg.dir, {dir: pkg.dir, proc})
29
57
  }
30
58
  }
@@ -58,20 +86,36 @@ let stop = (dir: string): bool =>
58
86
  let cleanRebuild = (
59
87
  pkg: PackageScan.pkg,
60
88
  ~onStdout: (string, string) => unit,
61
- ~onDone: unit => unit,
89
+ ~onDone: bool => unit,
62
90
  ): unit => {
63
91
  let wasManaged = stop(pkg.dir)
64
- let step = (cmd: string, args: array<string>, next: unit => unit): unit => {
92
+ // `next` receives the step's exit code so the chain can tell a failed clean or
93
+ // build (non-zero / crash) from success — the previous shape discarded exit
94
+ // codes and always reported the rebuild as OK, masking compile failures.
95
+ let step = (cmd: string, args: array<string>, next: option<int> => unit): unit => {
65
96
  let proc = ChildProcess.spawn(cmd, args, ~cwd=pkg.dir)
97
+ // "error" (spawn failure) and "close" can both fire; ensure the chain
98
+ // advances exactly once.
99
+ let settled = ref(false)
100
+ let settle = (code: option<int>) =>
101
+ if !settled.contents {
102
+ settled := true
103
+ next(code)
104
+ }
66
105
  ChildProcess.onStdoutLine(proc, line => onStdout(pkg.dir, line))
67
- ChildProcess.onClose(proc, _ => next())
106
+ ChildProcess.onStderrLine(proc, line => onStdout(pkg.dir, line))
107
+ ChildProcess.onError(proc, msg => {
108
+ onStdout(pkg.dir, msg)
109
+ settle(None)
110
+ })
111
+ ChildProcess.onClose(proc, code => settle(code))
68
112
  }
69
- step("pnpm", ["exec", "rescript", "clean"], () =>
70
- step("pnpm", ["exec", "rescript", "build"], () => {
113
+ step("pnpm", ["exec", "rescript", "clean"], _cleanCode =>
114
+ step("pnpm", ["exec", "rescript", "build"], buildCode => {
71
115
  if wasManaged {
72
116
  spawnWatcher(pkg, ~onStdout)
73
117
  }
74
- onDone()
118
+ onDone(buildCode == Some(0))
75
119
  })
76
120
  )
77
121
  }
@@ -1,10 +1,14 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
4
6
  import * as ChildProcess$ReventlessGwt from "./ChildProcess.res.mjs";
5
7
 
6
8
  let running = {};
7
9
 
10
+ let restarts = {};
11
+
8
12
  function isManaging(dir) {
9
13
  return running[dir] !== undefined;
10
14
  }
@@ -19,6 +23,23 @@ function spawnWatcher(pkg, onStdout) {
19
23
  "start"
20
24
  ], pkg.dir, undefined);
21
25
  ChildProcess$ReventlessGwt.onStdoutLine(proc, line => onStdout(pkg.dir, line));
26
+ ChildProcess$ReventlessGwt.onStderrLine(proc, line => onStdout(pkg.dir, line));
27
+ ChildProcess$ReventlessGwt.onError(proc, msg => onStdout(pkg.dir, msg));
28
+ ChildProcess$ReventlessGwt.onClose(proc, _code => {
29
+ let m = running[pkg.dir];
30
+ if (m === undefined) {
31
+ return;
32
+ }
33
+ if (m.proc !== proc) {
34
+ return;
35
+ }
36
+ Stdlib_Dict.$$delete(running, pkg.dir);
37
+ let n = Stdlib_Option.getOr(restarts[pkg.dir], 0);
38
+ if (n < 3) {
39
+ restarts[pkg.dir] = n + 1 | 0;
40
+ return spawnWatcher(pkg, onStdout);
41
+ }
42
+ });
22
43
  running[pkg.dir] = {
23
44
  dir: pkg.dir,
24
45
  proc: proc
@@ -40,22 +61,36 @@ function cleanRebuild(pkg, onStdout, onDone) {
40
61
  let wasManaged = stop(pkg.dir);
41
62
  let step = (cmd, args, next) => {
42
63
  let proc = ChildProcess$ReventlessGwt.spawn(cmd, args, pkg.dir, undefined);
64
+ let settled = {
65
+ contents: false
66
+ };
67
+ let settle = code => {
68
+ if (!settled.contents) {
69
+ settled.contents = true;
70
+ return next(code);
71
+ }
72
+ };
43
73
  ChildProcess$ReventlessGwt.onStdoutLine(proc, line => onStdout(pkg.dir, line));
44
- ChildProcess$ReventlessGwt.onClose(proc, param => next());
74
+ ChildProcess$ReventlessGwt.onStderrLine(proc, line => onStdout(pkg.dir, line));
75
+ ChildProcess$ReventlessGwt.onError(proc, msg => {
76
+ onStdout(pkg.dir, msg);
77
+ settle(undefined);
78
+ });
79
+ ChildProcess$ReventlessGwt.onClose(proc, settle);
45
80
  };
46
81
  step("pnpm", [
47
82
  "exec",
48
83
  "rescript",
49
84
  "clean"
50
- ], () => step("pnpm", [
85
+ ], _cleanCode => step("pnpm", [
51
86
  "exec",
52
87
  "rescript",
53
88
  "build"
54
- ], () => {
89
+ ], buildCode => {
55
90
  if (wasManaged) {
56
91
  spawnWatcher(pkg, onStdout);
57
92
  }
58
- onDone();
93
+ onDone(Primitive_object.equal(buildCode, 0));
59
94
  }));
60
95
  }
61
96
 
@@ -68,8 +103,12 @@ function managedCount() {
68
103
  return Object.keys(running).length;
69
104
  }
70
105
 
106
+ let maxRestarts = 3;
107
+
71
108
  export {
72
109
  running,
110
+ restarts,
111
+ maxRestarts,
73
112
  isManaging,
74
113
  spawnWatcher,
75
114
  stop,
@@ -0,0 +1,24 @@
1
+ // Worker entry for a single gwt watch re-run pass.
2
+ //
3
+ // Spawned by `Cli.runWatch` with the run `options` passed as `workerData`. Runs
4
+ // exactly one `runOnce` in this fresh worker (fresh ESM registry → recompiled
5
+ // implementation modules are re-imported, not served stale), emits its NDJSON to
6
+ // stdout (piped to the parent), then exits so the imported graph is reclaimed.
7
+ // The parent never reuses a worker — one per re-run.
8
+
9
+ @val @module("node:worker_threads") external workerData: Cli.options = "workerData"
10
+ @val external processExit: int => unit = "process.exit"
11
+
12
+ let () = {
13
+ let _ =
14
+ Cli.runOnce(workerData)
15
+ ->Promise.then(code => {
16
+ processExit(code)
17
+ Promise.resolve()
18
+ })
19
+ ->Promise.catch(e => {
20
+ Console.error2("gwt run worker failed:", e)
21
+ processExit(1)
22
+ Promise.resolve()
23
+ })
24
+ }
@@ -0,0 +1,16 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
4
+ import * as Cli$ReventlessGwt from "./Cli.res.mjs";
5
+ import * as Nodeworker_threads from "node:worker_threads";
6
+
7
+ Stdlib_Promise.$$catch(Cli$ReventlessGwt.runOnce(Nodeworker_threads.workerData).then(code => {
8
+ process.exit(code);
9
+ return Promise.resolve();
10
+ }), e => {
11
+ console.error("gwt run worker failed:", e);
12
+ process.exit(1);
13
+ return Promise.resolve();
14
+ });
15
+
16
+ /* Not a pure module */
package/src/Watch.res CHANGED
@@ -22,21 +22,6 @@ type event = Add | Change | Unlink
22
22
  @val external setTimeout: (unit => unit, int) => float = "setTimeout"
23
23
  @val external clearTimeout: float => unit = "clearTimeout"
24
24
 
25
- // Rank events so a coalesced burst reports the *strongest structural* signal
26
- // with a representative path. A single directory `mv` emits an interleaved
27
- // burst — `unlink` of the `.res` AND the generated `.res.mjs`, then `add`s at
28
- // the new path — inside one debounce window. The representative path must be a
29
- // `.res` *source* unlink (the relocation signal), never a `.res.mjs` or a
30
- // trailing add, or the consumer misclassifies it as an ordinary change. So an
31
- // unlink of a `.res` source outranks everything; any other unlink/add outranks
32
- // a plain edit.
33
- let rank = (event: event, path: string): int =>
34
- switch event {
35
- | Unlink => String.endsWith(path, ".res") ? 2 : 1
36
- | Add => 1
37
- | Change => 0
38
- }
39
-
40
25
  // True when an event is the *structural* signal Phase 12 acts on: a source
41
26
  // `.res` file leaving a directory (the move/delete half of a relocation).
42
27
  // Generated `.res.mjs` outputs end in `.mjs`, so they never match — which is
@@ -45,17 +30,30 @@ let rank = (event: event, path: string): int =>
45
30
  let isStructuralSource = (event: event, path: string): bool =>
46
31
  event == Unlink && String.endsWith(path, ".res")
47
32
 
33
+ // Coalesce a burst of chokidar events into a single debounce window, then emit.
34
+ // A window may span *multiple* packages — a branch switch or a multi-package
35
+ // refactor emits `.res` unlinks under several package roots at once. The old
36
+ // shape kept only one "best" path, so it clean-rebuilt just one package and
37
+ // left the others with stale `.res.mjs`. Instead accumulate the *set* of
38
+ // distinct structural source paths and emit one `Unlink` callback per path
39
+ // (the consumer maps each to its owning package). If the window held no
40
+ // structural signal, emit a single representative event so the consumer does a
41
+ // plain re-run.
48
42
  let debounce = (wait: int, fn: (event, string) => unit) => {
49
43
  let pending: ref<option<float>> = ref(None)
50
- let bestRank = ref(-1)
51
- let bestEvent = ref(Change)
52
- let bestPath = ref("")
44
+ let structural: ref<array<string>> = ref([])
45
+ let sawOther = ref(false)
46
+ let otherEvent = ref(Change)
47
+ let otherPath = ref("")
53
48
  (event, path) => {
54
- let r = rank(event, path)
55
- if r >= bestRank.contents {
56
- bestRank := r
57
- bestEvent := event
58
- bestPath := path
49
+ if isStructuralSource(event, path) {
50
+ if !(structural.contents->Array.includes(path)) {
51
+ structural := Array.concat(structural.contents, [path])
52
+ }
53
+ } else {
54
+ sawOther := true
55
+ otherEvent := event
56
+ otherPath := path
59
57
  }
60
58
  switch pending.contents {
61
59
  | Some(t) => clearTimeout(t)
@@ -65,12 +63,21 @@ let debounce = (wait: int, fn: (event, string) => unit) => {
65
63
  Some(
66
64
  setTimeout(() => {
67
65
  pending := None
68
- let e = bestEvent.contents
69
- let p = bestPath.contents
70
- bestRank := -1
71
- bestEvent := Change
72
- bestPath := ""
73
- fn(e, p)
66
+ let paths = structural.contents
67
+ let other = sawOther.contents
68
+ let oe = otherEvent.contents
69
+ let op = otherPath.contents
70
+ structural := []
71
+ sawOther := false
72
+ otherEvent := Change
73
+ otherPath := ""
74
+ if paths->Array.length > 0 {
75
+ // One rebuild per distinct owning package; a plain edit alongside
76
+ // is covered by each structural rebuild's own re-run.
77
+ paths->Array.forEach(p => fn(Unlink, p))
78
+ } else if other {
79
+ fn(oe, op)
80
+ }
74
81
  }, wait),
75
82
  )
76
83
  }
@@ -81,7 +88,16 @@ let start = (roots: array<string>, onChange: (event, string) => unit): watcher =
81
88
  roots,
82
89
  {
83
90
  ignoreInitial: true,
84
- ignored: ["**/node_modules/**", "**/lib/**", "**/.git/**"],
91
+ // Keep in sync with Discovery's pruned dirs — writes under `dist/` or
92
+ // `.history/` are build/editor output, not source, and a `dist/` write
93
+ // could otherwise drive a re-run loop.
94
+ ignored: [
95
+ "**/node_modules/**",
96
+ "**/lib/**",
97
+ "**/.git/**",
98
+ "**/dist/**",
99
+ "**/.history/**",
100
+ ],
85
101
  },
86
102
  )
87
103
  let debounced = debounce(120, onChange)
package/src/Watch.res.mjs CHANGED
@@ -2,21 +2,6 @@
2
2
 
3
3
  import * as Chokidar from "chokidar";
4
4
 
5
- function rank(event, path) {
6
- switch (event) {
7
- case "Add" :
8
- return 1;
9
- case "Change" :
10
- return 0;
11
- case "Unlink" :
12
- if (path.endsWith(".res")) {
13
- return 2;
14
- } else {
15
- return 1;
16
- }
17
- }
18
- }
19
-
20
5
  function isStructuralSource(event, path) {
21
6
  if (event === "Unlink") {
22
7
  return path.endsWith(".res");
@@ -29,21 +14,27 @@ function debounce(wait, fn) {
29
14
  let pending = {
30
15
  contents: undefined
31
16
  };
32
- let bestRank = {
33
- contents: -1
17
+ let structural = {
18
+ contents: []
34
19
  };
35
- let bestEvent = {
20
+ let sawOther = {
21
+ contents: false
22
+ };
23
+ let otherEvent = {
36
24
  contents: "Change"
37
25
  };
38
- let bestPath = {
26
+ let otherPath = {
39
27
  contents: ""
40
28
  };
41
29
  return (event, path) => {
42
- let r = rank(event, path);
43
- if (r >= bestRank.contents) {
44
- bestRank.contents = r;
45
- bestEvent.contents = event;
46
- bestPath.contents = path;
30
+ if (isStructuralSource(event, path)) {
31
+ if (!structural.contents.includes(path)) {
32
+ structural.contents = structural.contents.concat([path]);
33
+ }
34
+ } else {
35
+ sawOther.contents = true;
36
+ otherEvent.contents = event;
37
+ otherPath.contents = path;
47
38
  }
48
39
  let t = pending.contents;
49
40
  if (t !== undefined) {
@@ -51,12 +42,22 @@ function debounce(wait, fn) {
51
42
  }
52
43
  pending.contents = setTimeout(() => {
53
44
  pending.contents = undefined;
54
- let e = bestEvent.contents;
55
- let p = bestPath.contents;
56
- bestRank.contents = -1;
57
- bestEvent.contents = "Change";
58
- bestPath.contents = "";
59
- fn(e, p);
45
+ let paths = structural.contents;
46
+ let other = sawOther.contents;
47
+ let oe = otherEvent.contents;
48
+ let op = otherPath.contents;
49
+ structural.contents = [];
50
+ sawOther.contents = false;
51
+ otherEvent.contents = "Change";
52
+ otherPath.contents = "";
53
+ if (paths.length !== 0) {
54
+ paths.forEach(p => fn("Unlink", p));
55
+ return;
56
+ } else if (other) {
57
+ return fn(oe, op);
58
+ } else {
59
+ return;
60
+ }
60
61
  }, wait);
61
62
  };
62
63
  }
@@ -67,7 +68,9 @@ function start(roots, onChange) {
67
68
  ignored: [
68
69
  "**/node_modules/**",
69
70
  "**/lib/**",
70
- "**/.git/**"
71
+ "**/.git/**",
72
+ "**/dist/**",
73
+ "**/.history/**"
71
74
  ]
72
75
  });
73
76
  let debounced = debounce(120, onChange);
@@ -75,7 +78,6 @@ function start(roots, onChange) {
75
78
  }
76
79
 
77
80
  export {
78
- rank,
79
81
  isStructuralSource,
80
82
  debounce,
81
83
  start,
@@ -19,14 +19,22 @@
19
19
  @module("node:path") external join: (string, string) => string = "join"
20
20
  @val external _processKillSig: (int, int) => unit = "process.kill"
21
21
 
22
- // `process.kill(pid, 0)` performs no signal delivery — it only probes existence
23
- // (throws ESRCH when the pid is gone), which is exactly the liveness check.
22
+ // `process.kill(pid, 0)` performs no signal delivery — it only probes existence.
23
+ // It throws `ESRCH` when the pid is gone (dead) and `EPERM` when the pid exists
24
+ // but is owned by another user (alive, just not ours). A bare catch-all treated
25
+ // EPERM as dead, which would spawn a duplicate watcher over a live foreign one;
26
+ // treat EPERM as alive.
27
+ let _errCode: JsExn.t => option<string> = %raw(`(e) => (e && typeof e.code === "string") ? e.code : undefined`)
24
28
  let isAlive = (pid: int): bool =>
25
29
  try {
26
30
  _processKillSig(pid, 0)
27
31
  true
28
32
  } catch {
29
- | _ => false
33
+ | exn =>
34
+ switch JsExn.fromException(exn) {
35
+ | Some(e) => _errCode(e) == Some("EPERM")
36
+ | None => false
37
+ }
30
38
  }
31
39
 
32
40
  let libLock = (dir: string, name: string): string => join(join(dir, "lib"), name)