@zerotal/core 1.6.1 → 1.6.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.
package/CHANGELOG.md CHANGED
@@ -8,7 +8,51 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
- ## [1.6.1] — 2026-08-15
11
+ ## [1.6.3] — 2026-08-15
12
+
13
+ ### Added
14
+
15
+ - **`serve --dev` says when the framework on disk is no longer the framework running.** A
16
+ running dev server holds the code it imported at boot: `bun add zerotal@latest` in another
17
+ terminal rewrites `node_modules` and nothing else, and a save restarts only the _worker_,
18
+ which re-executes your app against that same in-memory framework. So an upgrade taken
19
+ mid-session appears to do nothing — the fix is installed, the symptom persists, and the
20
+ reasonable conclusion is that the fix does not work.
21
+
22
+ The supervisor now compares the version it booted with against the one installed, on each
23
+ restart, and says which is which:
24
+
25
+ ```text
26
+ [zerotal:dev] ⚠ framework upgraded on disk: running v1.6.2, installed v1.6.3
27
+ [zerotal:dev] restart the dev server to pick it up (a save will not).
28
+ ```
29
+
30
+ Once per version, so a long session is not nagged, and silent where there is nothing to read
31
+ — a workspace checkout or a hoisted layout is not a finding.
32
+
33
+ - **The dev banner carries the version** — `Zerotal v1.6.3 › dev`. There was previously
34
+ nothing on screen naming the framework a running server was actually executing.
35
+
36
+ ## [1.6.2] — 2026-08-15
37
+
38
+ ### Fixed
39
+
40
+ - **`serve --dev` killed its worker outright on Windows instead of stopping it.** A restart
41
+ sent `SIGTERM`, but Windows has no POSIX signals: there that call is `TerminateProcess`, so
42
+ the worker died mid-instruction. No provider ran `onStopping`, no open response was
43
+ finished, no database handle was closed — on every save, since that is how the dev server
44
+ reloads. The visible symptom was a console full of `ERR_INCOMPLETE_CHUNKED_ENCODING` from
45
+ the devtools event stream, which is just what a chunked response looks like when the
46
+ process writing it stops existing.
47
+
48
+ The supervisor now **asks** over an IPC channel and kills only if the request goes
49
+ unanswered within a second. POSIX behaviour is unchanged in substance — the worker runs the
50
+ same `stop()` either way — and Windows gets the orderly shutdown it never had. Verified
51
+ across a real hot restart: before, an open stream ended in a connection reset with the
52
+ worker never draining; after, the stream ends cleanly and the worker logs its stop.
53
+
54
+ A supervisor that cannot open a channel falls straight through to the signal path, so
55
+ nothing waits out the grace period for a chance it never had.
12
56
 
13
57
  ### Added
14
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -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
 
@@ -24,6 +24,7 @@ import {
24
24
  type PortOwner,
25
25
  } from "../../support/port.ts";
26
26
  import { localNetworkAddress } from "../../support/network.ts";
27
+ import { ZEROTAL_VERSION } from "../../support/version.ts";
27
28
  import { createInterface } from "node:readline";
28
29
 
29
30
  /**
@@ -319,7 +320,11 @@ export class ServeCommand extends Command {
319
320
  const network = localNetworkAddress();
320
321
 
321
322
  this.newLine();
322
- this.line(` Zerotal › ${mode}`);
323
+ // The version is here because a running server holds the framework it booted
324
+ // with: an upgrade installed in another terminal is invisible until a full
325
+ // restart, and without this line there is nothing on screen to tell you which
326
+ // one you are looking at.
327
+ this.line(` Zerotal v${ZEROTAL_VERSION} › ${mode}`);
323
328
  this.newLine();
324
329
  this._route("Local", `http://localhost:${port}`);
325
330
  this._route(
@@ -5,6 +5,8 @@
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";
9
+ import { ZEROTAL_VERSION, installedCoreVersion } from "../support/version.ts";
8
10
 
9
11
  /**
10
12
  * How the orchestrator reports to whatever is presenting dev mode.
@@ -80,6 +82,8 @@ export class DevOrchestrator {
80
82
  * queues a single follow-up instead of running in parallel.
81
83
  */
82
84
  private _restartInFlight: Promise<void> | null = null;
85
+ /** The installed version already warned about, so one upgrade warns once. */
86
+ private _warnedVersion: string | null = null;
83
87
  private _restartQueued = false;
84
88
 
85
89
  /**
@@ -156,6 +160,9 @@ export class DevOrchestrator {
156
160
  stdin: "pipe",
157
161
  stdout: routed ? "pipe" : "inherit",
158
162
  stderr: routed ? "pipe" : "inherit",
163
+ // Opens the channel `_stopChild` asks over. Nothing is sent the other
164
+ // way, but Bun only gives the parent a `send` when a handler is present.
165
+ ipc: () => {},
159
166
  cwd: this._cwd,
160
167
  env: {
161
168
  ...Bun.env,
@@ -278,17 +285,48 @@ export class DevOrchestrator {
278
285
  // browser refetches the updated CSS.
279
286
  await this._runBuild();
280
287
 
288
+ this._warnIfFrameworkUpgradedUnderneath();
289
+
281
290
  // Stop before spawning, always: the old server owns the port until it exits.
282
291
  await this._stopChild();
283
292
  await this._spawnServerWithRetry();
284
293
  }
285
294
 
295
+ /**
296
+ * Say so when the framework on disk is no longer the framework running.
297
+ *
298
+ * A `bun add zerotal@latest` in another terminal changes `node_modules` and
299
+ * nothing else: this process holds the code it imported at boot, and a restart
300
+ * only re-executes the *app* against that same in-memory framework. So the
301
+ * upgrade appears to have no effect — the fix is installed, the symptom
302
+ * persists, and the obvious conclusion is that the fix does not work.
303
+ *
304
+ * Checked on restart rather than at boot, because that is when it can have
305
+ * become true, and warned once per version so a long session is not nagged.
306
+ */
307
+ private _warnIfFrameworkUpgradedUnderneath(): void {
308
+ const onDisk = installedCoreVersion(this._cwd);
309
+ if (onDisk === null || onDisk === ZEROTAL_VERSION || onDisk === this._warnedVersion) return;
310
+
311
+ this._warnedVersion = onDisk;
312
+ this._say(
313
+ ` [zerotal:dev] ⚠ framework upgraded on disk: running v${ZEROTAL_VERSION}, installed v${onDisk}`,
314
+ "warn",
315
+ );
316
+ this._say(" [zerotal:dev] restart the dev server to pick it up (a save will not).", "warn");
317
+ }
318
+
286
319
  /** Stop the running server and wait for it to actually exit, releasing the port. */
287
320
  private async _stopChild(): Promise<void> {
288
321
  const child = this._child;
289
322
  this._child = null;
290
323
  if (!child) return;
291
324
 
325
+ // Ask first. On Windows a signal cannot reach the worker at all — it is
326
+ // terminated where it stands, with open responses left half-written — and
327
+ // asking is the only way it ever drains its providers. See devShutdown.ts.
328
+ if (await requestGracefulStop(child)) return;
329
+
292
330
  child.kill("SIGTERM");
293
331
  const forceKill = setTimeout(() => child.kill("SIGKILL"), 1_500);
294
332
  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
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The framework version this build of `@zerotal/core` is.
3
+ *
4
+ * Read from the manifest rather than written down, for the reason `@zerotal/monitor`
5
+ * already learned: a hardcoded version is correct until the next release, and three
6
+ * separate literals across the monorepo once claimed a version that had never been
7
+ * published. The monorepo releases in lockstep, so this package's manifest carries
8
+ * the framework version, and it ships inside the tarball.
9
+ *
10
+ * @module
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+
14
+ const pkg: { version: string } = JSON.parse(
15
+ readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
16
+ ) as { version: string };
17
+
18
+ /** Full version of the running framework, e.g. `1.6.2`. */
19
+ export const ZEROTAL_VERSION: string = pkg.version;
20
+
21
+ /**
22
+ * The version of `@zerotal/core` currently installed in a project, which is not
23
+ * necessarily {@link ZEROTAL_VERSION} — a long-running process holds the code it
24
+ * booted with, so an upgrade lands on disk without reaching it.
25
+ *
26
+ * @param cwd - Project root to look under.
27
+ * @returns The installed version, or `null` when there is nothing to read — a
28
+ * workspace checkout, a hoisted layout, a partial install. Absence is not a
29
+ * finding, so callers stay quiet on `null` rather than guessing.
30
+ */
31
+ export function installedCoreVersion(cwd: string): string | null {
32
+ try {
33
+ const raw = readFileSync(`${cwd}/node_modules/@zerotal/core/package.json`, "utf8");
34
+ const { version } = JSON.parse(raw) as { version?: string };
35
+ return version ?? null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }