akanjs 2.4.0-rc.0 → 2.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.4.0-rc.0",
3
+ "version": "2.4.0-rc.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -0,0 +1,4 @@
1
+ [AkanApp] 39364 - 07/21/2026, 04:19:57 AM INFO AkanApp gateway is running on port http://localhost:32283 +499ms
2
+ [AkanApp] 39364 - 07/21/2026, 04:19:57 AM VERBOSE All 1 child process(es) are ready +512ms
3
+ [AkanApp] 39364 - 07/21/2026, 04:19:57 AM ERROR Child 0/federation failed (exit:1); restarting in 1000ms (attempt 1) +565ms
4
+ [AkanApp] 39364 - 07/21/2026, 04:19:58 AM VERBOSE All 1 child process(es) are ready +1582ms
@@ -0,0 +1,5 @@
1
+ [AkanApp] 39364 - 07/21/2026, 04:19:58 AM INFO AkanApp gateway is running on port http://localhost:30883 +1587ms
2
+ [AkanApp] 39364 - 07/21/2026, 04:19:58 AM VERBOSE All 1 child process(es) are ready +1599ms
3
+ [AkanApp] 39364 - 07/21/2026, 04:19:58 AM ERROR Child 0/federation upstream is unreachable (/Users/kangminseon/github3/leading-flight-guidance/pkgs/akanjs/runtime/akan-child-39364-mru5abmz-0.sock); restarting +1740ms
4
+ [AkanApp] 39364 - 07/21/2026, 04:19:58 AM ERROR Child 0/federation failed (upstream-open-failed); restarting in 1000ms (attempt 1) +1740ms
5
+ [AkanApp] 39364 - 07/21/2026, 04:19:59 AM VERBOSE All 1 child process(es) are ready +2760ms
@@ -0,0 +1,7 @@
1
+ [AkanApp] 39364 - 07/21/2026, 04:19:59 AM INFO AkanApp gateway is running on port http://localhost:24583 +2773ms
2
+ [AkanApp] 39364 - 07/21/2026, 04:19:59 AM ERROR intentional-boot-failure +2783ms
3
+ [AkanApp] 39364 - 07/21/2026, 04:19:59 AM ERROR Child 0/federation failed (child-error); restarting in 1000ms (attempt 1) +2783ms
4
+ [AkanApp] 39364 - 07/21/2026, 04:20:00 AM ERROR intentional-boot-failure +3799ms
5
+ [AkanApp] 39364 - 07/21/2026, 04:20:00 AM ERROR Child 0/federation failed (child-error); restarting in 2000ms (attempt 2) +3799ms
6
+ [AkanApp] 39364 - 07/21/2026, 04:20:02 AM ERROR intentional-boot-failure +5817ms
7
+ [AkanApp] 39364 - 07/21/2026, 04:20:02 AM ERROR [child-crash-loop] Backend replica 0/federation failed 3 consecutive boots; waiting for a code change to retry: intentional-boot-failure +5817ms
@@ -0,0 +1,2 @@
1
+ [AkanApp] 39364 - 07/21/2026, 04:20:03 AM INFO AkanApp gateway is running on port http://localhost:24132 +7360ms
2
+ [AkanApp] 39364 - 07/21/2026, 04:20:03 AM VERBOSE All 1 child process(es) are ready +7373ms
package/server/akanApp.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { mkdir, rm } from "node:fs/promises";
1
+ import { mkdir, readdir, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { Logger } from "akanjs/common";
4
4
  import type { AkanChildRole, AkanChildStatus, AkanIpcMessage, AkanMetricsReport, AkanUpstream } from "akanjs/service";
5
5
  import { isTraceEnabled } from "akanjs/signal";
6
6
  import { makeAkanChildProxyHeaders } from "./akanAppHeaders";
7
7
  import type { BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
8
+ import { isPortInUseError } from "./lifecycle/portInUse";
8
9
  import { RotatingLogWriter } from "./logging/rotatingLogWriter";
9
10
  import { ProcessMetricsCollector } from "./processMetricsCollector";
10
11
 
@@ -16,9 +17,11 @@ interface ChildState {
16
17
  status: AkanChildStatus;
17
18
  pid?: number;
18
19
  upstream?: AkanUpstream;
20
+ wsUpstream?: Extract<AkanUpstream, { type: "tcp" }>;
19
21
  healthPath?: string;
20
22
  metrics: AkanMetricsReport;
21
- lastPongAt?: number;
23
+ /** Monotonic (`performance.now()`) so sleep/wake wall-clock jumps cannot fake a health timeout. */
24
+ lastPongAtMono?: number;
22
25
  restartAttempts: number;
23
26
  restartCount: number;
24
27
  restartTimer: Timer | null;
@@ -26,6 +29,7 @@ interface ChildState {
26
29
  lastExitCode?: number | null;
27
30
  lastRestartAt?: number;
28
31
  lastRestartReason?: string;
32
+ lastErrorMessage?: string;
29
33
  }
30
34
 
31
35
  interface GatewayWsData {
@@ -62,8 +66,13 @@ export class AkanApp {
62
66
  static readonly #childRestartBaseDelayMs = 1_000;
63
67
  static readonly #childRestartMaxDelayMs = 30_000;
64
68
  static readonly #childRestartGraceMs = 5_000;
69
+ /** In dev, stop restarting a replica that never boots after this many consecutive failures. */
70
+ static readonly #devMaxChildBootFailures = 3;
65
71
 
66
72
  readonly logger = new Logger("AkanApp");
73
+ /** Hosted by `akan start`: crash loops should yield to the dev host, which restarts on file edits. */
74
+ readonly #devHosted = process.env.AKAN_COMMAND_TYPE === "start";
75
+ readonly #healthTimeoutMs = AkanApp.#parseHealthTimeoutMs();
67
76
  readonly #serverPath: string;
68
77
  readonly #artifactDir: string;
69
78
  readonly #replica: AkanReplicaConfig;
@@ -149,15 +158,46 @@ export class AkanApp {
149
158
  return Bun.main.endsWith(".js") ? "production" : "development";
150
159
  }
151
160
 
161
+ /**
162
+ * Dev builds and first-touch transpiles can stall a child's event loop well past the production
163
+ * pong budget, so `akan start` runs with a wider timeout to avoid restarting healthy replicas.
164
+ */
165
+ static #parseHealthTimeoutMs() {
166
+ const configured = Number(process.env.AKAN_HEALTH_TIMEOUT_MS);
167
+ if (Number.isFinite(configured) && configured > 0) return configured;
168
+ return process.env.AKAN_COMMAND_TYPE === "start" ? 15_000 : 5_000;
169
+ }
170
+
171
+ /**
172
+ * Must exceed the child's own shutdown timeout (see `AkanServer.#defaultShutdownTimeoutMs`) so
173
+ * children always get to exit on their own before this gateway stops waiting.
174
+ */
175
+ static #childShutdownWaitMs() {
176
+ const configured = Number(process.env.AKAN_CHILD_SHUTDOWN_WAIT_MS);
177
+ if (Number.isFinite(configured) && configured > 0) return configured;
178
+ return process.env.AKAN_COMMAND_TYPE === "start" ? 5_000 : 30_000;
179
+ }
180
+
152
181
  async start() {
153
182
  await this.#prepareRuntimeDir();
154
183
  this.#startFileLogging();
155
184
  for (let idx = 0; idx < this.#replica.total; idx++) this.#spawn(idx);
156
- this.#listen();
185
+ try {
186
+ this.#listen();
187
+ } catch (error) {
188
+ if (isPortInUseError(error)) {
189
+ const message = `Port ${this.#port} is already in use — another \`akan start\` or an orphaned gateway may still be running (try: lsof -ti :${this.#port}).`;
190
+ this.logger.error(message);
191
+ this.#reportBackendBuildStatus({ ok: false, message });
192
+ }
193
+ await this.stop("listen-failed");
194
+ throw error;
195
+ }
157
196
  this.#snapshotTimer = setInterval(() => this.#requestRoomSnapshots(), 30_000);
158
197
  this.#healthTimer = setInterval(() => this.#checkHealth(), 2_000);
159
198
  this.#startMetricsReporting();
160
199
  process.on("message", (message) => this.#handleHostMessage(message as BuilderMessage));
200
+ process.on("disconnect", () => this.#handleHostDisconnect());
161
201
  process.on("SIGINT", () => this.#handleShutdownSignal("SIGINT"));
162
202
  process.on("SIGTERM", () => this.#handleShutdownSignal("SIGTERM"));
163
203
  await new Promise<void>((resolve) => {
@@ -192,7 +232,7 @@ export class AkanApp {
192
232
  }
193
233
  await Promise.race([
194
234
  Promise.all([...this.#children.values()].map((child) => child.proc.exited.catch(() => undefined))),
195
- new Promise((resolve) => setTimeout(resolve, 30_000)),
235
+ new Promise((resolve) => setTimeout(resolve, AkanApp.#childShutdownWaitMs())),
196
236
  ]);
197
237
  for (const child of this.#children.values()) {
198
238
  if (!child.proc.killed) child.proc.kill();
@@ -213,6 +253,18 @@ export class AkanApp {
213
253
  });
214
254
  }
215
255
 
256
+ /**
257
+ * The IPC channel closes when the dev host dies (including SIGKILL). Exiting takes the children
258
+ * down too, so a dead host never strands a gateway tree that would block the next `akan start`.
259
+ */
260
+ #handleHostDisconnect() {
261
+ if (this.#stopping) return;
262
+ this.logger.warn("Host IPC channel closed; shutting down gateway and children");
263
+ this.#exitAfterStop = true;
264
+ setTimeout(() => process.exit(1), AkanApp.#childShutdownWaitMs() + 5_000);
265
+ void this.stop("ipc-disconnect").catch(() => process.exit(1));
266
+ }
267
+
216
268
  #spawn(idx: number) {
217
269
  const role = this.#getRole(idx);
218
270
  const upstream = this.#getChildUpstream(idx, role);
@@ -261,6 +313,7 @@ export class AkanApp {
261
313
  #handleChildExit(idx: number, proc: Bun.Subprocess<"ignore", "pipe", "pipe">, code: number | null) {
262
314
  const child = this.#children.get(idx);
263
315
  if (!child || child.proc !== proc) return;
316
+ if (child.status === "crashed") return;
264
317
  child.status = "exited";
265
318
  child.lastExitCode = code;
266
319
  this.#invalidateFederationChildCache();
@@ -276,11 +329,16 @@ export class AkanApp {
276
329
  child.lastRestartReason = reason;
277
330
  return;
278
331
  }
332
+ if (this.#devHosted && child.restartAttempts >= AkanApp.#devMaxChildBootFailures - 1 && !child.ready) {
333
+ this.#markChildCrashed(child, reason);
334
+ return;
335
+ }
279
336
 
280
337
  child.restartPending = true;
281
338
  child.ready = false;
282
339
  child.status = reason === "health-timeout" || reason === "upstream-open-failed" ? "unhealthy" : "exited";
283
340
  child.upstream = undefined;
341
+ child.wsUpstream = undefined;
284
342
  child.healthPath = undefined;
285
343
  this.#invalidateFederationChildCache();
286
344
  child.lastRestartReason = reason;
@@ -341,6 +399,41 @@ export class AkanApp {
341
399
  this.#spawn(idx);
342
400
  }
343
401
 
402
+ /**
403
+ * Dev-only terminal state: the same broken code fails every boot, so retrying is pure churn.
404
+ * The dev host replaces this whole gateway on the next server-side edit, which clears the state.
405
+ */
406
+ #markChildCrashed(child: ChildState, reason: string) {
407
+
408
+ if (child.status === "crashed") return;
409
+ child.ready = false;
410
+ child.status = "crashed";
411
+ child.restartPending = false;
412
+ child.upstream = undefined;
413
+ child.wsUpstream = undefined;
414
+ child.healthPath = undefined;
415
+ child.lastRestartReason = reason;
416
+ this.#invalidateFederationChildCache();
417
+ this.#removeChildRooms(child.idx);
418
+ const attempts = child.restartAttempts + 1;
419
+ const detail = child.lastErrorMessage ?? reason;
420
+ const message = `Backend replica ${child.idx}/${child.role} failed ${attempts} consecutive boots; waiting for a code change to retry: ${detail}`;
421
+ this.logger.error(`[child-crash-loop] ${message}`);
422
+ this.#reportBackendBuildStatus({ ok: false, message });
423
+ }
424
+
425
+ /** Forwards a backend-phase build status to the dev host so failures reach the HMR overlay. */
426
+ #reportBackendBuildStatus({ ok, message }: { ok: boolean; message: string }) {
427
+ process.send?.({
428
+ type: "build-status",
429
+ data: { generation: -1, phase: "backend", ok, files: [], message },
430
+ } satisfies BuilderMessage);
431
+ }
432
+
433
+ #isChildUnavailable(child: ChildState): boolean {
434
+ return child.proc.killed || child.status === "exited" || child.status === "crashed";
435
+ }
436
+
344
437
  async #stopChildForRestart(child: ChildState, proc: Bun.Subprocess<"ignore", "pipe", "pipe">, reason: string) {
345
438
  if (reason.startsWith("exit:") || proc.killed) return;
346
439
  if (!proc.killed) {
@@ -359,9 +452,13 @@ export class AkanApp {
359
452
 
360
453
  async #prepareRuntimeDir() {
361
454
  await mkdir(this.#runtimeDir, { recursive: true });
362
- for (let idx = 0; idx < this.#replica.total; idx++) {
363
- await this.#removeChildSocket(idx, this.#getRole(idx));
364
- }
455
+
456
+ const entries = await readdir(this.#runtimeDir).catch(() => []);
457
+ await Promise.all(
458
+ entries
459
+ .filter((name) => /^akan-child-.*\.sock$/.test(name))
460
+ .map((name) => rm(path.join(this.#runtimeDir, name), { force: true }).catch(() => undefined)),
461
+ );
365
462
  }
366
463
 
367
464
  async #removeChildSocket(idx: number, role: AkanChildRole) {
@@ -455,11 +552,9 @@ export class AkanApp {
455
552
 
456
553
  #upgradeWebSocket(req: Request, server: Bun.Server<GatewayWsData>): Response | undefined {
457
554
  const child = this.#pickFederationChild();
458
- if (!child?.upstream || !child.upstream || !this.#getChildUpstream(child.idx, child.role).ws) {
459
- return new Response("No websocket upstream is ready", { status: 503 });
460
- }
461
- const upstream = this.#getChildUpstream(child.idx, child.role).ws;
462
- if (!upstream) return new Response("No websocket upstream is ready", { status: 503 });
555
+
556
+ const upstream = child?.upstream ? (child.wsUpstream ?? this.#getChildUpstream(child.idx, child.role).ws) : null;
557
+ if (!child || !upstream) return new Response("No websocket upstream is ready", { status: 503 });
463
558
  const url = new URL(req.url);
464
559
  const upstreamWs = new WebSocket(`ws://${upstream.host}:${upstream.port}${url.pathname}${url.search}`, {
465
560
  headers: this.#makeProxyHeaders(req, child.idx),
@@ -513,6 +608,7 @@ export class AkanApp {
513
608
  #getHealthStatus() {
514
609
  return {
515
610
  status: this.#stopping ? "stopping" : "running",
611
+ pid: process.pid,
516
612
  children: [...this.#children.values()].map((child) => ({
517
613
  idx: child.idx,
518
614
  role: child.role,
@@ -526,6 +622,7 @@ export class AkanApp {
526
622
  lastExitCode: child.lastExitCode,
527
623
  lastRestartAt: child.lastRestartAt,
528
624
  lastRestartReason: child.lastRestartReason,
625
+ lastErrorMessage: child.lastErrorMessage,
529
626
  })),
530
627
  };
531
628
  }
@@ -709,8 +806,7 @@ export class AkanApp {
709
806
  (child.role === "federation" || child.role === "all") &&
710
807
  child.ready &&
711
808
  child.status !== "unhealthy" &&
712
- child.status !== "exited" &&
713
- !child.proc.killed,
809
+ !this.#isChildUnavailable(child),
714
810
  );
715
811
  this.#federationChildCache = candidates;
716
812
  if (candidates.length === 0) return null;
@@ -767,6 +863,7 @@ export class AkanApp {
767
863
  return;
768
864
  case "error":
769
865
  this.logger.error(message.message);
866
+ if (child) child.lastErrorMessage = message.message;
770
867
  if (child && proc) void this.#scheduleChildRestart(child, proc, "child-error");
771
868
  return;
772
869
  case "build-route":
@@ -798,13 +895,19 @@ export class AkanApp {
798
895
  child.status = "ready";
799
896
  child.pid = message.pid;
800
897
  child.upstream = message.upstream;
898
+ child.wsUpstream = message.wsUpstream;
801
899
  child.healthPath = message.healthPath;
802
- child.lastPongAt = Date.now();
900
+ child.lastPongAtMono = performance.now();
803
901
  child.restartAttempts = 0;
804
902
  child.restartPending = false;
903
+ child.lastErrorMessage = undefined;
805
904
  this.#invalidateFederationChildCache();
806
- if ([...this.#children.values()].every((item) => item.ready)) {
905
+
906
+ const trafficChildren = [...this.#children.values()].filter((item) => item.role !== "batch");
907
+ if (child.role !== "batch" && trafficChildren.every((item) => item.ready)) {
807
908
  process.send?.({ type: "backend-ready", pid: process.pid } satisfies AkanIpcMessage);
909
+ }
910
+ if ([...this.#children.values()].every((item) => item.ready)) {
808
911
  this.logger.verbose(`All ${this.#children.size} child process(es) are ready`);
809
912
  }
810
913
  }
@@ -813,7 +916,7 @@ export class AkanApp {
813
916
  const child = this.#children.get(idx);
814
917
  if (!child) return;
815
918
  child.status = "healthy";
816
- child.lastPongAt = Date.now();
919
+ child.lastPongAtMono = performance.now();
817
920
  this.#invalidateFederationChildCache();
818
921
  }
819
922
 
@@ -828,7 +931,7 @@ export class AkanApp {
828
931
  for (const childIdx of targets) {
829
932
  if (childIdx === originIdx) continue;
830
933
  const child = this.#children.get(childIdx);
831
- if (!child || child.proc.killed || child.status === "exited") continue;
934
+ if (!child || this.#isChildUnavailable(child)) continue;
832
935
  if (
833
936
  !this.#sendToChild(child, {
834
937
  type: "pubsub.deliver",
@@ -935,31 +1038,30 @@ export class AkanApp {
935
1038
 
936
1039
  #requestRoomSnapshots() {
937
1040
  for (const child of this.#children.values()) {
938
- if (child.proc.killed || child.status === "exited") continue;
1041
+ if (this.#isChildUnavailable(child)) continue;
939
1042
  this.#sendToChild(child, { type: "pubsub.snapshot.request" } satisfies AkanIpcMessage);
940
1043
  }
941
1044
  }
942
1045
 
943
1046
  #checkHealth() {
944
- const now = Date.now();
1047
+ const nowMono = performance.now();
945
1048
  for (const child of this.#children.values()) {
946
- if (child.proc.killed || child.status === "exited") continue;
947
- if (child.lastPongAt && now - child.lastPongAt > 5_000) {
1049
+ if (this.#isChildUnavailable(child)) continue;
1050
+ if (child.lastPongAtMono && nowMono - child.lastPongAtMono > this.#healthTimeoutMs) {
948
1051
  child.status = "unhealthy";
949
1052
  this.#invalidateFederationChildCache();
950
1053
  void this.#scheduleChildRestart(child, child.proc, "health-timeout");
951
- return;
1054
+ continue;
952
1055
  }
953
1056
  const sent = this.#sendToChild(child, {
954
1057
  type: "health.ping",
955
1058
  nonce: crypto.randomUUID(),
956
- sentAt: now,
1059
+ sentAt: Date.now(),
957
1060
  } satisfies AkanIpcMessage);
958
1061
  if (!sent) {
959
1062
  child.status = "unhealthy";
960
1063
  this.#invalidateFederationChildCache();
961
1064
  void this.#scheduleChildRestart(child, child.proc, "health-send-failed");
962
- return;
963
1065
  }
964
1066
  }
965
1067
  }
@@ -1000,7 +1102,7 @@ export class AkanApp {
1000
1102
  }
1001
1103
 
1002
1104
  #sendToChild(child: ChildState, message: AkanIpcMessage | BuilderMessage): boolean {
1003
- if (child.proc.killed || child.status === "exited") return false;
1105
+ if (this.#isChildUnavailable(child)) return false;
1004
1106
  try {
1005
1107
  child.proc.send(message);
1006
1108
  return true;
@@ -16,6 +16,7 @@ import type { AkanLib, AkanLibProps } from "./akanLib";
16
16
  import type { BuilderRpc } from "./artifact";
17
17
  import { DiLifecycle } from "./di/diLifecycle";
18
18
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
19
+ import { isPortInUseError } from "./lifecycle/portInUse";
19
20
  import { ShutdownManager } from "./lifecycle/shutdownManager";
20
21
  import { ProcessMetricsCollector } from "./processMetricsCollector";
21
22
  import { WebProxyRunner } from "./proxy";
@@ -74,7 +75,7 @@ export class AkanServer {
74
75
  websocketPrefix = "/ws";
75
76
  openapi = AkanServer.#isOpenApiEnvEnabled();
76
77
  serverMode: "federation" | "batch" | "all";
77
- shutdownTimeoutMs = 30_000;
78
+ shutdownTimeoutMs = AkanServer.#defaultShutdownTimeoutMs();
78
79
 
79
80
  #di: DiLifecycle;
80
81
  #localPublish: ((roomId: string, data: object | object[]) => void) | null = null;
@@ -257,10 +258,10 @@ export class AkanServer {
257
258
  websocket: websocketHandlers,
258
259
  } as Parameters<typeof Bun.serve>[0]);
259
260
  if (unix && process.env.AKAN_CHILD_WS_PORT) {
260
- const wsPort = Number(process.env.AKAN_CHILD_WS_PORT);
261
- this.#wsServer = Bun.serve({
261
+ const preferredWsPort = Number(process.env.AKAN_CHILD_WS_PORT);
262
+ const wsServeOptions = (port: number) => ({
262
263
  idleTimeout: 0,
263
- port: wsPort,
264
+ port,
264
265
  routes: ApiRouter.buildRoutes({
265
266
  prefix: this.prefix,
266
267
  websocketPrefix: this.websocketPrefix,
@@ -268,12 +269,20 @@ export class AkanServer {
268
269
  builtinRoutes,
269
270
  routeOptions,
270
271
  renderEnvRoutes,
271
- upgradeAppWs: (req, data) => this.#wsServer?.upgrade(req, { data }) ?? false,
272
+ upgradeAppWs: (req: Request, data: { createdAt: number }) => this.#wsServer?.upgrade(req, { data }) ?? false,
272
273
  webProxyRunner,
273
274
  }),
274
275
  websocket: websocketHandlers,
275
276
  });
276
- this.logger.verbose(`${this.name} websocket fallback is serving on port ${wsPort}`);
277
+ try {
278
+ this.#wsServer = Bun.serve(wsServeOptions(preferredWsPort));
279
+ } catch (error) {
280
+ if (!isPortInUseError(error)) throw error;
281
+
282
+ this.logger.warn(`ws port ${preferredWsPort} is in use; falling back to an ephemeral port`);
283
+ this.#wsServer = Bun.serve(wsServeOptions(0));
284
+ }
285
+ this.logger.verbose(`${this.name} websocket fallback is serving on port ${this.#wsServer.port}`);
277
286
  }
278
287
 
279
288
  const server = this.#server;
@@ -300,12 +309,14 @@ export class AkanServer {
300
309
  this.#startMetricsReporting();
301
310
  this.#di.registerSchedule(this.serverMode);
302
311
  this.logger.verbose(`🚀 ${this.name} is running on ${unix ? `unix://${unix}` : `port ${port}`}`);
312
+ const wsPort = this.#wsServer?.port;
303
313
  process.send?.({
304
314
  type: "ready",
305
315
  pid: process.pid,
306
316
  replicaIdx: Number(process.env.AKAN_REPLICA_IDX ?? 0),
307
317
  role: this.serverMode,
308
318
  upstream: unix ? { type: "unix", socketPath: unix } : { type: "tcp", host: "127.0.0.1", port: Number(port) },
319
+ wsUpstream: typeof wsPort === "number" ? { type: "tcp", host: "127.0.0.1", port: wsPort } : undefined,
309
320
  healthPath: "/_akan/app/child-health",
310
321
  } satisfies AkanIpcMessage);
311
322
  await this.#di.runSchedulerInit();
@@ -324,7 +335,7 @@ export class AkanServer {
324
335
  if (!isNoListenCommand) {
325
336
  this.#startMetricsReporting();
326
337
  this.#di.registerSchedule(this.serverMode);
327
- process.on("message", (message) => this.#handleIpcMessage(message as AkanIpcMessage));
338
+ this.#registerParentIpc();
328
339
  process.send?.({
329
340
  type: "ready",
330
341
  pid: process.pid,
@@ -336,7 +347,7 @@ export class AkanServer {
336
347
  }
337
348
  return this;
338
349
  }
339
- process.on("message", (message) => this.#handleIpcMessage(message as AkanIpcMessage));
350
+ this.#registerParentIpc();
340
351
  return this.listen();
341
352
  }
342
353
  async stop() {
@@ -368,6 +379,23 @@ export class AkanServer {
368
379
  }
369
380
  }
370
381
 
382
+ #registerParentIpc() {
383
+ process.on("message", (message) => this.#handleIpcMessage(message as AkanIpcMessage));
384
+ process.on("disconnect", () => this.#handleParentDisconnect());
385
+ }
386
+
387
+ /**
388
+ * The IPC channel closes when the parent gateway dies (including SIGKILL). Exiting here keeps a
389
+ * killed dev/gateway run from stranding replicas that would hold ports and break the next boot.
390
+ */
391
+ #handleParentDisconnect() {
392
+ this.logger.warn("Parent IPC channel closed; shutting down to avoid an orphaned replica");
393
+ setTimeout(() => process.exit(1), this.shutdownTimeoutMs + 1_000);
394
+ void this.stop()
395
+ .then(() => process.exit(0))
396
+ .catch(() => process.exit(1));
397
+ }
398
+
371
399
  #handleIpcMessage(message: AkanIpcMessage) {
372
400
  if (!message || typeof message !== "object") return;
373
401
  if (message.type === "pubsub.deliver") this.#localPublish?.(message.roomId, message.data as object | object[]);
@@ -461,6 +489,16 @@ export class AkanServer {
461
489
  );
462
490
  }
463
491
 
492
+ /**
493
+ * Shutdown must finish inside the gateway's child-wait budget or the layer above SIGKILLs this
494
+ * process and strands its resources; dev (`akan start`) keeps it short so edit-restarts stay snappy.
495
+ */
496
+ static #defaultShutdownTimeoutMs() {
497
+ const configured = Number(process.env.AKAN_SHUTDOWN_TIMEOUT_MS);
498
+ if (Number.isFinite(configured) && configured > 0) return configured;
499
+ return process.env.AKAN_COMMAND_TYPE === "start" ? 3_000 : 30_000;
500
+ }
501
+
464
502
  async #withShutdownTimeout<T>(promise: Promise<T>) {
465
503
  let timeout: Timer | null = null;
466
504
  try {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Detects "address already in use" listen failures across the shapes Bun throws them in
3
+ * (`code: "EADDRINUSE"` on newer versions, message-only on older ones).
4
+ */
5
+ export const isPortInUseError = (error: unknown): boolean => {
6
+ if (!error || typeof error !== "object") return false;
7
+ const candidate = error as { code?: unknown; message?: unknown };
8
+ if (candidate.code === "EADDRINUSE") return true;
9
+ const message = String(candidate.message ?? "");
10
+ return message.includes("EADDRINUSE") || message.toLowerCase().includes("in use");
11
+ };
@@ -216,6 +216,11 @@ export class RscRenderer {
216
216
  }
217
217
  this.#send = process.send.bind(process) as (message: unknown) => void;
218
218
  process.on("message", (msg: InMsg) => this.#handleMessage(msg));
219
+
220
+ process.on("disconnect", () => {
221
+ this.#logger.warn("parent IPC channel closed; exiting rsc worker");
222
+ process.exit(0);
223
+ });
219
224
  this.#logger.verbose(`constructed (pid=${process.pid})`);
220
225
  }
221
226
 
@@ -1,6 +1,6 @@
1
1
  export type AkanChildRole = "all" | "federation" | "batch";
2
2
 
3
- export type AkanChildStatus = "starting" | "ready" | "healthy" | "draining" | "unhealthy" | "exited";
3
+ export type AkanChildStatus = "starting" | "ready" | "healthy" | "draining" | "unhealthy" | "exited" | "crashed";
4
4
 
5
5
  export type AkanUpstream = { type: "unix"; socketPath: string } | { type: "tcp"; host: string; port: number };
6
6
 
@@ -110,6 +110,8 @@ export type AkanIpcMessage =
110
110
  replicaIdx: number;
111
111
  role: AkanChildRole;
112
112
  upstream?: AkanUpstream;
113
+ /** Actual websocket upstream the child bound; may differ from the preferred port when it was in use. */
114
+ wsUpstream?: Extract<AkanUpstream, { type: "tcp" }>;
113
115
  healthPath?: string;
114
116
  }
115
117
  | { type: "backend-ready"; pid: number }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Detects "address already in use" listen failures across the shapes Bun throws them in
3
+ * (`code: "EADDRINUSE"` on newer versions, message-only on older ones).
4
+ */
5
+ export declare const isPortInUseError: (error: unknown) => boolean;
@@ -1,5 +1,5 @@
1
1
  export type AkanChildRole = "all" | "federation" | "batch";
2
- export type AkanChildStatus = "starting" | "ready" | "healthy" | "draining" | "unhealthy" | "exited";
2
+ export type AkanChildStatus = "starting" | "ready" | "healthy" | "draining" | "unhealthy" | "exited" | "crashed";
3
3
  export type AkanUpstream = {
4
4
  type: "unix";
5
5
  socketPath: string;
@@ -122,6 +122,10 @@ export type AkanIpcMessage = {
122
122
  replicaIdx: number;
123
123
  role: AkanChildRole;
124
124
  upstream?: AkanUpstream;
125
+ /** Actual websocket upstream the child bound; may differ from the preferred port when it was in use. */
126
+ wsUpstream?: Extract<AkanUpstream, {
127
+ type: "tcp";
128
+ }>;
125
129
  healthPath?: string;
126
130
  } | {
127
131
  type: "backend-ready";