@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
|
@@ -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
|
|
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("
|
|
987
|
-
|
|
988
|
-
|
|
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("/")}/
|
|
994
|
-
"
|
|
1040
|
+
).join("/")}/obfuscated.json`,
|
|
1041
|
+
"{}",
|
|
995
1042
|
);
|
|
996
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
466
|
+
await transition(async () => {
|
|
467
|
+
if (!shouldRun || breaker || child !== null) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
424
470
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
471
|
+
try {
|
|
472
|
+
await launch();
|
|
473
|
+
} catch (error) {
|
|
474
|
+
runtime = BridgeRuntime.Crashed;
|
|
429
475
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
476
|
+
options.onLog.error("game failed to restart", {
|
|
477
|
+
error: errorText(error),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
});
|
|
434
481
|
};
|
|
435
482
|
|
|
436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
632
|
+
await launch();
|
|
633
|
+
});
|
|
592
634
|
},
|
|
593
635
|
|
|
594
636
|
async stop() {
|
|
595
637
|
shouldRun = false;
|
|
596
638
|
|
|
597
|
-
|
|
598
|
-
|
|
639
|
+
await transition(async () => {
|
|
640
|
+
shouldRun = false;
|
|
599
641
|
|
|
600
|
-
|
|
642
|
+
if (!child) {
|
|
643
|
+
runtime = BridgeRuntime.Stopped;
|
|
601
644
|
|
|
602
|
-
|
|
603
|
-
}
|
|
645
|
+
options.onStopped?.();
|
|
604
646
|
|
|
605
|
-
|
|
606
|
-
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const active = child;
|
|
651
|
+
const group = childGroup;
|
|
607
652
|
|
|
608
|
-
|
|
653
|
+
runtime = BridgeRuntime.Stopping;
|
|
609
654
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
618
|
-
|
|
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
|
-
|
|
629
|
-
|
|
673
|
+
if (settled === null) {
|
|
674
|
+
terminate(active, group, "SIGTERM");
|
|
630
675
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
676
|
+
const afterTerm = await Promise.race([
|
|
677
|
+
active.exited,
|
|
678
|
+
Bun.sleep(KILL_GRACE_MS).then(() => null),
|
|
679
|
+
]);
|
|
635
680
|
|
|
636
|
-
|
|
637
|
-
|
|
681
|
+
if (afterTerm === null) {
|
|
682
|
+
terminate(active, group, "SIGKILL");
|
|
638
683
|
|
|
639
|
-
|
|
684
|
+
await active.exited;
|
|
685
|
+
}
|
|
640
686
|
}
|
|
641
|
-
}
|
|
642
687
|
|
|
643
|
-
|
|
688
|
+
if (child === active) {
|
|
689
|
+
const code = exitCodeOf(active.exitCode, active.signalCode);
|
|
644
690
|
|
|
645
|
-
|
|
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
|
-
|
|
702
|
+
options.onStopped?.();
|
|
703
|
+
});
|
|
648
704
|
},
|
|
649
705
|
|
|
650
706
|
async command(input: string, commandOptions: BridgeCommandOptions = {}): Promise<BridgeReply> {
|
package/src/runtime/serve.ts
CHANGED
|
@@ -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);
|