openvibers/openvibe.games Commits

Graceful stop (WS-P lifecycle follow-up): SIGTERM used to exit synchronously, cutting every WebSocket session and HTTP request in flight and never awaiting the outbox. Now, within 10 s (the unit allows 30): stop taking connections and WebSocket upgrades (503) while requests in flight carry on with Connection: close; stop the simulation, metrics and prune timers, the editor lock sweeper and the media mirror; announce the restart to every player, then close every game and editor socket with 1012 service restart (each game close saves that character and records games.player.left; clients reconnect on their own; a socket that does not finish the handshake in 3 s is terminated); save the world; let requests finish (5 s at most); await the mirror pass, the progress-summary writes and the outbox's last send; close world.db; exit 0 (exit 1 only past the deadline or on a failed stop). MediaMirror.stop() now resolves when its pass ends and schedules nothing after; ProgressSummaryWriter.settle(). test: apps/server/src/shutdown.test.ts spawns the server as systemd runs it (outbox on), SIGTERM with a player socket, an editor socket and an HTTP request open: both sockets get 1012, new connections are refused, the request is answered, the outbox is stopped, exit 0 well inside the deadline

OP OpenVibers committed 26 Sept 2026 · 142d4ec0a7 · 17 files, +438 −31


Changes

ddiff --git a/apps/server/dist-types/game/gameServer.d.ts b/apps/server/dist-types/game/gameServer.d.ts
iindex 041e58f..9a87584 100644
---- a/apps/server/dist-types/game/gameServer.d.ts
++++ b/apps/server/dist-types/game/gameServer.d.ts
@@@ -189,6 +189,12 @@ export declare class GameServer {
* then fails its /api/auth/me check. Returns how many closed.
*/
revokeSubject(subjectId: string, validAfterMs: number): number;
++ /**
++ * Graceful stop (WS-P lifecycle): everyone in the world is told the server is restarting. main.ts
++ * then closes every socket with 1012 (service restart); each close saves that character and records
++ * games.player.left, as when a player leaves, and the client reconnects on its own.
++ */
++ announceRestart(): void;
broadcastMapReload(): void;
/** Exposes crafting context for the client-facing recipe availability (welcome-time). */
workstationsNear(session: PlayerSession): ReadonlySet<string>;
ddiff --git a/apps/server/dist-types/game/gameServer.d.ts.map b/apps/server/dist-types/game/gameServer.d.ts.map
iindex 2a768ac..53b3541 100644
---- a/apps/server/dist-types/game/gameServer.d.ts.map
++++ b/apps/server/dist-types/game/gameServer.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
++{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/editorWs.d.ts b/apps/server/dist-types/net/editorWs.d.ts
iindex 7d6098d..c1a9bad 100644
---- a/apps/server/dist-types/net/editorWs.d.ts
++++ b/apps/server/dist-types/net/editorWs.d.ts
@@@ -4,6 +4,8 @@ import type { Logger } from '@openvibe/shared';
import type { EditorAuth } from './httpServer.js';
export interface EditorHub {
wss: WebSocketServer;
++ /** Graceful stop: the lease sweeper stops. */
++ stop(): void;
broadcastSaved(revision: string): void;
/** Live peer count, for tests and diagnostics. */
peerCount(): number;
ddiff --git a/apps/server/dist-types/net/editorWs.d.ts.map b/apps/server/dist-types/net/editorWs.d.ts.map
iindex c1976ad..b3c2e61 100644
---- a/apps/server/dist-types/net/editorWs.d.ts.map
++++ b/apps/server/dist-types/net/editorWs.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"editorWs.d.ts","sourceRoot":"","sources":["../../src/net/editorWs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EAAE,eAAe,EAAkB,MAAM,IAAI,CAAA;AACpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAU9C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAgCjD,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,eAAe,CAAA;IACpB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACtC,kDAAkD;IAClD,SAAS,IAAI,MAAM,CAAA;CACpB;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,SAAS,CAiMrF"}
\\ No newline at end of file
++{"version":3,"file":"editorWs.d.ts","sourceRoot":"","sources":["../../src/net/editorWs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EAAE,eAAe,EAAkB,MAAM,IAAI,CAAA;AACpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAU9C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAgCjD,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,eAAe,CAAA;IACpB,8CAA8C;IAC9C,IAAI,IAAI,IAAI,CAAA;IACZ,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACtC,kDAAkD;IAClD,SAAS,IAAI,MAAM,CAAA;CACpB;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,SAAS,CAqMrF"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/gracefulStop.d.ts b/apps/server/dist-types/net/gracefulStop.d.ts
nnew file mode 100644
iindex 0000000..48a913b
---- /dev/null
++++ b/apps/server/dist-types/net/gracefulStop.d.ts
@@@ -0,0 +1,38 @@
++import type { Server } from 'node:http';
++import type { WebSocketServer } from 'ws';
++/**
++ * Graceful stop (roadmap WS-P lifecycle; openvibe-contracts manifests/services/games.json →
++ * lifecycle.shutdown). main.ts runs the order; these are the network halves of it.
++ *
++ * The unit's TimeoutStopSec is 30: the whole stop is bounded by DEADLINE_MS (the manifest's
++ * deadlineSeconds), well inside it. Requests in flight get DRAIN_MS; sockets that do not answer the
++ * close handshake within SOCKETS_MS are terminated.
++ */
++export declare const DEADLINE_MS = 10000;
++export declare const DRAIN_MS = 5000;
++export declare const SOCKETS_MS = 3000;
++/** WebSocket close code for "service restart" (RFC 6455 registry): the clients reconnect on their own. */
++export declare const CLOSE_RESTART = 1012;
++export interface HttpDrainer {
++ /**
++ * Stop taking connections, close idle keep-alive ones, answer every request in flight with
++ * Connection: close. Resolves 0 once every connection has ended, or after `ms` with how many
++ * requests were still open (their connections are then cut).
++ */
++ close(ms: number): Promise<number>;
++ stopping(): boolean;
++}
++/** Tracks responses in flight on `server`. Attach before the server takes requests. */
++export declare function httpDrainer(server: Server): HttpDrainer;
++/**
++ * Close every client of `wss` with 1012 (service restart) and `reason`; a client that has not
++ * finished the close handshake after `ms` is terminated. Resolves once every one has closed (its
++ * 'close' handlers have run).
++ */
++export declare function closeSockets(wss: WebSocketServer, ms: number, reason?: string): Promise<{
++ closed: number;
++ terminated: number;
++}>;
++/** Await `p`, but no longer than `ms`. */
++export declare function within<T>(ms: number, p: Promise<T> | undefined | null): Promise<T | undefined>;
++//# sourceMappingURL=gracefulStop.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/gracefulStop.d.ts.map b/apps/server/dist-types/net/gracefulStop.d.ts.map
nnew file mode 100644
iindex 0000000..5ffdc2b
---- /dev/null
++++ b/apps/server/dist-types/net/gracefulStop.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"gracefulStop.d.ts","sourceRoot":"","sources":["../../src/net/gracefulStop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAkB,MAAM,WAAW,CAAA;AACvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,IAAI,CAAA;AAEzC;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,QAAS,CAAA;AACjC,eAAO,MAAM,QAAQ,OAAQ,CAAA;AAC7B,eAAO,MAAM,UAAU,OAAQ,CAAA;AAE/B,0GAA0G;AAC1G,eAAO,MAAM,aAAa,OAAO,CAAA;AAEjC,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAClC,QAAQ,IAAI,OAAO,CAAA;CACpB;AAED,uFAAuF;AACvF,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAiCvD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,eAAe,EACpB,EAAE,EAAE,MAAM,EACV,MAAM,SAAmB,GACxB,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CA+BjD;AAED,0CAA0C;AAC1C,wBAAgB,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAS9F"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/mediaMirror.d.ts b/apps/server/dist-types/platform/mediaMirror.d.ts
iindex 966e9cd..00c8c06 100644
---- a/apps/server/dist-types/platform/mediaMirror.d.ts
++++ b/apps/server/dist-types/platform/mediaMirror.d.ts
@@@ -14,6 +14,7 @@ export declare class MediaMirror {
private readonly opts;
private timer;
private running;
++ private stopped;
private readonly now;
constructor(opts: MediaMirrorOptions);
/** Queues one stored asset (idempotent) and wakes the worker. */
@@@ -26,7 +27,8 @@ export declare class MediaMirror {
/** Queues every content-addressed asset already on disk (a no-op for known ones). */
backfill(): Promise<number>;
start(): void;
-- stop(): void;
++ /** No new passes; resolves when the pass in progress (if any) has finished. */
++ stop(): Promise<void>;
kick(): void;
/** Mirrors every due row once. Concurrent callers share the pass. */
runOnce(): Promise<void>;
ddiff --git a/apps/server/dist-types/platform/mediaMirror.d.ts.map b/apps/server/dist-types/platform/mediaMirror.d.ts.map
iindex e5389b8..17d2c69 100644
---- a/apps/server/dist-types/platform/mediaMirror.d.ts.map
++++ b/apps/server/dist-types/platform/mediaMirror.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"mediaMirror.d.ts","sourceRoot":"","sources":["../../src/platform/mediaMirror.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAkB,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAC7E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AA2BvD,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,cAAc,CAAA;IACtB,KAAK,EAAE,gBAAgB,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,qBAAa,WAAW;IAKV,OAAO,CAAC,QAAQ,CAAC,IAAI;IAJjC,OAAO,CAAC,KAAK,CAA8B;IAC3C,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAc;gBAEL,IAAI,EAAE,kBAAkB;IAIrD,iEAAiE;IACjE,OAAO,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAUhF,qFAAqF;IAC/E,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC;IAqBjC,KAAK,IAAI,IAAI;IAIb,IAAI,IAAI,IAAI;IAKZ,IAAI,IAAI,IAAI;IAIZ,qEAAqE;IACrE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IASxB,OAAO,CAAC,QAAQ;YASF,IAAI;IAUlB,OAAO,CAAC,IAAI;YAIE,MAAM;IA4DpB,OAAO,CAAC,IAAI;CAoBb"}
\\ No newline at end of file
++{"version":3,"file":"mediaMirror.d.ts","sourceRoot":"","sources":["../../src/platform/mediaMirror.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAkB,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAC7E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AA2BvD,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,cAAc,CAAA;IACtB,KAAK,EAAE,gBAAgB,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,qBAAa,WAAW;IAMV,OAAO,CAAC,QAAQ,CAAC,IAAI;IALjC,OAAO,CAAC,KAAK,CAA8B;IAC3C,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAc;gBAEL,IAAI,EAAE,kBAAkB;IAIrD,iEAAiE;IACjE,OAAO,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAUhF,qFAAqF;IAC/E,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC;IAqBjC,KAAK,IAAI,IAAI;IAKb,+EAA+E;IAC/E,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAOrB,IAAI,IAAI,IAAI;IAIZ,qEAAqE;IACrE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IASxB,OAAO,CAAC,QAAQ;YAUF,IAAI;IAUlB,OAAO,CAAC,IAAI;YAIE,MAAM;IA4DpB,OAAO,CAAC,IAAI;CAoBb"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/progressSummary.d.ts b/apps/server/dist-types/platform/progressSummary.d.ts
iindex cc8063b..096b60b 100644
---- a/apps/server/dist-types/platform/progressSummary.d.ts
++++ b/apps/server/dist-types/platform/progressSummary.d.ts
@@@ -22,12 +22,15 @@ export declare class ProgressSummaryWriter {
private readonly joined;
private readonly modules;
private inflight;
++ private readonly writes;
constructor(client: OpenVibeClient, log: Log, now?: () => number);
/** A character entered the world (its session starts counting). */
playerJoined(playerId: string): void;
/** A character left: fold the session into the person's summary. Resolves when written (or given up). */
playerLeft(playerId: string, subjectId: string | null, levels: Record<string, number>, world: string): Promise<void>;
pending(): number;
++ /** Graceful stop: resolves when every write in flight has finished (or given up). */
++ settle(): Promise<void>;
}
export {};
//# sourceMappingURL=progressSummary.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/progressSummary.d.ts.map b/apps/server/dist-types/platform/progressSummary.d.ts.map
iindex 4f74c07..145b873 100644
---- a/apps/server/dist-types/platform/progressSummary.d.ts.map
++++ b/apps/server/dist-types/platform/progressSummary.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"progressSummary.d.ts","sourceRoot":"","sources":["../../src/platform/progressSummary.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAEvD,eAAO,MAAM,SAAS,2BAA2B,CAAA;AAGjD,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,cAAc,EAAE,MAAM,CAAA;IACtB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,kEAAkE;AAClE,wBAAgB,YAAY,CAC1B,IAAI,EAAE,eAAe,GAAG,SAAS,EACjC,CAAC,EAAE,cAAc,GAChB,eAAe,CAUjB;AAED,UAAU,GAAG;IACX,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;CAC1D;AAED,qBAAa,qBAAqB;IAO9B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPtB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsD;IAC9E,OAAO,CAAC,QAAQ,CAAI;gBAGlB,MAAM,EAAE,cAAc,EACL,GAAG,EAAE,GAAG,EACR,GAAG,GAAE,MAAM,MAAiB;IAK/C,mEAAmE;IACnE,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAIpC,yGAAyG;IACzG,UAAU,CACR,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC;IA0BhB,OAAO,IAAI,MAAM;CAGlB"}
\\ No newline at end of file
++{"version":3,"file":"progressSummary.d.ts","sourceRoot":"","sources":["../../src/platform/progressSummary.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAEvD,eAAO,MAAM,SAAS,2BAA2B,CAAA;AAGjD,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,cAAc,EAAE,MAAM,CAAA;IACtB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,kEAAkE;AAClE,wBAAgB,YAAY,CAC1B,IAAI,EAAE,eAAe,GAAG,SAAS,EACjC,CAAC,EAAE,cAAc,GAChB,eAAe,CAUjB;AAED,UAAU,GAAG;IACX,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;CAC1D;AAED,qBAAa,qBAAqB;IAQ9B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,GAAG;IARtB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsD;IAC9E,OAAO,CAAC,QAAQ,CAAI;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2B;gBAGhD,MAAM,EAAE,cAAc,EACL,GAAG,EAAE,GAAG,EACR,GAAG,GAAE,MAAM,MAAiB;IAK/C,mEAAmE;IACnE,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAIpC,yGAAyG;IACzG,UAAU,CACR,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC;IA6BhB,OAAO,IAAI,MAAM;IAIjB,qFAAqF;IACrF,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;CAGxB"}
\\ No newline at end of file
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 9c8fb48..8fd2082 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -2733,6 +2733,18 @@ export class GameServer {
return closed
}
++ /**
++ * Graceful stop (WS-P lifecycle): everyone in the world is told the server is restarting. main.ts
++ * then closes every socket with 1012 (service restart); each close saves that character and records
++ * games.player.left, as when a player leaves, and the client reconnects on its own.
++ */
++ announceRestart(): void {
++ this.broadcastAll({
++ t: 'announce',
++ text: '🔄 The server is restarting: you will reconnect in a moment…',
++ })
++ }
++
broadcastMapReload(): void {
this.broadcastAll({ t: 'map_reload' })
this.broadcastAll({ t: 'announce', text: '🗺 The world was reshaped by the map editors…' })
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 5ec1c9c..23375ee 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -15,6 +15,14 @@ import { GameServer } from './game/gameServer.js'
import { GameWorld } from './game/gameWorld.js'
import { loadHavok } from './havokLoader.js'
import { attachEditorWs } from './net/editorWs.js'
++import {
++ closeSockets,
++ DEADLINE_MS,
++ DRAIN_MS,
++ httpDrainer,
++ SOCKETS_MS,
++ within,
++} from './net/gracefulStop.js'
import { createHttpServer } from './net/httpServer.js'
import { resolveNetworkUser } from './net/networkAuth.js'
import { attachWebSocket } from './net/wsTransport.js'
@@@ -154,18 +162,14 @@ async function main(): Promise<void> {
const pruneTimer = setInterval(() => outbox?.prune(), 6 * 3600 * 1000)
pruneTimer.unref()
++ // games.progress.summary on Network (WS-B task 9): needs the games principal.
++ const progressSummary = platform
++ ? new ProgressSummaryWriter(platform.client, platformLog.child({ system: 'progress-summary' }))
++ : null
const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }), {
...(recorder ? { events: recorder } : {}),
mods: modRuntime,
-- // games.progress.summary on Network (WS-B task 9): needs the games principal.
-- ...(platform
-- ? {
-- progressSummary: new ProgressSummaryWriter(
-- platform.client,
-- platformLog.child({ system: 'progress-summary' }),
-- ),
-- }
-- : {}),
++ ...(progressSummary ? { progressSummary } : {}),
})
// Sign-out everywhere (network.user.token_valid_after): POST /internal/events closes the person's
@@@ -299,6 +303,7 @@ async function main(): Promise<void> {
onAssetStored: (asset) => mirror?.enqueue(asset),
},
)
++ const drainer = httpDrainer(http)
world.reconcileMapNodes()
world.reconcileMapProps()
const gameWss = attachWebSocket(http, game, log.child({ system: 'ws' }))
@@@ -309,7 +314,10 @@ async function main(): Promise<void> {
)
http.on('upgrade', (req, socket, head) => {
const path = (req.url ?? '').split('?')[0]
-- if (path === '/ws') {
++ if (drainer.stopping()) {
++ // Stopping: no new sessions (the client retries after the restart).
++ socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nRetry-After: 5\r\n\r\n')
++ } else if (path === '/ws') {
gameWss.handleUpgrade(req, socket, head, (ws) => gameWss.emit('connection', ws, req))
} else if (path === '/editor-ws') {
editors.wss.handleUpgrade(req, socket, head, (ws) => editors.wss.emit('connection', ws, req))
@@@ -341,21 +349,60 @@ async function main(): Promise<void> {
const metricsTimer = setInterval(() => metrics.logSummary(log), config.metricsLogSeconds * 1000)
-- const shutdown = (signal: string): void => {
-- log.info('shutting down', { signal })
-- running = false
-- clearInterval(metricsTimer)
-- game.shutdown()
-- mirror?.stop()
-- // Unsent events stay in the outbox table and go out after the restart.
-- void outbox?.stop()
-- store.close()
-- http.close()
-- physics.dispose()
-- process.exit(0)
++ // ── Stop (roadmap WS-P lifecycle; manifests/services/games.json → lifecycle.shutdown) ──
++ // systemd sends SIGTERM (TimeoutStopSec=30). In order: stop taking connections (HTTP and WebSocket
++ // upgrades) while requests in flight carry on; stop the simulation, the metrics and prune timers and
++ // the media mirror; tell every player, then close every game and editor socket with 1012 (each game
++ // close saves that character and records games.player.left; the clients reconnect on their own);
++ // save the world; let requests in flight finish (DRAIN_MS); let the mirror pass and the progress
++ // summaries settle; stop the event outbox and await its last send (unsent rows stay in the table
++ // for the next start); close world.db; exit 0. Everything within DEADLINE_MS, else exit 1.
++ let stopping: Promise<void> | null = null
++ const shutdown = (signal: string): Promise<void> => {
++ if (stopping) return stopping
++ stopping = (async () => {
++ const t0 = Date.now()
++ log.info('shutting down', { signal, deadlineMs: DEADLINE_MS })
++ const hard = setTimeout(() => {
++ log.error('shutdown deadline passed: exiting 1', { deadlineMs: DEADLINE_MS })
++ process.exit(1)
++ }, DEADLINE_MS)
++ hard.unref()
++ const httpDone = drainer.close(DRAIN_MS)
++ running = false
++ clearInterval(metricsTimer)
++ clearInterval(pruneTimer)
++ editors.stop()
++ const mirrorDone = mirror?.stop()
++ game.announceRestart()
++ const [players, editorPeers] = await Promise.all([
++ closeSockets(gameWss, SOCKETS_MS),
++ closeSockets(editors.wss, SOCKETS_MS),
++ ])
++ game.shutdown()
++ const cut = await httpDone
++ await within(2000, mirrorDone)
++ await within(1000, progressSummary?.settle())
++ await within(2000, outbox?.stop())
++ store.close()
++ physics.dispose()
++ log.info('stopped', {
++ ms: Date.now() - t0,
++ players: players.closed + players.terminated,
++ editors: editorPeers.closed + editorPeers.terminated,
++ terminated: players.terminated + editorPeers.terminated,
++ requestsCut: cut,
++ outbox: outbox ? 'stopped' : 'off',
++ })
++ process.exit(0)
++ })().catch((err: unknown) => {
++ log.error('shutdown failed: exiting 1', { error: String((err as Error)?.stack ?? err) })
++ process.exit(1)
++ })
++ return stopping
}
-- process.on('SIGINT', () => shutdown('SIGINT'))
-- process.on('SIGTERM', () => shutdown('SIGTERM'))
++ process.on('SIGINT', () => void shutdown('SIGINT'))
++ process.on('SIGTERM', () => void shutdown('SIGTERM'))
}
main().catch((err: unknown) => {
ddiff --git a/apps/server/src/net/editorWs.ts b/apps/server/src/net/editorWs.ts
iindex b9ab2f3..1af860d 100644
---- a/apps/server/src/net/editorWs.ts
++++ b/apps/server/src/net/editorWs.ts
@@@ -44,6 +44,8 @@ const PEER_COLORS = ['#ff9d4d', '#4dc3ff', '#7dff6e', '#ff6ec7', '#ffe14d', '#b3
export interface EditorHub {
wss: WebSocketServer
++ /** Graceful stop: the lease sweeper stops. */
++ stop(): void
broadcastSaved(revision: string): void
/** Live peer count, for tests and diagnostics. */
peerCount(): number
@@@ -233,6 +235,10 @@ export function attachEditorWs(http: Server, auth: EditorAuth, log: Logger): Edi
return {
wss,
++ /** Graceful stop: the lease sweeper stops (main.ts closes the sockets). */
++ stop(): void {
++ clearInterval(sweeper)
++ },
broadcastSaved(revision: string): void {
broadcast({ t: 'mapSaved', revision })
},
ddiff --git a/apps/server/src/net/gracefulStop.ts b/apps/server/src/net/gracefulStop.ts
nnew file mode 100644
iindex 0000000..13b88a1
---- /dev/null
++++ b/apps/server/src/net/gracefulStop.ts
@@@ -0,0 +1,117 @@
++import type { Server, ServerResponse } from 'node:http'
++import type { WebSocketServer } from 'ws'
++
++/**
++ * Graceful stop (roadmap WS-P lifecycle; openvibe-contracts manifests/services/games.json →
++ * lifecycle.shutdown). main.ts runs the order; these are the network halves of it.
++ *
++ * The unit's TimeoutStopSec is 30: the whole stop is bounded by DEADLINE_MS (the manifest's
++ * deadlineSeconds), well inside it. Requests in flight get DRAIN_MS; sockets that do not answer the
++ * close handshake within SOCKETS_MS are terminated.
++ */
++export const DEADLINE_MS = 10_000
++export const DRAIN_MS = 5_000
++export const SOCKETS_MS = 3_000
++
++/** WebSocket close code for "service restart" (RFC 6455 registry): the clients reconnect on their own. */
++export const CLOSE_RESTART = 1012
++
++export interface HttpDrainer {
++ /**
++ * Stop taking connections, close idle keep-alive ones, answer every request in flight with
++ * Connection: close. Resolves 0 once every connection has ended, or after `ms` with how many
++ * requests were still open (their connections are then cut).
++ */
++ close(ms: number): Promise<number>
++ stopping(): boolean
++}
++
++/** Tracks responses in flight on `server`. Attach before the server takes requests. */
++export function httpDrainer(server: Server): HttpDrainer {
++ const inflight = new Set<ServerResponse>()
++ let closing = false
++ server.prependListener('request', (_req, res: ServerResponse) => {
++ if (closing && !res.headersSent) res.setHeader('connection', 'close')
++ inflight.add(res)
++ res.on('close', () => inflight.delete(res))
++ })
++ function close(ms: number): Promise<number> {
++ closing = true
++ return new Promise((resolve) => {
++ let done = false
++ const finish = (cut: number): void => {
++ if (done) return
++ done = true
++ clearInterval(sweep)
++ clearTimeout(timer)
++ resolve(cut)
++ }
++ server.close(() => finish(0))
++ for (const res of inflight) if (!res.headersSent) res.setHeader('connection', 'close')
++ // A connection whose response just finished goes idle: close it now, not after keepAliveTimeout.
++ const idle = (): void => server.closeIdleConnections()
++ idle()
++ const sweep = setInterval(idle, 50)
++ const timer = setTimeout(() => {
++ const cut = inflight.size
++ server.closeAllConnections()
++ finish(cut)
++ }, ms)
++ })
++ }
++ return { close, stopping: () => closing }
++}
++
++/**
++ * Close every client of `wss` with 1012 (service restart) and `reason`; a client that has not
++ * finished the close handshake after `ms` is terminated. Resolves once every one has closed (its
++ * 'close' handlers have run).
++ */
++export function closeSockets(
++ wss: WebSocketServer,
++ ms: number,
++ reason = 'server_restart',
++): Promise<{ closed: number; terminated: number }> {
++ const clients = [...wss.clients]
++ if (clients.length === 0) return Promise.resolve({ closed: 0, terminated: 0 })
++ return new Promise((resolve) => {
++ let left = clients.length
++ let terminated = 0
++ let done = false
++ const finish = (): void => {
++ if (done) return
++ done = true
++ clearTimeout(cut)
++ clearTimeout(giveUp)
++ resolve({ closed: clients.length - terminated, terminated })
++ }
++ const cut = setTimeout(() => {
++ for (const ws of clients) {
++ if (ws.readyState !== ws.CLOSED) {
++ terminated++
++ ws.terminate()
++ }
++ }
++ }, ms)
++ // terminate() emits 'close' on the next turns; never wait on it forever.
++ const giveUp = setTimeout(finish, ms + 1000)
++ for (const ws of clients) {
++ ws.once('close', () => {
++ if (--left === 0) finish()
++ })
++ ws.close(CLOSE_RESTART, reason)
++ }
++ })
++}
++
++/** Await `p`, but no longer than `ms`. */
++export function within<T>(ms: number, p: Promise<T> | undefined | null): Promise<T | undefined> {
++ if (!p) return Promise.resolve(undefined)
++ return Promise.race([
++ p.catch(() => undefined),
++ new Promise<undefined>((r) => {
++ const t = setTimeout(() => r(undefined), ms)
++ t.unref()
++ }),
++ ])
++}
ddiff --git a/apps/server/src/platform/mediaMirror.ts b/apps/server/src/platform/mediaMirror.ts
iindex 7c17a19..a4b562c 100644
---- a/apps/server/src/platform/mediaMirror.ts
++++ b/apps/server/src/platform/mediaMirror.ts
@@@ -62,6 +62,7 @@ export interface MediaMirrorOptions {
export class MediaMirror {
private timer: NodeJS.Timeout | null = null
private running: Promise<void> | null = null
++ private stopped = false
private readonly now: () => number
constructor(private readonly opts: MediaMirrorOptions) {
@@@ -102,12 +103,16 @@ export class MediaMirror {
}
start(): void {
++ this.stopped = false
this.schedule(0)
}
-- stop(): void {
++ /** No new passes; resolves when the pass in progress (if any) has finished. */
++ stop(): Promise<void> {
if (this.timer) clearTimeout(this.timer)
this.timer = null
++ this.stopped = true
++ return this.running ?? Promise.resolve()
}
kick(): void {
@@@ -125,6 +130,7 @@ export class MediaMirror {
}
private schedule(ms: number): void {
++ if (this.stopped) return
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.timer = null
ddiff --git a/apps/server/src/platform/progressSummary.ts b/apps/server/src/platform/progressSummary.ts
iindex de7f37f..5e752ff 100644
---- a/apps/server/src/platform/progressSummary.ts
++++ b/apps/server/src/platform/progressSummary.ts
@@@ -55,6 +55,7 @@ export class ProgressSummaryWriter {
private readonly joined = new Map<string, number>()
private readonly modules: ReturnType<typeof createModulesClient>['forSubject']
private inflight = 0
++ private readonly writes = new Set<Promise<void>>()
constructor(
client: OpenVibeClient,
@@@ -85,7 +86,7 @@ export class ProgressSummaryWriter {
sessionSeconds: since === undefined ? 0 : (this.now() - since) / 1000,
}
this.inflight++
-- return this.modules
++ const write: Promise<void> = this.modules
.update(NAMESPACE, subjectId, (prev: ProgressSummary | undefined) =>
mergeSummary(prev, outcome),
)
@@@ -98,10 +99,18 @@ export class ProgressSummaryWriter {
})
.finally(() => {
this.inflight--
++ this.writes.delete(write)
})
++ this.writes.add(write)
++ return write
}
pending(): number {
return this.inflight
}
++
++ /** Graceful stop: resolves when every write in flight has finished (or given up). */
++ settle(): Promise<void> {
++ return Promise.all([...this.writes]).then(() => undefined)
++ }
}
ddiff --git a/apps/server/src/shutdown.test.ts b/apps/server/src/shutdown.test.ts
nnew file mode 100644
iindex 0000000..222e226
---- /dev/null
++++ b/apps/server/src/shutdown.test.ts
@@@ -0,0 +1,158 @@
++import { spawn, type ChildProcess } from 'node:child_process'
++import { mkdtempSync, rmSync } from 'node:fs'
++import { request, type IncomingHttpHeaders } from 'node:http'
++import { createServer, type AddressInfo } from 'node:net'
++import { tmpdir } from 'node:os'
++import { dirname, join } from 'node:path'
++import { fileURLToPath } from 'node:url'
++import { afterAll, describe, expect, it } from 'vitest'
++import WebSocket from 'ws'
++import { CLOSE_RESTART, DEADLINE_MS } from './net/gracefulStop.js'
++
++/**
++ * Graceful stop (roadmap WS-P lifecycle), on the real server process as systemd runs it: SIGTERM
++ * while a player socket, an editor socket and an HTTP request are open. The sockets are closed with
++ * 1012 (service restart), new connections are refused, the request in flight is answered, the event
++ * outbox is stopped, and the process exits 0 within the manifest's deadline.
++ */
++
++const SERVER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')
++const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
++
++function freePort(): Promise<number> {
++ return new Promise((resolve, reject) => {
++ const s = createServer()
++ s.on('error', reject)
++ s.listen(0, '127.0.0.1', () => {
++ const { port } = s.address() as AddressInfo
++ s.close(() => resolve(port))
++ })
++ })
++}
++
++async function refused(port: number): Promise<boolean> {
++ try {
++ await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(1000) })
++ return false
++ } catch {
++ return true
++ }
++}
++
++function openSocket(
++ url: string,
++): Promise<{ ws: WebSocket; closed: Promise<{ code: number; reason: string }> }> {
++ return new Promise((resolve, reject) => {
++ const ws = new WebSocket(url)
++ const closed = new Promise<{ code: number; reason: string }>((r) =>
++ ws.on('close', (code, reason) => r({ code, reason: reason.toString() })),
++ )
++ ws.on('open', () => resolve({ ws, closed }))
++ ws.on('error', reject)
++ })
++}
++
++const dir = mkdtempSync(join(tmpdir(), 'ov-games-stop-'))
++let child: ChildProcess | null = null
++
++afterAll(() => {
++ if (child && child.exitCode === null) child.kill('SIGKILL')
++ rmSync(dir, { recursive: true, force: true })
++})
++
++describe('SIGTERM', () => {
++ it('drains HTTP, closes sockets with 1012, stops the outbox and exits 0 in time', async () => {
++ const port = await freePort()
++ let out = ''
++ child = spawn(process.execPath, ['--import', 'tsx', 'src/main.ts'], {
++ cwd: SERVER_DIR,
++ env: {
++ ...process.env,
++ NODE_ENV: 'test',
++ PORT: String(port),
++ HOST: '127.0.0.1',
++ DB_PATH: join(dir, 'world.db'),
++ MAP_PATH: join(dir, 'map.json'),
++ STATIC_DIR: '',
++ GUEST_IP_BINDING: 'off',
++ // The platform side on, pointed at nothing: the outbox exists and has to be stopped.
++ OV_OAUTH_CLIENT_SECRET: 'test-secret-not-real',
++ OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:9',
++ EVENTS_URL: 'http://127.0.0.1:9',
++ MEDIA_URL: '',
++ GAMES_EVENTS_SECRET: 'x'.repeat(40),
++ GAMES_EVENTS_ENDPOINT: '',
++ },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ })
++ child.stdout?.on('data', (d: Buffer) => (out += d.toString()))
++ child.stderr?.on('data', (d: Buffer) => (out += d.toString()))
++ const exited = new Promise<number | null>((r) => child?.on('exit', (code) => r(code)))
++ for (let i = 0; !out.includes('"listening"'); i++) {
++ if (child.exitCode !== null || i > 300) throw new Error(`server did not start:\n${out}`)
++ await sleep(100)
++ }
++ expect(out).toContain('"events on"')
++
++ const player = await openSocket(`ws://127.0.0.1:${port}/ws`)
++ const editor = await openSocket(`ws://127.0.0.1:${port}/editor-ws`)
++
++ // A request in flight: POST /internal/events reads its whole body before it answers.
++ const body = Buffer.from(JSON.stringify({ probe: 'graceful-stop', pad: 'x'.repeat(64) }))
++ const req = request({
++ host: '127.0.0.1',
++ port,
++ path: '/internal/events',
++ method: 'POST',
++ agent: false,
++ headers: { 'content-type': 'application/json', 'content-length': body.length },
++ })
++ const answered = new Promise<{ status: number; headers: IncomingHttpHeaders; at: number }>(
++ (resolve, reject) => {
++ req.on('response', (res) => {
++ res.resume()
++ res.on('end', () =>
++ resolve({ status: res.statusCode ?? 0, headers: res.headers, at: Date.now() }),
++ )
++ })
++ req.on('error', reject)
++ },
++ )
++ req.write(body.subarray(0, 10))
++ await sleep(200)
++
++ const t0 = Date.now()
++ child.kill('SIGTERM')
++ const [p, e] = await Promise.all([player.closed, editor.closed])
++ expect(p).toEqual({ code: CLOSE_RESTART, reason: 'server_restart' })
++ expect(e).toEqual({ code: CLOSE_RESTART, reason: 'server_restart' })
++ await sleep(200)
++ expect(await refused(port)).toBe(true)
++
++ req.end(body.subarray(10))
++ const r = await answered
++ expect(r.at).toBeGreaterThan(t0)
++ expect(r.status).toBe(401) // unsigned: answered, not cut
++ expect(r.headers.connection).toBe('close')
++
++ const code = await exited
++ const ms = Date.now() - t0
++ expect(code, out.slice(-3000)).toBe(0)
++ expect(ms).toBeLessThan(DEADLINE_MS)
++ expect(out).toContain('world saved on shutdown')
++ const stopped = out
++ .split('\n')
++ .filter((l) => l.includes('"msg":"stopped"'))
++ .map((l) => JSON.parse(l) as Record<string, unknown>)[0]
++ expect(stopped).toMatchObject({
++ outbox: 'stopped',
++ players: 1,
++ editors: 1,
++ terminated: 0,
++ requestsCut: 0,
++ })
++ console.log(
++ `games: SIGTERM → exit 0 in ${ms} ms (${String(stopped?.ms)} ms inside the process)`,
++ )
++ }, 60_000)
++})