@nanobpm/urban 0.77.2 → 0.77.3

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.
@@ -4,6 +4,14 @@ export interface SchedulerDeps {
4
4
  clearTimer: (handle: unknown) => void;
5
5
  /** Current wall-clock time in ms since epoch. */
6
6
  now: () => number;
7
+ /** Optional shutdown signal. When it aborts, a pending {@link schedulerClock} `wait()` clears its
8
+ * armed timer and rejects, so a handler parked on the (virtual) clock unwinds at teardown instead
9
+ * of wedging whoever awaits it. Under the test kit this is the engine's shutdown signal: a worker
10
+ * handler parked on `app.wait()` sits on a virtual timer that no `advanceTime` will fire during
11
+ * `engine.close()`, so without this its promise never settles and `close()` hangs (the issue #446
12
+ * follow-up — the virtual-timer sibling of the real-time use-after-free). Absent on the live
13
+ * scheduler, where real timers always fire on wall-clock time, so `wait()` never rejects there. */
14
+ readonly signal?: AbortSignal;
7
15
  }
8
16
  /** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
9
17
  export declare const MAX_TIMER_DELAY_MS = 2147483647;
@@ -3,6 +3,13 @@
3
3
  // worker handlers. Keeping one definition here — rather than a copy per module — means a
4
4
  // test's fake scheduler drives them all the same way, and the "clamp a far-future delay to
5
5
  // setTimeout's 32-bit range" rule has a single home.
6
+ /** The rejection surfaced to a `wait()` cancelled by a shutdown {@link SchedulerDeps.signal}: the
7
+ * signal's `reason` when it is an {@link Error}, else a generic teardown error. Reusing the reason
8
+ * keeps a caller's fail-loud message (e.g. "WasmEngineClient closing") intact. */
9
+ function schedulerAbortError(signal) {
10
+ const reason = signal.reason;
11
+ return reason instanceof Error ? reason : new Error("app.wait aborted: scheduler shutting down");
12
+ }
6
13
  /** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
7
14
  export const MAX_TIMER_DELAY_MS = 2_147_483_647;
8
15
  function isTimerHandle(handle) {
@@ -32,9 +39,48 @@ export function defaultScheduler() {
32
39
  export function schedulerClock(sched) {
33
40
  return {
34
41
  now: () => sched.now(),
35
- wait: (ms) => new Promise((resolve) => {
42
+ wait: (ms) => new Promise((resolve, reject) => {
43
+ const signal = sched.signal;
44
+ // Already shutting down: reject immediately rather than arm a timer that would outlive the
45
+ // teardown (and, under the virtual clock, never fire).
46
+ if (signal?.aborted) {
47
+ reject(schedulerAbortError(signal));
48
+ return;
49
+ }
36
50
  const delay = Number.isFinite(ms) && ms > 0 ? Math.min(ms, MAX_TIMER_DELAY_MS) : 0;
37
- sched.setTimer(() => resolve(), delay);
51
+ let settled = false;
52
+ let onAbort;
53
+ const handle = sched.setTimer(() => {
54
+ settled = true;
55
+ if (onAbort && signal)
56
+ signal.removeEventListener("abort", onAbort);
57
+ resolve();
58
+ }, delay);
59
+ // When a shutdown signal is present, a mid-wait abort disarms the timer and rejects, so a
60
+ // handler parked here unwinds at teardown instead of wedging its awaiter. Inert (no listener,
61
+ // no behaviour change) on the live scheduler, which supplies no signal.
62
+ //
63
+ // `settled` guards the synchronous-timer case: this seam permits `setTimer` to invoke its
64
+ // callback synchronously (the test `capturingScheduler` does exactly that), which resolves
65
+ // the wait *before* we get here. Installing an abort listener on that already-settled promise
66
+ // would leak its closure until the signal fires (and have shutdown clear an already-fired
67
+ // handle), so skip it entirely when the callback already ran.
68
+ if (signal && !settled) {
69
+ onAbort = () => {
70
+ sched.clearTimer(handle);
71
+ reject(schedulerAbortError(signal));
72
+ };
73
+ signal.addEventListener("abort", onAbort, { once: true });
74
+ // Re-entrant seam: `setTimer` above is injectable and may abort `signal` *synchronously*
75
+ // without firing the timer callback (so `settled` is still false). An abort event is not
76
+ // replayed to a listener added after the fact, so the listener just registered would never
77
+ // run and the wait would hang until the runner timeout. Re-check and drive the abort path
78
+ // by hand — removing the (now-inert) listener first so it cannot also fire and double-reject.
79
+ if (signal.aborted && !settled) {
80
+ signal.removeEventListener("abort", onAbort);
81
+ onAbort();
82
+ }
83
+ }
38
84
  }),
39
85
  };
40
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.77.2",
3
+ "version": "0.77.3",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -42,3 +42,108 @@ test("schedulerClock.wait clamps non-positive / non-finite delays to the current
42
42
  await schedulerClock(sched).wait(Number.POSITIVE_INFINITY);
43
43
  assert.deepEqual(sched.delays, [0, 0, 0]);
44
44
  });
45
+
46
+ // Regression (issue #446 follow-up): a handler parked on `app.wait()` sits on a (virtual) timer that
47
+ // no clock advance fires during teardown, so whoever awaits it — the test kit's `engine.close()` —
48
+ // hangs forever. `schedulerClock().wait()` now honours an optional shutdown `signal`: a mid-wait
49
+ // abort disarms the armed timer and rejects, so the parked handler unwinds at teardown. The live
50
+ // scheduler supplies no signal, so this is wholly inert in production.
51
+ test("schedulerClock.wait disarms its timer and rejects when the shutdown signal aborts mid-wait", async () => {
52
+ const controller = new AbortController();
53
+ const cleared: unknown[] = [];
54
+ let armed: (() => void) | undefined;
55
+ const sched: SchedulerDeps = {
56
+ now: () => 0,
57
+ signal: controller.signal,
58
+ setTimer: (fn) => {
59
+ armed = fn;
60
+ return 42;
61
+ },
62
+ clearTimer: (h) => cleared.push(h),
63
+ };
64
+ const waiting = schedulerClock(sched).wait(10_000);
65
+ assert.equal(typeof armed, "function", "wait must arm a timer while not aborted");
66
+ controller.abort(new Error("engine shutting down"));
67
+ await assert.rejects(waiting, /engine shutting down/, "an aborted wait must reject with the signal's reason");
68
+ assert.deepEqual(cleared, [42], "the armed timer must be cleared on abort so it cannot fire later");
69
+ });
70
+
71
+ test("schedulerClock.wait rejects immediately (arming no timer) when the signal is already aborted", async () => {
72
+ const controller = new AbortController();
73
+ controller.abort(new Error("already shutting down"));
74
+ let armedCount = 0;
75
+ const sched: SchedulerDeps = {
76
+ now: () => 0,
77
+ signal: controller.signal,
78
+ setTimer: () => {
79
+ armedCount += 1;
80
+ return 0;
81
+ },
82
+ clearTimer: () => {},
83
+ };
84
+ await assert.rejects(schedulerClock(sched).wait(1000), /already shutting down/);
85
+ assert.equal(armedCount, 0, "an already-aborted signal must reject without arming a timer");
86
+ });
87
+
88
+ // Regression (PR #447 review, scheduler.ts): this seam permits `setTimer` to fire its callback
89
+ // SYNCHRONOUSLY (the `capturingScheduler` above does). When it does — and a shutdown `signal` is
90
+ // present — the wait resolves before we would install the abort listener, so we must NOT install
91
+ // one: attaching a listener to an already-settled promise leaks its closure until the signal fires
92
+ // and later has shutdown clear an already-fired timer handle. A synchronously-resolved wait must
93
+ // leave zero listeners on the signal and must not clear its (already-fired) handle on a later abort.
94
+ test("schedulerClock.wait installs no abort listener when a signalled wait resolves synchronously", async () => {
95
+ const controller = new AbortController();
96
+ const cleared: unknown[] = [];
97
+ const sched: SchedulerDeps = {
98
+ now: () => 0,
99
+ signal: controller.signal,
100
+ setTimer: (fn) => {
101
+ fn(); // fire synchronously — the wait resolves before any listener could be installed
102
+ return 7;
103
+ },
104
+ clearTimer: (h) => cleared.push(h),
105
+ };
106
+ await schedulerClock(sched).wait(0);
107
+ // A leaked listener would fire here and clear the stale handle; the fix installs none.
108
+ controller.abort(new Error("shutting down"));
109
+ assert.deepEqual(cleared, [], "a synchronously-resolved wait must not install an abort listener");
110
+ });
111
+ // The abort reason is attacker/caller-controlled: guard the case where it is a bare value rather
112
+ // than an Error so `wait()` still rejects with a generic teardown error instead of leaking it.
113
+ test("schedulerClock.wait rejects with a generic teardown error when the abort reason is not an Error", async () => {
114
+ const controller = new AbortController();
115
+ const sched: SchedulerDeps = {
116
+ now: () => 0,
117
+ signal: controller.signal,
118
+ setTimer: () => 0,
119
+ clearTimer: () => {},
120
+ };
121
+ const waiting = schedulerClock(sched).wait(1000);
122
+ controller.abort("not-an-error");
123
+ await assert.rejects(waiting, /scheduler shutting down/);
124
+ });
125
+
126
+ // Regression (PR #447 review, scheduler.ts:108): `setTimer` is an injectable seam that may abort the
127
+ // shutdown `signal` SYNCHRONOUSLY while arming, WITHOUT firing its callback (a re-entrant scheduler).
128
+ // An abort event is not replayed to a listener added after the fact, so a wait that merely installed
129
+ // its listener would hang forever — never rejecting, never clearing the armed timer. `wait()` must
130
+ // re-check `signal.aborted` after installing the listener and drive the abort path by hand.
131
+ test("schedulerClock.wait rejects (and clears its timer) when setTimer aborts the signal synchronously", async () => {
132
+ const controller = new AbortController();
133
+ const cleared: unknown[] = [];
134
+ const sched: SchedulerDeps = {
135
+ now: () => 0,
136
+ signal: controller.signal,
137
+ setTimer: () => {
138
+ controller.abort(new Error("aborted during setTimer"));
139
+ return 99;
140
+ },
141
+ clearTimer: (h) => cleared.push(h),
142
+ };
143
+ await assert.rejects(
144
+ schedulerClock(sched).wait(1000),
145
+ /aborted during setTimer/,
146
+ "a synchronous mid-arm abort must reject the wait, not leave it hanging",
147
+ );
148
+ assert.deepEqual(cleared, [99], "the armed timer must be cleared when the synchronous abort is handled");
149
+ });
@@ -10,6 +10,22 @@ export interface SchedulerDeps {
10
10
  clearTimer: (handle: unknown) => void;
11
11
  /** Current wall-clock time in ms since epoch. */
12
12
  now: () => number;
13
+ /** Optional shutdown signal. When it aborts, a pending {@link schedulerClock} `wait()` clears its
14
+ * armed timer and rejects, so a handler parked on the (virtual) clock unwinds at teardown instead
15
+ * of wedging whoever awaits it. Under the test kit this is the engine's shutdown signal: a worker
16
+ * handler parked on `app.wait()` sits on a virtual timer that no `advanceTime` will fire during
17
+ * `engine.close()`, so without this its promise never settles and `close()` hangs (the issue #446
18
+ * follow-up — the virtual-timer sibling of the real-time use-after-free). Absent on the live
19
+ * scheduler, where real timers always fire on wall-clock time, so `wait()` never rejects there. */
20
+ readonly signal?: AbortSignal;
21
+ }
22
+
23
+ /** The rejection surfaced to a `wait()` cancelled by a shutdown {@link SchedulerDeps.signal}: the
24
+ * signal's `reason` when it is an {@link Error}, else a generic teardown error. Reusing the reason
25
+ * keeps a caller's fail-loud message (e.g. "WasmEngineClient closing") intact. */
26
+ function schedulerAbortError(signal: AbortSignal): Error {
27
+ const reason: unknown = signal.reason;
28
+ return reason instanceof Error ? reason : new Error("app.wait aborted: scheduler shutting down");
13
29
  }
14
30
 
15
31
  /** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
@@ -58,9 +74,47 @@ export function schedulerClock(sched: SchedulerDeps): AppClock {
58
74
  return {
59
75
  now: () => sched.now(),
60
76
  wait: (ms) =>
61
- new Promise<void>((resolve) => {
77
+ new Promise<void>((resolve, reject) => {
78
+ const signal = sched.signal;
79
+ // Already shutting down: reject immediately rather than arm a timer that would outlive the
80
+ // teardown (and, under the virtual clock, never fire).
81
+ if (signal?.aborted) {
82
+ reject(schedulerAbortError(signal));
83
+ return;
84
+ }
62
85
  const delay = Number.isFinite(ms) && ms > 0 ? Math.min(ms, MAX_TIMER_DELAY_MS) : 0;
63
- sched.setTimer(() => resolve(), delay);
86
+ let settled = false;
87
+ let onAbort: (() => void) | undefined;
88
+ const handle = sched.setTimer(() => {
89
+ settled = true;
90
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
91
+ resolve();
92
+ }, delay);
93
+ // When a shutdown signal is present, a mid-wait abort disarms the timer and rejects, so a
94
+ // handler parked here unwinds at teardown instead of wedging its awaiter. Inert (no listener,
95
+ // no behaviour change) on the live scheduler, which supplies no signal.
96
+ //
97
+ // `settled` guards the synchronous-timer case: this seam permits `setTimer` to invoke its
98
+ // callback synchronously (the test `capturingScheduler` does exactly that), which resolves
99
+ // the wait *before* we get here. Installing an abort listener on that already-settled promise
100
+ // would leak its closure until the signal fires (and have shutdown clear an already-fired
101
+ // handle), so skip it entirely when the callback already ran.
102
+ if (signal && !settled) {
103
+ onAbort = () => {
104
+ sched.clearTimer(handle);
105
+ reject(schedulerAbortError(signal));
106
+ };
107
+ signal.addEventListener("abort", onAbort, { once: true });
108
+ // Re-entrant seam: `setTimer` above is injectable and may abort `signal` *synchronously*
109
+ // without firing the timer callback (so `settled` is still false). An abort event is not
110
+ // replayed to a listener added after the fact, so the listener just registered would never
111
+ // run and the wait would hang until the runner timeout. Re-check and drive the abort path
112
+ // by hand — removing the (now-inert) listener first so it cannot also fire and double-reject.
113
+ if (signal.aborted && !settled) {
114
+ signal.removeEventListener("abort", onAbort);
115
+ onAbort();
116
+ }
117
+ }
64
118
  }),
65
119
  };
66
120
  }