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
Changes
@@@ -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>;@@@ -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":"AAAA,OAAO,EAyBL,aAAa,EAkBd,MAAM,oBAAoB,CAAA;AAE3B,OAAO,KAAK,EAAE,gBAAgB,EAAa,MAAM,uBAAuB,CAAA;AAExE,OAAO,EAIL,KAAK,aAAa,EAGnB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAUL,KAAK,MAAM,EAEZ,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAGhD,OAAO,KAAK,EAAe,iBAAiB,EAAoB,MAAM,2BAA2B,CAAA;AACjG,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAA;AAC3E,OAAO,KAAK,EAAW,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAE7D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAChE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAa/C,OAAO,EAML,KAAK,aAAa,EACnB,MAAM,oBAAoB,CAAA;AAE3B,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD,qEAAqE;AACrE,MAAM,WAAW,cAAc;IAC7B,yEAAyE;IACzE,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1C;AAKD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,6DAA6D;IAC7D,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,mGAAmG;IACnG,eAAe,CAAC,EAAE,qBAAqB,CAAA;CACxC;AAcD,qBAAa,UAAU;IAgCnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,YAAY;IApC/B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqC;IAC9D,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA2C;IAC1E,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqC;IACtE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA8B;IAC3D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAoB;IAClD,2EAA2E;IAC3E,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAA0D;IAC9F,OAAO,CAAC,IAAI,CAAI;IAChB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkB;IAC9C,+EAA+E;IAC/E,OAAO,CAAC,SAAS,CAAoB;IACrC,OAAO,CAAC,aAAa,CAAI;IACzB,oEAAoE;IACpE,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAkB;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,gBAAwB;IACxC,uDAAuD;IACvD,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,uDAAuD;IACvD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,sEAAsE;IACtE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAClD,mEAAmE;IACnE,OAAO,CAAC,MAAM,CAAiC;IAE/C,+FAA+F;IAC/F,WAAW,IAAI,MAAM;gBAKF,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,SAAS,EAChB,KAAK,EAAE,gBAAgB,EACvB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,YAAY,GAAE,gBAAqB;IA8FtD,IAAI,WAAW,IAAI,MAAM,CAExB;IAED;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAiBrB,SAAS,CAAC,IAAI,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,IAAI;IA6dzD,yEAAyE;IACzE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAgE;IAE5F,6DAA6D;IAC7D,OAAO,CAAC,OAAO;IAgCf,OAAO,CAAC,cAAc;IAMtB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAqEpB,iDAAiD;IACjD,OAAO,CAAC,SAAS;IA+DjB,OAAO,CAAC,QAAQ;IAgChB,OAAO,CAAC,cAAc;IAYtB,uEAAuE;IACvE,OAAO,CAAC,qBAAqB;IAe7B,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,QAAQ;IAIhB;;;;;OAKG;IACH,OAAO,CAAC,cAAc;IA6JtB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI;YAkC1B,WAAW;IAoMzB,OAAO,CAAC,aAAa;IAoFrB,OAAO,CAAC,WAAW;IAmBnB,OAAO,CAAC,WAAW;IAUnB,2EAA2E;IAC3E,OAAO,CAAC,WAAW;IAmBnB,OAAO,CAAC,WAAW;IAyBnB,2EAA2E;IAC3E,OAAO,CAAC,WAAW;IAiDnB;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IA6FlB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAyCpB;;;;OAIG;IACH,OAAO,CAAC,gBAAgB;IAmCxB,mEAAmE;IACnE,OAAO,CAAC,eAAe;IAyBvB,qEAAqE;IACrE,OAAO,CAAC,cAAc;IAmEtB;+DAC2D;IAC3D,OAAO,CAAC,eAAe;IAmBvB,wEAAwE;IACxE,OAAO,CAAC,WAAW;IAiCnB;;;OAGG;IACH,OAAO,CAAC,aAAa;IAyBrB,yEAAyE;IACzE,OAAO,CAAC,gBAAgB;IAgDxB,OAAO,CAAC,QAAQ;IAehB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IA8FpB,mEAAmE;IACnE,OAAO,CAAC,OAAO;IAyCf,OAAO,CAAC,SAAS;IAajB,IAAI,IAAI,IAAI;IA6KZ,OAAO,CAAC,mBAAmB;IA+E3B,OAAO,CAAC,SAAS;IAyCjB;;;OAGG;IACH,KAAK,CAAC,MAAM,GAAE,YAAY,GAAG,UAAyB,GAAG,IAAI;IAiC7D,6BAA6B;IAC7B,QAAQ,IAAI,IAAI;IAMhB,6FAA6F;IAC7F,OAAO,CAAC,UAAU;IAclB;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAC,OAAO,CAqBvB;IAED,OAAO,CAAC,WAAW;IAyBnB,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,cAAc;IAOtB,OAAO,CAAC,YAAY;IAKpB,OAAO,CAAC,cAAc;IAkBtB,0EAA0E;IAC1E,OAAO,CAAC,wBAAwB;IAUhC,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,kBAAkB;IAO1B,sEAAsE;IACtE;;;;OAIG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM;IAiB9D;;;;OAIG;IACH,eAAe,IAAI,IAAI;IAOvB,kBAAkB,IAAI,IAAI;IAK1B,yFAAyF;IACzF,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG,WAAW,CAAC,MAAM,CAAC;CAG9D"}\\ No newline at end of file@@@ -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;@@@ -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 filennew file mode 100644@@@ -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 filennew file mode 100644@@@ -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@@@ -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>;@@@ -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@@@ -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@@@ -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@@@ -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…' })@@@ -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' }))++ : nullconst 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) => {@@@ -44,6 +44,8 @@ const PEER_COLORS = ['#ff9d4d', '#4dc3ff', '#7dff6e', '#ff6ec7', '#ffe14d', '#b3export interface EditorHub {wss: WebSocketServer++ /** Graceful stop: the lease sweeper stops. */++ stop(): voidbroadcastSaved(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): Edireturn {wss,++ /** Graceful stop: the lease sweeper stops (main.ts closes the sockets). */++ stop(): void {++ clearInterval(sweeper)++ },broadcastSaved(revision: string): void {broadcast({ t: 'mapSaved', revision })},nnew file mode 100644@@@ -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()++ }),++ ])++}@@@ -62,6 +62,7 @@ export interface MediaMirrorOptions {export class MediaMirror {private timer: NodeJS.Timeout | null = nullprivate running: Promise<void> | null = null++ private stopped = falseprivate readonly now: () => numberconstructor(private readonly opts: MediaMirrorOptions) {@@@ -102,12 +103,16 @@ export class MediaMirror {}start(): void {++ this.stopped = falsethis.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) returnif (this.timer) clearTimeout(this.timer)this.timer = setTimeout(() => {this.timer = null@@@ -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)++ }}nnew file mode 100644@@@ -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)++})
@@ -189,6 +189,12 @@ export declare class GameServer {
@@ -189,6 +189,12 @@ export declare class GameServer {
* then fails its /api/auth/me check. Returns how many closed.
* then fails its /api/auth/me check. Returns how many closed.
*/
*/
revokeSubject(subjectId: string, validAfterMs: number): number;
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;
broadcastMapReload(): void;
/** Exposes crafting context for the client-facing recipe availability (welcome-time). */
/** Exposes crafting context for the client-facing recipe availability (welcome-time). */
workstationsNear(session: PlayerSession): ReadonlySet<string>;
workstationsNear(session: PlayerSession): ReadonlySet<string>;
@@ -1 +1 @@
@@ -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
\ 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
\ No newline at end of file
@@ -4,6 +4,8 @@ import type { Logger } from '@openvibe/shared';
@@ -4,6 +4,8 @@ import type { Logger } from '@openvibe/shared';
import type { EditorAuth } from './httpServer.js';
import type { EditorAuth } from './httpServer.js';
export interface EditorHub {
export interface EditorHub {
wss: WebSocketServer;
wss: WebSocketServer;
+ /** Graceful stop: the lease sweeper stops. */
+ stop(): void;
broadcastSaved(revision: string): void;
broadcastSaved(revision: string): void;
/** Live peer count, for tests and diagnostics. */
/** Live peer count, for tests and diagnostics. */
peerCount(): number;
peerCount(): number;
@@ -1 +1 @@
@@ -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
\ 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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -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
\ No newline at end of file
@@ -14,6 +14,7 @@ export declare class MediaMirror {
@@ -14,6 +14,7 @@ export declare class MediaMirror {
private readonly opts;
private readonly opts;
private timer;
private timer;
private running;
private running;
+ private stopped;
private readonly now;
private readonly now;
constructor(opts: MediaMirrorOptions);
constructor(opts: MediaMirrorOptions);
/** Queues one stored asset (idempotent) and wakes the worker. */
/** Queues one stored asset (idempotent) and wakes the worker. */
@@ -26,7 +27,8 @@ export declare class MediaMirror {
@@ -26,7 +27,8 @@ export declare class MediaMirror {
/** Queues every content-addressed asset already on disk (a no-op for known ones). */
/** Queues every content-addressed asset already on disk (a no-op for known ones). */
backfill(): Promise<number>;
backfill(): Promise<number>;
start(): void;
start(): void;
- stop(): void;
+ /** No new passes; resolves when the pass in progress (if any) has finished. */
+ stop(): Promise<void>;
kick(): void;
kick(): void;
/** Mirrors every due row once. Concurrent callers share the pass. */
/** Mirrors every due row once. Concurrent callers share the pass. */
runOnce(): Promise<void>;
runOnce(): Promise<void>;
@@ -1 +1 @@
@@ -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
\ 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
\ No newline at end of file
@@ -22,12 +22,15 @@ export declare class ProgressSummaryWriter {
@@ -22,12 +22,15 @@ export declare class ProgressSummaryWriter {
private readonly joined;
private readonly joined;
private readonly modules;
private readonly modules;
private inflight;
private inflight;
+ private readonly writes;
constructor(client: OpenVibeClient, log: Log, now?: () => number);
constructor(client: OpenVibeClient, log: Log, now?: () => number);
/** A character entered the world (its session starts counting). */
/** A character entered the world (its session starts counting). */
playerJoined(playerId: string): void;
playerJoined(playerId: string): void;
/** A character left: fold the session into the person's summary. Resolves when written (or given up). */
/** 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>;
playerLeft(playerId: string, subjectId: string | null, levels: Record<string, number>, world: string): Promise<void>;
pending(): number;
pending(): number;
+ /** Graceful stop: resolves when every write in flight has finished (or given up). */
+ settle(): Promise<void>;
}
}
export {};
export {};
//# sourceMappingURL=progressSummary.d.ts.map
//# sourceMappingURL=progressSummary.d.ts.map
\ No newline at end of file
\ No newline at end of file
@@ -1 +1 @@
@@ -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
\ 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
\ No newline at end of file
@@ -2733,6 +2733,18 @@ export class GameServer {
@@ -2733,6 +2733,18 @@ export class GameServer {
return closed
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 {
broadcastMapReload(): void {
this.broadcastAll({ t: 'map_reload' })
this.broadcastAll({ t: 'map_reload' })
this.broadcastAll({ t: 'announce', text: '🗺 The world was reshaped by the map editors…' })
this.broadcastAll({ t: 'announce', text: '🗺 The world was reshaped by the map editors…' })
@@ -15,6 +15,14 @@ import { GameServer } from './game/gameServer.js'
@@ -15,6 +15,14 @@ import { GameServer } from './game/gameServer.js'
import { GameWorld } from './game/gameWorld.js'
import { GameWorld } from './game/gameWorld.js'
import { loadHavok } from './havokLoader.js'
import { loadHavok } from './havokLoader.js'
import { attachEditorWs } from './net/editorWs.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 { createHttpServer } from './net/httpServer.js'
import { resolveNetworkUser } from './net/networkAuth.js'
import { resolveNetworkUser } from './net/networkAuth.js'
import { attachWebSocket } from './net/wsTransport.js'
import { attachWebSocket } from './net/wsTransport.js'
@@ -154,18 +162,14 @@ async function main(): Promise<void> {
@@ -154,18 +162,14 @@ async function main(): Promise<void> {
const pruneTimer = setInterval(() => outbox?.prune(), 6 * 3600 * 1000)
const pruneTimer = setInterval(() => outbox?.prune(), 6 * 3600 * 1000)
pruneTimer.unref()
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' }), {
const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }), {
...(recorder ? { events: recorder } : {}),
...(recorder ? { events: recorder } : {}),
mods: modRuntime,
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
// Sign-out everywhere (network.user.token_valid_after): POST /internal/events closes the person's
@@ -299,6 +303,7 @@ async function main(): Promise<void> {
@@ -299,6 +303,7 @@ async function main(): Promise<void> {
onAssetStored: (asset) => mirror?.enqueue(asset),
onAssetStored: (asset) => mirror?.enqueue(asset),
},
},
)
)
+ const drainer = httpDrainer(http)
world.reconcileMapNodes()
world.reconcileMapNodes()
world.reconcileMapProps()
world.reconcileMapProps()
const gameWss = attachWebSocket(http, game, log.child({ system: 'ws' }))
const gameWss = attachWebSocket(http, game, log.child({ system: 'ws' }))
@@ -309,7 +314,10 @@ async function main(): Promise<void> {
@@ -309,7 +314,10 @@ async function main(): Promise<void> {
)
)
http.on('upgrade', (req, socket, head) => {
http.on('upgrade', (req, socket, head) => {
const path = (req.url ?? '').split('?')[0]
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))
gameWss.handleUpgrade(req, socket, head, (ws) => gameWss.emit('connection', ws, req))
} else if (path === '/editor-ws') {
} else if (path === '/editor-ws') {
editors.wss.handleUpgrade(req, socket, head, (ws) => editors.wss.emit('connection', ws, req))
editors.wss.handleUpgrade(req, socket, head, (ws) => editors.wss.emit('connection', ws, req))
@@ -341,21 +349,60 @@ async function main(): Promise<void> {
@@ -341,21 +349,60 @@ async function main(): Promise<void> {
const metricsTimer = setInterval(() => metrics.logSummary(log), config.metricsLogSeconds * 1000)
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) => {
main().catch((err: unknown) => {
@@ -44,6 +44,8 @@ const PEER_COLORS = ['#ff9d4d', '#4dc3ff', '#7dff6e', '#ff6ec7', '#ffe14d', '#b3
@@ -44,6 +44,8 @@ const PEER_COLORS = ['#ff9d4d', '#4dc3ff', '#7dff6e', '#ff6ec7', '#ffe14d', '#b3
export interface EditorHub {
export interface EditorHub {
wss: WebSocketServer
wss: WebSocketServer
+ /** Graceful stop: the lease sweeper stops. */
+ stop(): void
broadcastSaved(revision: string): void
broadcastSaved(revision: string): void
/** Live peer count, for tests and diagnostics. */
/** Live peer count, for tests and diagnostics. */
peerCount(): number
peerCount(): number
@@ -233,6 +235,10 @@ export function attachEditorWs(http: Server, auth: EditorAuth, log: Logger): Edi
@@ -233,6 +235,10 @@ export function attachEditorWs(http: Server, auth: EditorAuth, log: Logger): Edi
return {
return {
wss,
wss,
+ /** Graceful stop: the lease sweeper stops (main.ts closes the sockets). */
+ stop(): void {
+ clearInterval(sweeper)
+ },
broadcastSaved(revision: string): void {
broadcastSaved(revision: string): void {
broadcast({ t: 'mapSaved', revision })
broadcast({ t: 'mapSaved', revision })
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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()
+ }),
+ ])
+}
@@ -62,6 +62,7 @@ export interface MediaMirrorOptions {
@@ -62,6 +62,7 @@ export interface MediaMirrorOptions {
export class MediaMirror {
export class MediaMirror {
private timer: NodeJS.Timeout | null = null
private timer: NodeJS.Timeout | null = null
private running: Promise<void> | null = null
private running: Promise<void> | null = null
+ private stopped = false
private readonly now: () => number
private readonly now: () => number
constructor(private readonly opts: MediaMirrorOptions) {
constructor(private readonly opts: MediaMirrorOptions) {
@@ -102,12 +103,16 @@ export class MediaMirror {
@@ -102,12 +103,16 @@ export class MediaMirror {
}
}
start(): void {
start(): void {
+ this.stopped = false
this.schedule(0)
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)
if (this.timer) clearTimeout(this.timer)
this.timer = null
this.timer = null
+ this.stopped = true
+ return this.running ?? Promise.resolve()
}
}
kick(): void {
kick(): void {
@@ -125,6 +130,7 @@ export class MediaMirror {
@@ -125,6 +130,7 @@ export class MediaMirror {
}
}
private schedule(ms: number): void {
private schedule(ms: number): void {
+ if (this.stopped) return
if (this.timer) clearTimeout(this.timer)
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.timer = setTimeout(() => {
this.timer = null
this.timer = null
@@ -55,6 +55,7 @@ export class ProgressSummaryWriter {
@@ -55,6 +55,7 @@ export class ProgressSummaryWriter {
private readonly joined = new Map<string, number>()
private readonly joined = new Map<string, number>()
private readonly modules: ReturnType<typeof createModulesClient>['forSubject']
private readonly modules: ReturnType<typeof createModulesClient>['forSubject']
private inflight = 0
private inflight = 0
+ private readonly writes = new Set<Promise<void>>()
constructor(
constructor(
client: OpenVibeClient,
client: OpenVibeClient,
@@ -85,7 +86,7 @@ export class ProgressSummaryWriter {
@@ -85,7 +86,7 @@ export class ProgressSummaryWriter {
sessionSeconds: since === undefined ? 0 : (this.now() - since) / 1000,
sessionSeconds: since === undefined ? 0 : (this.now() - since) / 1000,
}
}
this.inflight++
this.inflight++
- return this.modules
+ const write: Promise<void> = this.modules
.update(NAMESPACE, subjectId, (prev: ProgressSummary | undefined) =>
.update(NAMESPACE, subjectId, (prev: ProgressSummary | undefined) =>
mergeSummary(prev, outcome),
mergeSummary(prev, outcome),
)
)
@@ -98,10 +99,18 @@ export class ProgressSummaryWriter {
@@ -98,10 +99,18 @@ export class ProgressSummaryWriter {
})
})
.finally(() => {
.finally(() => {
this.inflight--
this.inflight--
+ this.writes.delete(write)
})
})
+ this.writes.add(write)
+ return write
}
}
pending(): number {
pending(): number {
return this.inflight
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)
+ }
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -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)
+})