@serverkgg/bridge 0.0.19 → 0.0.21

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": "@serverkgg/bridge",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -23,6 +23,7 @@ export interface BridgeInstallModule {
23
23
  export interface BridgeLifecycleModule {
24
24
  kind: BridgeKind.Lifecycle;
25
25
  ready?: RegExp;
26
+ failed?: RegExp;
26
27
  stopTimeoutSeconds?: number;
27
28
  command(context: BridgeContext): Promise<string[]>;
28
29
  stop?(context: BridgeContext): Promise<void>;
@@ -981,19 +981,73 @@ describe("list", () => {
981
981
  expect((await files.list("mods/*/*.jar")).map((entry) => entry.path)).toEqual([
982
982
  "mods/custom/plugin.jar",
983
983
  ]);
984
- await expect(files.list("**/*.jar")).rejects.toThrow("directory depth limit");
984
+ expect((await files.list("**/*.jar")).map((entry) => entry.path).sort()).toEqual([
985
+ "mods/custom/plugin.jar",
986
+ "server.jar",
987
+ ]);
988
+ });
989
+ test("prunes subtrees past the depth limit instead of failing the whole listing", async () => {
990
+ const nested = Array.from(
991
+ {
992
+ length: 14,
993
+ },
994
+ () => "nested",
995
+ ).join("/");
996
+ await files.write("resource_packs/pack/manifest.json", "{}");
997
+ await files.write("resource_packs/pack/pack_icon.png", "icon");
998
+ await files.write(`resource_packs/pack/models/${nested}/obfuscated.json`, "{}");
999
+ expect((await files.list("**/*")).map((entry) => entry.path).sort()).toEqual([
1000
+ "resource_packs/pack/manifest.json",
1001
+ "resource_packs/pack/pack_icon.png",
1002
+ ]);
1003
+ expect((await files.list("resource_packs/**/*.json")).map((entry) => entry.path)).toEqual([
1004
+ "resource_packs/pack/manifest.json",
1005
+ ]);
1006
+ expect(
1007
+ (
1008
+ await files.list("**/*", {
1009
+ directory: "resource_packs/pack",
1010
+ })
1011
+ )
1012
+ .map((entry) => entry.path)
1013
+ .sort(),
1014
+ ).toEqual([
1015
+ "resource_packs/pack/manifest.json",
1016
+ "resource_packs/pack/pack_icon.png",
1017
+ ]);
985
1018
  });
986
- test("refuses partial deep listings and unsafe listing roots", async () => {
987
- await files.write(
988
- `${Array.from(
1019
+ test("warns about what a pruned listing left out", async () => {
1020
+ const lines: string[] = [];
1021
+ const noisy = createBridgeFiles({
1022
+ root,
1023
+ uid: 0,
1024
+ gid: 0,
1025
+ egress,
1026
+ onLog: Object.assign(() => undefined, {
1027
+ warn: (message: string) => {
1028
+ lines.push(message);
1029
+ },
1030
+ error: () => undefined,
1031
+ }),
1032
+ });
1033
+ await noisy.write("manifest.json", "{}");
1034
+ await noisy.write(
1035
+ `models/${Array.from(
989
1036
  {
990
1037
  length: 14,
991
1038
  },
992
1039
  () => "nested",
993
- ).join("/")}/file.txt`,
994
- "data",
1040
+ ).join("/")}/obfuscated.json`,
1041
+ "{}",
995
1042
  );
996
- await expect(files.list("**/*")).rejects.toThrow("directory depth limit");
1043
+ expect((await noisy.list("**/*.json")).map((entry) => entry.path)).toEqual([
1044
+ "manifest.json",
1045
+ ]);
1046
+ expect(lines).toEqual([
1047
+ "file listing skipped directories deeper than its depth limit",
1048
+ ]);
1049
+ });
1050
+ test("refuses unsafe listing roots", async () => {
997
1051
  await expect(
998
1052
  files.list("**/*", {
999
1053
  directory: "../outside",
@@ -209,10 +209,13 @@ const walk = async (
209
209
  directoryPatterns: RegExp[],
210
210
  scan: {
211
211
  visited: number;
212
+ pruned: number;
212
213
  },
213
214
  ) => {
214
215
  if (depth > WALK_DEPTH) {
215
- throw new BridgePathError("invalid", "file listing exceeded its directory depth limit");
216
+ scan.pruned += 1;
217
+
218
+ return;
216
219
  }
217
220
 
218
221
  let entries: Dirent[];
@@ -392,9 +395,18 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
392
395
  if (!resolved.isDirectory) {
393
396
  throw new BridgePathError("invalid", `path "${prefix}" is not a directory to list`);
394
397
  }
395
- await walk(resolved.target, prefix, 0, entries, pattern, directoryPatterns, {
398
+ const scan = {
396
399
  visited: 0,
397
- });
400
+ pruned: 0,
401
+ };
402
+ await walk(resolved.target, prefix, 0, entries, pattern, directoryPatterns, scan);
403
+ if (scan.pruned > 0) {
404
+ options.onLog.warn("file listing skipped directories deeper than its depth limit", {
405
+ glob,
406
+ depth: WALK_DEPTH,
407
+ skipped: scan.pruned,
408
+ });
409
+ }
398
410
  return entries;
399
411
  },
400
412
 
@@ -4,7 +4,7 @@ import { BridgeRuntime } from "../core";
4
4
  import { BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
5
5
  import type { BridgeLeveledLogger } from "./bridgeLog";
6
6
  import { readProcessGroup } from "./bridgeSignal";
7
- import { createSupervisor } from "./bridgeSupervisor";
7
+ import { type BridgeSupervisor, createSupervisor } from "./bridgeSupervisor";
8
8
 
9
9
  const CHILD_PATTERN = /child=(?<pid>\d+)/;
10
10
 
@@ -32,6 +32,14 @@ const TIGHT_LAST_LINE_CRASH = `sleep 0.2; echo "${LAST_LINE}"; exit 3`;
32
32
 
33
33
  const INSTANT_EXIT = "false";
34
34
 
35
+ const FAILED_BOOT_LINE = "[main/FATAL] [minecraft/Main]: Failed to start the minecraft server";
36
+
37
+ const FAILED_BOOT = /Failed to start the minecraft server/;
38
+
39
+ const FAILED_BOOT_WRAPPER = `sleep 300 & echo child=$!; echo "${FAILED_BOOT_LINE}"; wait`;
40
+
41
+ const LATE_FAILED_LINE_WRAPPER = `echo ready; sleep 0.6; echo "${FAILED_BOOT_LINE}"; sleep 300`;
42
+
35
43
  const silent: BridgeLeveledLogger = Object.assign(() => {}, {
36
44
  warn: () => {},
37
45
  error: () => {},
@@ -169,6 +177,55 @@ const timedSupervisor = (command: () => string, ready: RegExp, stableAfterMs: nu
169
177
  };
170
178
  };
171
179
 
180
+ const racedSupervisor = (delayMs: number, resolve: () => string[]) => {
181
+ const pids: number[] = [];
182
+
183
+ let launches = 0;
184
+ let overlapped = false;
185
+
186
+ const supervisor: BridgeSupervisor = createSupervisor({
187
+ cwd: tmpdir(),
188
+ uid: process.getuid?.() ?? 0,
189
+ gid: process.getgid?.() ?? 0,
190
+ stopTimeoutSeconds: 1,
191
+ onLog: silent,
192
+ async resolveCommand() {
193
+ launches += 1;
194
+
195
+ if (supervisor.running) {
196
+ overlapped = true;
197
+ }
198
+
199
+ await Bun.sleep(delayMs);
200
+
201
+ return resolve();
202
+ },
203
+ });
204
+
205
+ supervisor.subscribe((entry) => {
206
+ const pid = Number(entry.line.match(CHILD_PATTERN)?.groups?.pid);
207
+
208
+ if (Number.isSafeInteger(pid)) {
209
+ pids.push(pid);
210
+ }
211
+ });
212
+
213
+ return {
214
+ supervisor,
215
+ pids,
216
+ launches: () => launches,
217
+ overlapped: () => overlapped,
218
+ };
219
+ };
220
+
221
+ const wrapperArgv = () => {
222
+ return [
223
+ "bash",
224
+ "-c",
225
+ WRAPPER,
226
+ ];
227
+ };
228
+
172
229
  describe("createSupervisor", () => {
173
230
  test("runs the game outside the bridge's own process group", async () => {
174
231
  const { supervisor, pid } = await startWrapper(WRAPPER, 1);
@@ -397,4 +454,158 @@ describe("createSupervisor", () => {
397
454
 
398
455
  await supervisor.stop();
399
456
  }, 40_000);
457
+
458
+ test("spawns one game when two starts race inside the launch window", async () => {
459
+ const { supervisor, pids, launches, overlapped } = racedSupervisor(200, wrapperArgv);
460
+
461
+ await Promise.all([
462
+ supervisor.start(),
463
+ supervisor.start(),
464
+ ]);
465
+
466
+ expect(launches(), "the second start entered the launch path beside the first").toBe(1);
467
+ expect(await waitFor(() => pids.length > 0, 10_000)).toBe(true);
468
+
469
+ await Bun.sleep(500);
470
+
471
+ expect(pids, "the race spawned a second game").toHaveLength(1);
472
+ expect(overlapped()).toBe(false);
473
+
474
+ await supervisor.stop();
475
+
476
+ expect(await waitGone(pids.at(0) ?? 0, 5000)).toBe(true);
477
+ }, 30_000);
478
+
479
+ test("never spawns beside the game a stop is taking down", async () => {
480
+ const { supervisor, pids, launches, overlapped } = racedSupervisor(0, wrapperArgv);
481
+
482
+ await supervisor.start();
483
+
484
+ expect(await waitFor(() => pids.length === 1, 10_000)).toBe(true);
485
+
486
+ const first = pids.at(0) ?? 0;
487
+
488
+ const stopping = supervisor.stop();
489
+ const starting = supervisor.start();
490
+
491
+ await stopping;
492
+ await starting;
493
+
494
+ expect(await waitFor(() => pids.length === 2, 10_000)).toBe(true);
495
+ expect(launches()).toBe(2);
496
+ expect(overlapped(), "the queued start spawned while the stopped game was still alive").toBe(false);
497
+ expect(await waitGone(first, 5000)).toBe(true);
498
+ expect(supervisor.running).toBe(true);
499
+
500
+ await supervisor.stop();
501
+
502
+ expect(await waitGone(pids.at(1) ?? 0, 5000)).toBe(true);
503
+ }, 30_000);
504
+
505
+ test("starts again after a launch that threw before the game was spawned", async () => {
506
+ let resolvable = false;
507
+
508
+ const { supervisor, pids } = racedSupervisor(0, () => {
509
+ if (!resolvable) {
510
+ throw new Error("the start command could not be resolved");
511
+ }
512
+
513
+ return wrapperArgv();
514
+ });
515
+
516
+ await expect(supervisor.start()).rejects.toThrow("the start command could not be resolved");
517
+ expect(supervisor.running).toBe(false);
518
+
519
+ resolvable = true;
520
+
521
+ await supervisor.start();
522
+
523
+ expect(supervisor.running, "the failed launch wedged the supervisor").toBe(true);
524
+ expect(await waitFor(() => pids.length === 1, 10_000)).toBe(true);
525
+
526
+ await supervisor.stop();
527
+
528
+ expect(await waitGone(pids.at(0) ?? 0, 5000)).toBe(true);
529
+ }, 30_000);
530
+
531
+ test("stops a game that reports a failed boot instead of waiting for a ready line that never comes", async () => {
532
+ const records: BridgeCrashRecord[] = [];
533
+ const pids: number[] = [];
534
+
535
+ const supervisor = createSupervisor({
536
+ cwd: tmpdir(),
537
+ uid: process.getuid?.() ?? 0,
538
+ gid: process.getgid?.() ?? 0,
539
+ stopTimeoutSeconds: 1,
540
+ ready: READY,
541
+ failed: FAILED_BOOT,
542
+ onLog: silent,
543
+ resolveCommand: async () => {
544
+ return [
545
+ "bash",
546
+ "-c",
547
+ FAILED_BOOT_WRAPPER,
548
+ ];
549
+ },
550
+ onExit(record) {
551
+ records.push(record);
552
+ },
553
+ });
554
+
555
+ supervisor.subscribe((entry) => {
556
+ const match = CHILD_PATTERN.exec(entry.line);
557
+
558
+ if (match?.groups?.pid) {
559
+ pids.push(Number(match.groups.pid));
560
+ }
561
+ });
562
+
563
+ await supervisor.start();
564
+
565
+ expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
566
+
567
+ const record = records.at(0);
568
+
569
+ expect(record?.phase).toBe(BridgeCrashPhase.Booting);
570
+ expect(record?.outOfMemory).toBe(false);
571
+ expect(record?.restarting).toBe(false);
572
+ expect(record?.lastLine).toBe(FAILED_BOOT_LINE);
573
+ expect(supervisor.running).toBe(false);
574
+ expect(supervisor.runtime).toBe(BridgeRuntime.Crashed);
575
+ expect(await waitGone(pids.at(0) ?? 0, 5000)).toBe(true);
576
+ }, 30_000);
577
+
578
+ test("ignores the failed pattern once the game has been running long enough to count as stable", async () => {
579
+ const records: BridgeCrashRecord[] = [];
580
+
581
+ const supervisor = createSupervisor({
582
+ cwd: tmpdir(),
583
+ uid: process.getuid?.() ?? 0,
584
+ gid: process.getgid?.() ?? 0,
585
+ stopTimeoutSeconds: 1,
586
+ ready: READY,
587
+ failed: FAILED_BOOT,
588
+ stableAfterMs: 200,
589
+ onLog: silent,
590
+ resolveCommand: async () => {
591
+ return [
592
+ "bash",
593
+ "-c",
594
+ LATE_FAILED_LINE_WRAPPER,
595
+ ];
596
+ },
597
+ onExit(record) {
598
+ records.push(record);
599
+ },
600
+ });
601
+
602
+ await supervisor.start();
603
+
604
+ await Bun.sleep(1500);
605
+
606
+ expect(records).toHaveLength(0);
607
+ expect(supervisor.running).toBe(true);
608
+
609
+ await supervisor.stop();
610
+ }, 30_000);
400
611
  });
@@ -36,6 +36,7 @@ export interface SupervisorOptions {
36
36
  gid: number;
37
37
  stopTimeoutSeconds: number;
38
38
  ready?: RegExp;
39
+ failed?: RegExp;
39
40
  resolveCommand: () => Promise<string[]>;
40
41
  gracefulStop?: () => Promise<void>;
41
42
  stableAfterMs?: number;
@@ -135,6 +136,21 @@ const drainPumps = async (pumps: readonly Promise<unknown>[]) => {
135
136
  ]);
136
137
  };
137
138
 
139
+ const chain = () => {
140
+ let queue: Promise<unknown> = Promise.resolve();
141
+
142
+ return <Result>(run: () => Promise<Result>): Promise<Result> => {
143
+ const next = queue.then(run, run);
144
+
145
+ queue = next.then(
146
+ () => undefined,
147
+ () => undefined,
148
+ );
149
+
150
+ return next;
151
+ };
152
+ };
153
+
138
154
  export const createSupervisor = (options: SupervisorOptions) => {
139
155
  const tail: SupervisorLine[] = [];
140
156
  const watchers = new Set<LineWatcher>();
@@ -151,7 +167,10 @@ export const createSupervisor = (options: SupervisorOptions) => {
151
167
  let launchedAt = performance.now();
152
168
  let stable = false;
153
169
  let stableTimer: ReturnType<typeof setTimeout> | null = null;
154
- let queue: Promise<unknown> = Promise.resolve();
170
+ let bootFailure: string | null = null;
171
+
172
+ const serialize = chain();
173
+ const transition = chain();
155
174
 
156
175
  const markStable = () => {
157
176
  if (stable) {
@@ -281,6 +300,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
281
300
 
282
301
  launchedAt = performance.now();
283
302
  stable = false;
303
+ bootFailure = null;
284
304
 
285
305
  if (stableTimer) {
286
306
  clearTimeout(stableTimer);
@@ -299,7 +319,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
299
319
 
300
320
  reported = true;
301
321
 
302
- void handleExit(code, pumps).catch((error: unknown) => {
322
+ void handleExit(spawned, code, pumps).catch((error: unknown) => {
303
323
  options.onLog.error("the game exit was not reported", {
304
324
  error: errorText(error),
305
325
  });
@@ -379,6 +399,31 @@ export const createSupervisor = (options: SupervisorOptions) => {
379
399
  return;
380
400
  }
381
401
 
402
+ if (options.failed) {
403
+ void watch(options.failed, options.stopTimeoutSeconds * 1000 * 10).then((match) => {
404
+ if (!match || launchStamp !== launchedAt || child === null || stable) {
405
+ return;
406
+ }
407
+
408
+ bootFailure = (match.input ?? match[0]).trim().slice(0, BRIDGE_CRASH_LINE_LIMIT);
409
+
410
+ options.onLog.error("the game reported a failed boot, stopping it", {
411
+ line: bootFailure,
412
+ });
413
+
414
+ const failing = child;
415
+ const failingGroup = childGroup;
416
+
417
+ terminate(failing, failingGroup, "SIGTERM");
418
+
419
+ void Bun.sleep(KILL_GRACE_MS).then(() => {
420
+ if (child === failing) {
421
+ terminate(failing, failingGroup, "SIGKILL");
422
+ }
423
+ });
424
+ });
425
+ }
426
+
382
427
  if (!options.ready) {
383
428
  runtime = BridgeRuntime.Running;
384
429
 
@@ -418,24 +463,24 @@ export const createSupervisor = (options: SupervisorOptions) => {
418
463
  const restart = async () => {
419
464
  await Bun.sleep(RESTART_DELAY_MS);
420
465
 
421
- if (!shouldRun || breaker || child !== null) {
422
- return;
423
- }
466
+ await transition(async () => {
467
+ if (!shouldRun || breaker || child !== null) {
468
+ return;
469
+ }
424
470
 
425
- try {
426
- await launch();
427
- } catch (error) {
428
- runtime = BridgeRuntime.Crashed;
471
+ try {
472
+ await launch();
473
+ } catch (error) {
474
+ runtime = BridgeRuntime.Crashed;
429
475
 
430
- options.onLog.error("game failed to restart", {
431
- error: errorText(error),
432
- });
433
- }
476
+ options.onLog.error("game failed to restart", {
477
+ error: errorText(error),
478
+ });
479
+ }
480
+ });
434
481
  };
435
482
 
436
- async function handleExit(code: number, pumps: readonly Promise<unknown>[]) {
437
- const finished = child;
438
-
483
+ const reap = (finished: SpawnedGame, code: number) => {
439
484
  child = null;
440
485
  exitCode = code;
441
486
 
@@ -445,11 +490,17 @@ export const createSupervisor = (options: SupervisorOptions) => {
445
490
  stableTimer = null;
446
491
  }
447
492
 
448
- if (finished) {
449
- sweep(finished.pid, childGroup);
450
- }
493
+ sweep(finished.pid, childGroup);
451
494
 
452
495
  childGroup = null;
496
+ };
497
+
498
+ async function handleExit(finished: SpawnedGame, code: number, pumps: readonly Promise<unknown>[]) {
499
+ if (finished !== child) {
500
+ return;
501
+ }
502
+
503
+ reap(finished, code);
453
504
 
454
505
  if (!shouldRun) {
455
506
  outOfMemory = false;
@@ -466,7 +517,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
466
517
  const uptimeMs = performance.now() - launchedAt;
467
518
  const phase = stable ? BridgeCrashPhase.Running : BridgeCrashPhase.Booting;
468
519
 
469
- outOfMemory = code === SIGKILL_EXIT_CODE;
520
+ outOfMemory = code === SIGKILL_EXIT_CODE && bootFailure === null;
470
521
 
471
522
  if (outOfMemory) {
472
523
  options.onLog.error("the game was killed for running out of memory", {
@@ -489,7 +540,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
489
540
 
490
541
  options.onExit?.({
491
542
  ...settled,
492
- lastLine: lastLine(),
543
+ lastLine: bootFailure ?? lastLine(),
493
544
  });
494
545
  };
495
546
 
@@ -535,17 +586,6 @@ export const createSupervisor = (options: SupervisorOptions) => {
535
586
  void restart();
536
587
  }
537
588
 
538
- const serialize = <Result>(run: () => Promise<Result>): Promise<Result> => {
539
- const next = queue.then(run, run);
540
-
541
- queue = next.then(
542
- () => undefined,
543
- () => undefined,
544
- );
545
-
546
- return next;
547
- };
548
-
549
589
  const writeLine = async (input: string) => {
550
590
  if (!child) {
551
591
  throw new Error("the game is not running");
@@ -578,73 +618,89 @@ export const createSupervisor = (options: SupervisorOptions) => {
578
618
  },
579
619
 
580
620
  async start() {
581
- shouldRun = true;
582
- breaker = false;
583
- outOfMemory = false;
584
- restarts = 0;
585
- restartStamps.length = 0;
586
-
587
- if (child) {
588
- return;
589
- }
621
+ await transition(async () => {
622
+ shouldRun = true;
623
+ breaker = false;
624
+ outOfMemory = false;
625
+ restarts = 0;
626
+ restartStamps.length = 0;
627
+
628
+ if (child) {
629
+ return;
630
+ }
590
631
 
591
- await launch();
632
+ await launch();
633
+ });
592
634
  },
593
635
 
594
636
  async stop() {
595
637
  shouldRun = false;
596
638
 
597
- if (!child) {
598
- runtime = BridgeRuntime.Stopped;
639
+ await transition(async () => {
640
+ shouldRun = false;
599
641
 
600
- options.onStopped?.();
642
+ if (!child) {
643
+ runtime = BridgeRuntime.Stopped;
601
644
 
602
- return;
603
- }
645
+ options.onStopped?.();
604
646
 
605
- const active = child;
606
- const group = childGroup;
647
+ return;
648
+ }
649
+
650
+ const active = child;
651
+ const group = childGroup;
607
652
 
608
- runtime = BridgeRuntime.Stopping;
653
+ runtime = BridgeRuntime.Stopping;
609
654
 
610
- if (options.gracefulStop) {
611
- const graceful = options.gracefulStop().catch((error: unknown) => {
612
- options.onLog.warn("graceful stop failed", {
613
- error: errorText(error),
655
+ if (options.gracefulStop) {
656
+ const graceful = options.gracefulStop().catch((error: unknown) => {
657
+ options.onLog.warn("graceful stop failed", {
658
+ error: errorText(error),
659
+ });
614
660
  });
615
- });
616
661
 
617
- await Promise.race([
618
- graceful,
662
+ await Promise.race([
663
+ graceful,
664
+ active.exited,
665
+ ]);
666
+ }
667
+
668
+ const settled = await Promise.race([
619
669
  active.exited,
670
+ Bun.sleep(options.stopTimeoutSeconds * 1000).then(() => null),
620
671
  ]);
621
- }
622
-
623
- const settled = await Promise.race([
624
- active.exited,
625
- Bun.sleep(options.stopTimeoutSeconds * 1000).then(() => null),
626
- ]);
627
672
 
628
- if (settled === null) {
629
- terminate(active, group, "SIGTERM");
673
+ if (settled === null) {
674
+ terminate(active, group, "SIGTERM");
630
675
 
631
- const afterTerm = await Promise.race([
632
- active.exited,
633
- Bun.sleep(KILL_GRACE_MS).then(() => null),
634
- ]);
676
+ const afterTerm = await Promise.race([
677
+ active.exited,
678
+ Bun.sleep(KILL_GRACE_MS).then(() => null),
679
+ ]);
635
680
 
636
- if (afterTerm === null) {
637
- terminate(active, group, "SIGKILL");
681
+ if (afterTerm === null) {
682
+ terminate(active, group, "SIGKILL");
638
683
 
639
- await active.exited;
684
+ await active.exited;
685
+ }
640
686
  }
641
- }
642
687
 
643
- sweep(active.pid, group);
688
+ if (child === active) {
689
+ const code = exitCodeOf(active.exitCode, active.signalCode);
644
690
 
645
- runtime = BridgeRuntime.Stopped;
691
+ reap(active, code);
692
+
693
+ outOfMemory = false;
694
+
695
+ options.onLog("game stopped", {
696
+ code,
697
+ });
698
+ }
699
+
700
+ runtime = BridgeRuntime.Stopped;
646
701
 
647
- options.onStopped?.();
702
+ options.onStopped?.();
703
+ });
648
704
  },
649
705
 
650
706
  async command(input: string, commandOptions: BridgeCommandOptions = {}): Promise<BridgeReply> {
@@ -83,6 +83,7 @@ export const serve = async (driver: BridgeDriver) => {
83
83
  gid: config.gameGid,
84
84
  stopTimeoutSeconds: driver.lifecycle.stopTimeoutSeconds ?? DEFAULT_STOP_TIMEOUT_SECONDS,
85
85
  ready: driver.lifecycle.ready,
86
+ failed: driver.lifecycle.failed,
86
87
  onLog: log,
87
88
  async resolveCommand() {
88
89
  return await driver.lifecycle.command(context);