@zerotal/core 1.6.1 → 1.6.2
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.
- package/CHANGELOG.md +20 -1
- package/package.json +1 -1
- package/src/application/Application.ts +6 -0
- package/src/dev/DevOrchestrator.ts +9 -0
- package/src/dev/devShutdown.ts +89 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,26 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
-
## [1.6.
|
|
11
|
+
## [1.6.2] — 2026-08-15
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **`serve --dev` killed its worker outright on Windows instead of stopping it.** A restart
|
|
16
|
+
sent `SIGTERM`, but Windows has no POSIX signals: there that call is `TerminateProcess`, so
|
|
17
|
+
the worker died mid-instruction. No provider ran `onStopping`, no open response was
|
|
18
|
+
finished, no database handle was closed — on every save, since that is how the dev server
|
|
19
|
+
reloads. The visible symptom was a console full of `ERR_INCOMPLETE_CHUNKED_ENCODING` from
|
|
20
|
+
the devtools event stream, which is just what a chunked response looks like when the
|
|
21
|
+
process writing it stops existing.
|
|
22
|
+
|
|
23
|
+
The supervisor now **asks** over an IPC channel and kills only if the request goes
|
|
24
|
+
unanswered within a second. POSIX behaviour is unchanged in substance — the worker runs the
|
|
25
|
+
same `stop()` either way — and Windows gets the orderly shutdown it never had. Verified
|
|
26
|
+
across a real hot restart: before, an open stream ended in a connection reset with the
|
|
27
|
+
worker never draining; after, the stream ends cleanly and the worker logs its stop.
|
|
28
|
+
|
|
29
|
+
A supervisor that cannot open a channel falls straight through to the signal path, so
|
|
30
|
+
nothing waits out the grace period for a chance it never had.
|
|
12
31
|
|
|
13
32
|
### Added
|
|
14
33
|
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import { ServiceProvider } from "../provider/ServiceProvider.ts";
|
|
|
9
9
|
import type { AuthenticatedUser } from "../auth/AuthenticatedUser.ts";
|
|
10
10
|
import * as DevWsServer from "../dev/DevWsServer.ts";
|
|
11
11
|
import { DevReloadMiddleware, setDevReloadClientActive } from "../dev/DevReloadMiddleware.ts";
|
|
12
|
+
import { onGracefulStopRequest } from "../dev/devShutdown.ts";
|
|
12
13
|
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
13
14
|
import { Pipeline } from "../pipeline/Pipeline.ts";
|
|
14
15
|
import { ExceptionHandler } from "./ExceptionHandler.ts";
|
|
@@ -1520,6 +1521,11 @@ export class Application {
|
|
|
1520
1521
|
process.on(signal, () => void this.stop());
|
|
1521
1522
|
}
|
|
1522
1523
|
|
|
1524
|
+
// Windows cannot deliver either of those to a child, so `serve --dev` asks
|
|
1525
|
+
// over IPC instead and this is the ear for it. Silent anywhere there is no
|
|
1526
|
+
// supervisor on the other end.
|
|
1527
|
+
onGracefulStopRequest(() => void this.stop());
|
|
1528
|
+
|
|
1523
1529
|
process.on("SIGUSR2", () => void this._reloadRoutes());
|
|
1524
1530
|
}
|
|
1525
1531
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { watch } from "node:fs";
|
|
6
6
|
import type { BuildHookFn } from "./DevBuildHook.ts";
|
|
7
7
|
import { DEV_WORKER_ENV_VAR } from "../support/env.ts";
|
|
8
|
+
import { requestGracefulStop } from "./devShutdown.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* How the orchestrator reports to whatever is presenting dev mode.
|
|
@@ -156,6 +157,9 @@ export class DevOrchestrator {
|
|
|
156
157
|
stdin: "pipe",
|
|
157
158
|
stdout: routed ? "pipe" : "inherit",
|
|
158
159
|
stderr: routed ? "pipe" : "inherit",
|
|
160
|
+
// Opens the channel `_stopChild` asks over. Nothing is sent the other
|
|
161
|
+
// way, but Bun only gives the parent a `send` when a handler is present.
|
|
162
|
+
ipc: () => {},
|
|
159
163
|
cwd: this._cwd,
|
|
160
164
|
env: {
|
|
161
165
|
...Bun.env,
|
|
@@ -289,6 +293,11 @@ export class DevOrchestrator {
|
|
|
289
293
|
this._child = null;
|
|
290
294
|
if (!child) return;
|
|
291
295
|
|
|
296
|
+
// Ask first. On Windows a signal cannot reach the worker at all — it is
|
|
297
|
+
// terminated where it stands, with open responses left half-written — and
|
|
298
|
+
// asking is the only way it ever drains its providers. See devShutdown.ts.
|
|
299
|
+
if (await requestGracefulStop(child)) return;
|
|
300
|
+
|
|
292
301
|
child.kill("SIGTERM");
|
|
293
302
|
const forceKill = setTimeout(() => child.kill("SIGKILL"), 1_500);
|
|
294
303
|
try {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking a dev worker to stop, on a platform where a signal cannot.
|
|
3
|
+
*
|
|
4
|
+
* Windows has no POSIX signals. `child.kill("SIGTERM")` there is
|
|
5
|
+
* `TerminateProcess`: the worker dies mid-instruction, so no provider drains, no
|
|
6
|
+
* open response is finished, and no database handle is closed — on every save,
|
|
7
|
+
* because that is how `serve --dev` restarts. The visible symptom is a browser
|
|
8
|
+
* console full of `ERR_INCOMPLETE_CHUNKED_ENCODING` from the devtools event
|
|
9
|
+
* stream, which is simply what a chunked response looks like when the process
|
|
10
|
+
* writing it stops existing.
|
|
11
|
+
*
|
|
12
|
+
* The supervisor therefore asks over the IPC channel first and kills only when
|
|
13
|
+
* the request goes unanswered. POSIX behaviour is unchanged in substance — the
|
|
14
|
+
* worker runs the same {@link Application.stop} either way — and Windows gains
|
|
15
|
+
* the orderly shutdown it never had.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The message a supervisor sends to ask a worker to shut itself down. */
|
|
21
|
+
export const DEV_SHUTDOWN_MESSAGE = "zerotal:dev:shutdown";
|
|
22
|
+
|
|
23
|
+
/** How long a supervisor waits for the worker to go on its own, in ms. */
|
|
24
|
+
export const DEV_SHUTDOWN_GRACE_MS = 1_000;
|
|
25
|
+
|
|
26
|
+
/** The part of a spawned child this module needs. */
|
|
27
|
+
export interface StoppableChild {
|
|
28
|
+
send?: (message: unknown) => void;
|
|
29
|
+
exited: Promise<number>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ask a child to shut itself down, and wait for it to actually go.
|
|
34
|
+
*
|
|
35
|
+
* @param child - The spawned worker. Must have been spawned with an `ipc`
|
|
36
|
+
* handler, or there is no channel to ask over and this returns `false`.
|
|
37
|
+
* @param graceMs - How long to wait before giving up on the request.
|
|
38
|
+
* @returns `true` if the child exited on its own, `false` if the caller still
|
|
39
|
+
* has to kill it. Never throws: a dead channel is a `false`, not an error,
|
|
40
|
+
* because the caller's next move is the same either way.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* if (!(await requestGracefulStop(child))) child.kill("SIGTERM");
|
|
44
|
+
*/
|
|
45
|
+
export async function requestGracefulStop(
|
|
46
|
+
child: StoppableChild,
|
|
47
|
+
graceMs: number = DEV_SHUTDOWN_GRACE_MS,
|
|
48
|
+
): Promise<boolean> {
|
|
49
|
+
if (typeof child.send !== "function") return false;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
child.send(DEV_SHUTDOWN_MESSAGE);
|
|
53
|
+
} catch {
|
|
54
|
+
// The channel is already gone, which means so is the chance of a polite
|
|
55
|
+
// exit. The caller kills.
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
60
|
+
try {
|
|
61
|
+
const outcome = await Promise.race([
|
|
62
|
+
child.exited.then(() => "exited" as const),
|
|
63
|
+
new Promise<"timeout">((resolve) => {
|
|
64
|
+
timer = setTimeout(() => resolve("timeout"), graceMs);
|
|
65
|
+
}),
|
|
66
|
+
]);
|
|
67
|
+
return outcome === "exited";
|
|
68
|
+
} catch {
|
|
69
|
+
// `exited` rejecting means the child is gone by some other route.
|
|
70
|
+
return false;
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run `stop` when a supervisor asks this process to shut down.
|
|
78
|
+
*
|
|
79
|
+
* Installed by {@link Application} alongside its signal handlers. Harmless in a
|
|
80
|
+
* process with no IPC channel — the message never arrives — so it needs no
|
|
81
|
+
* environment check to stay out of production's way.
|
|
82
|
+
*
|
|
83
|
+
* @param stop - What to run. Called at most once per request.
|
|
84
|
+
*/
|
|
85
|
+
export function onGracefulStopRequest(stop: () => void): void {
|
|
86
|
+
process.on("message", (message: unknown) => {
|
|
87
|
+
if (message === DEV_SHUTDOWN_MESSAGE) stop();
|
|
88
|
+
});
|
|
89
|
+
}
|