@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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|