@serverkgg/bridge 0.0.15 → 0.0.17
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 +1 -1
- package/src/context/bridgeContext.ts +7 -1
- package/src/core/bridgeAction.ts +2 -0
- package/src/modules/bridgeModule.ts +21 -4
- package/src/protocol/bridgeCatalog.ts +6 -0
- package/src/protocol/bridgeCrash.ts +83 -0
- package/src/protocol/bridgeFrame.ts +14 -0
- package/src/protocol/bridgeManifest.ts +1 -0
- package/src/protocol/bridgePreview.ts +1 -0
- package/src/protocol/index.ts +1 -0
- package/src/runtime/bridgeActions.ts +10 -0
- package/src/runtime/bridgeBackupLease.test.ts +148 -0
- package/src/runtime/bridgeBackupLease.ts +74 -0
- package/src/runtime/bridgeEvents.test.ts +411 -9
- package/src/runtime/bridgeEvents.ts +53 -3
- package/src/runtime/bridgeFiles.test.ts +330 -4
- package/src/runtime/bridgeFiles.ts +112 -18
- package/src/runtime/bridgeRegistry.test.ts +61 -0
- package/src/runtime/bridgeRegistry.ts +30 -2
- package/src/runtime/bridgeServer.test.ts +58 -0
- package/src/runtime/bridgeServer.ts +14 -6
- package/src/runtime/bridgeSupervisor.test.ts +289 -0
- package/src/runtime/bridgeSupervisor.ts +166 -9
- package/src/runtime/bridgeZip.ts +73 -6
- package/src/runtime/serve.ts +58 -14
- package/src/wire/panel.ts +1 -0
|
@@ -31,6 +31,18 @@ const requiresRunning = (module: BridgePanelModule) => {
|
|
|
31
31
|
return false;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
export const protectedActionsOf = (module: BridgePanelModule): string[] => {
|
|
35
|
+
const protectedActions = "protectedActions" in module ? (module.protectedActions ?? []) : [];
|
|
36
|
+
const declared = actionsOf(module);
|
|
37
|
+
if (
|
|
38
|
+
protectedActions.some((action) => isReadAction(action) || !declared.includes(action))
|
|
39
|
+
|| (protectedActions.length > 0 && requiresRunning(module))
|
|
40
|
+
) {
|
|
41
|
+
throw new BridgeDriverError("protected actions must be declared mutations that work while stopped");
|
|
42
|
+
}
|
|
43
|
+
return protectedActions;
|
|
44
|
+
};
|
|
45
|
+
|
|
34
46
|
const refreshOf = (module: BridgePanelModule) => {
|
|
35
47
|
if (module.kind === BridgeKind.Collection || module.kind === BridgeKind.Detail) {
|
|
36
48
|
return module.refreshSeconds ?? null;
|
|
@@ -358,6 +370,12 @@ export const createRegistry = (
|
|
|
358
370
|
}
|
|
359
371
|
|
|
360
372
|
case BridgeKind.Catalog: {
|
|
373
|
+
if (action === BridgeAction.Releases && module.releases) {
|
|
374
|
+
return await module.releases(context, asString(args, "id"));
|
|
375
|
+
}
|
|
376
|
+
if (action === BridgeAction.Preview && module.preview) {
|
|
377
|
+
return await module.preview(context, asString(args, "id"), asOptional(args, "releaseId") ?? undefined);
|
|
378
|
+
}
|
|
361
379
|
if (action === BridgeAction.Search) {
|
|
362
380
|
return await module.search(context, {
|
|
363
381
|
query: asText(args, "query"),
|
|
@@ -373,7 +391,7 @@ export const createRegistry = (
|
|
|
373
391
|
}
|
|
374
392
|
|
|
375
393
|
if (action === BridgeAction.Install) {
|
|
376
|
-
return await module.install(context, asString(args, "id"));
|
|
394
|
+
return await module.install(context, asString(args, "id"), asOptional(args, "releaseId") ?? undefined);
|
|
377
395
|
}
|
|
378
396
|
|
|
379
397
|
if (action === BridgeAction.Remove) {
|
|
@@ -398,6 +416,7 @@ export const createRegistry = (
|
|
|
398
416
|
return {
|
|
399
417
|
manifest(): BridgeManifest {
|
|
400
418
|
const entries: BridgeModuleManifest[] = Object.entries(modules).map(([id, module]) => {
|
|
419
|
+
const protectedActions = protectedActionsOf(module);
|
|
401
420
|
return {
|
|
402
421
|
id,
|
|
403
422
|
kind: module.kind,
|
|
@@ -406,6 +425,11 @@ export const createRegistry = (
|
|
|
406
425
|
pageSize: pageSizeOf(module),
|
|
407
426
|
dependsOn: dependsOf(module),
|
|
408
427
|
actions: actionsOf(module),
|
|
428
|
+
...(protectedActions.length > 0
|
|
429
|
+
? {
|
|
430
|
+
protectedActions,
|
|
431
|
+
}
|
|
432
|
+
: {}),
|
|
409
433
|
};
|
|
410
434
|
});
|
|
411
435
|
|
|
@@ -419,13 +443,17 @@ export const createRegistry = (
|
|
|
419
443
|
};
|
|
420
444
|
},
|
|
421
445
|
|
|
422
|
-
async invoke(name: string, action: string, args: BridgeValues = {}) {
|
|
446
|
+
async invoke(name: string, action: string, args: BridgeValues = {}, recoveryOperationId?: string) {
|
|
423
447
|
const module = modules[name];
|
|
424
448
|
|
|
425
449
|
if (!module) {
|
|
426
450
|
throw new BridgeDriverError(`no module named "${name}"`);
|
|
427
451
|
}
|
|
428
452
|
|
|
453
|
+
if (protectedActionsOf(module).includes(action) && (!recoveryOperationId || supervisor.running)) {
|
|
454
|
+
throw new BridgeDriverError("this action needs a recovery operation with the game stopped");
|
|
455
|
+
}
|
|
456
|
+
|
|
429
457
|
if (requiresRunning(module) && !supervisor.running) {
|
|
430
458
|
throw new BridgeDriverError(`module "${name}" needs the game to be running`);
|
|
431
459
|
}
|
|
@@ -40,6 +40,7 @@ const baseOptions = (socket: string): ServerOptions => ({
|
|
|
40
40
|
exitCode: null,
|
|
41
41
|
outOfMemory: false,
|
|
42
42
|
restarts: 0,
|
|
43
|
+
crashRecords: true,
|
|
43
44
|
installError: null,
|
|
44
45
|
installErrorText: null,
|
|
45
46
|
installHold: null,
|
|
@@ -596,3 +597,60 @@ describe("error serialisation", () => {
|
|
|
596
597
|
});
|
|
597
598
|
});
|
|
598
599
|
});
|
|
600
|
+
|
|
601
|
+
describe("backup ownership dispatch", () => {
|
|
602
|
+
let dir = "";
|
|
603
|
+
beforeEach(async () => {
|
|
604
|
+
dir = await mkdtemp(join(tmpdir(), "bridge-lease-"));
|
|
605
|
+
});
|
|
606
|
+
afterEach(async () => {
|
|
607
|
+
await rm(dir, {
|
|
608
|
+
force: true,
|
|
609
|
+
recursive: true,
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
test("carries the lease token through renew and release frames", async () => {
|
|
613
|
+
const socket = join(dir, "bridge.sock");
|
|
614
|
+
const renewals: (string | undefined)[] = [];
|
|
615
|
+
const releases: (string | undefined)[] = [];
|
|
616
|
+
const server = createServer({
|
|
617
|
+
...baseOptions(socket),
|
|
618
|
+
onQuiesce: async (supportsSnapshot, leaseId) => {
|
|
619
|
+
expect(supportsSnapshot).toBe(true);
|
|
620
|
+
renewals.push(leaseId);
|
|
621
|
+
return {
|
|
622
|
+
supported: true,
|
|
623
|
+
quiesced: true,
|
|
624
|
+
settleSeconds: 0,
|
|
625
|
+
leaseId,
|
|
626
|
+
};
|
|
627
|
+
},
|
|
628
|
+
onRelease: async (leaseId) => {
|
|
629
|
+
releases.push(leaseId);
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
try {
|
|
633
|
+
await call(socket, {
|
|
634
|
+
id: "renew",
|
|
635
|
+
token: TOKEN,
|
|
636
|
+
method: BridgeMethod.Quiesce,
|
|
637
|
+
supportsSnapshot: true,
|
|
638
|
+
leaseId: "owner",
|
|
639
|
+
});
|
|
640
|
+
await call(socket, {
|
|
641
|
+
id: "release",
|
|
642
|
+
token: TOKEN,
|
|
643
|
+
method: BridgeMethod.Release,
|
|
644
|
+
leaseId: "owner",
|
|
645
|
+
});
|
|
646
|
+
expect(renewals).toEqual([
|
|
647
|
+
"owner",
|
|
648
|
+
]);
|
|
649
|
+
expect(releases).toEqual([
|
|
650
|
+
"owner",
|
|
651
|
+
]);
|
|
652
|
+
} finally {
|
|
653
|
+
server.stop();
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
});
|
|
@@ -25,11 +25,11 @@ export interface ServerOptions {
|
|
|
25
25
|
onStart: () => Promise<void>;
|
|
26
26
|
onStop: () => Promise<void>;
|
|
27
27
|
onCommand: (input: string, expect: string | undefined, timeoutMs: number | undefined) => Promise<BridgeCommandResult>;
|
|
28
|
-
onInvoke: (module: string, action: string, args: BridgeValues) => Promise<unknown>;
|
|
28
|
+
onInvoke: (module: string, action: string, args: BridgeValues, recoveryOperationId?: string) => Promise<unknown>;
|
|
29
29
|
onEvents: (cursor: number) => BridgeEventsResult;
|
|
30
30
|
onSample: () => Promise<BridgeSampleResult>;
|
|
31
|
-
onQuiesce: () => Promise<BridgeQuiesceResult>;
|
|
32
|
-
onRelease: () => Promise<void>;
|
|
31
|
+
onQuiesce: (supportsSnapshot: boolean, leaseId?: string) => Promise<BridgeQuiesceResult>;
|
|
32
|
+
onRelease: (leaseId?: string) => Promise<void>;
|
|
33
33
|
onDescribe: () => Promise<BridgeDescribeResult>;
|
|
34
34
|
onAnnounce: (message: string) => Promise<BridgeAnnounceResult>;
|
|
35
35
|
onPendingList: () => Promise<BridgePendingResult>;
|
|
@@ -40,6 +40,9 @@ export interface ServerOptions {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
interface IncomingFrame {
|
|
43
|
+
leaseId?: unknown;
|
|
44
|
+
supportsSnapshot?: unknown;
|
|
45
|
+
recoveryOperationId?: unknown;
|
|
43
46
|
id?: unknown;
|
|
44
47
|
token?: unknown;
|
|
45
48
|
method?: unknown;
|
|
@@ -116,7 +119,12 @@ export const createServer = (options: ServerOptions) => {
|
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
case BridgeMethod.Invoke: {
|
|
119
|
-
return await options.onInvoke(
|
|
122
|
+
return await options.onInvoke(
|
|
123
|
+
String(request.module ?? ""),
|
|
124
|
+
String(request.action ?? ""),
|
|
125
|
+
asArgs(request.args),
|
|
126
|
+
asText(request.recoveryOperationId) ?? undefined,
|
|
127
|
+
);
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
case BridgeMethod.Events: {
|
|
@@ -128,11 +136,11 @@ export const createServer = (options: ServerOptions) => {
|
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
case BridgeMethod.Quiesce: {
|
|
131
|
-
return await options.onQuiesce();
|
|
139
|
+
return await options.onQuiesce(request.supportsSnapshot === true, asText(request.leaseId) ?? undefined);
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
case BridgeMethod.Release: {
|
|
135
|
-
await options.onRelease();
|
|
143
|
+
await options.onRelease(asText(request.leaseId) ?? undefined);
|
|
136
144
|
|
|
137
145
|
return null;
|
|
138
146
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { BridgeRuntime } from "../core";
|
|
4
|
+
import { BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
|
|
4
5
|
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
5
6
|
import { readProcessGroup } from "./bridgeSignal";
|
|
6
7
|
import { createSupervisor } from "./bridgeSupervisor";
|
|
@@ -11,6 +12,26 @@ const WRAPPER = "sleep 300 & echo child=$!; wait";
|
|
|
11
12
|
|
|
12
13
|
const STUBBORN_WRAPPER = 'trap "" TERM; sleep 300 & echo child=$!; wait';
|
|
13
14
|
|
|
15
|
+
const NEVER_READY = /this game never says it is ready/;
|
|
16
|
+
|
|
17
|
+
const READY = /^ready$/;
|
|
18
|
+
|
|
19
|
+
const BOOT_CRASH = "sleep 0.3; exit 3";
|
|
20
|
+
|
|
21
|
+
const OOM_CRASH = "sleep 0.3; kill -9 $$";
|
|
22
|
+
|
|
23
|
+
const RUNNING_CRASH = "echo ready; sleep 0.3; exit 4";
|
|
24
|
+
|
|
25
|
+
const LATE_CRASH = "sleep 1; exit 5";
|
|
26
|
+
|
|
27
|
+
const LAST_LINE = "boom, the game gave up here";
|
|
28
|
+
|
|
29
|
+
const LAST_LINE_CRASH = `echo "${LAST_LINE}"; sleep 0.5; exit 7`;
|
|
30
|
+
|
|
31
|
+
const TIGHT_LAST_LINE_CRASH = `sleep 0.2; echo "${LAST_LINE}"; exit 3`;
|
|
32
|
+
|
|
33
|
+
const INSTANT_EXIT = "false";
|
|
34
|
+
|
|
14
35
|
const silent: BridgeLeveledLogger = Object.assign(() => {}, {
|
|
15
36
|
warn: () => {},
|
|
16
37
|
error: () => {},
|
|
@@ -55,6 +76,46 @@ const wrapperSupervisor = (script: string, stopTimeoutSeconds: number) => {
|
|
|
55
76
|
});
|
|
56
77
|
};
|
|
57
78
|
|
|
79
|
+
const argvSupervisor = (resolveCommand: () => Promise<string[]>, ready: RegExp) => {
|
|
80
|
+
const records: BridgeCrashRecord[] = [];
|
|
81
|
+
|
|
82
|
+
const supervisor = createSupervisor({
|
|
83
|
+
cwd: tmpdir(),
|
|
84
|
+
uid: process.getuid?.() ?? 0,
|
|
85
|
+
gid: process.getgid?.() ?? 0,
|
|
86
|
+
stopTimeoutSeconds: 1,
|
|
87
|
+
ready,
|
|
88
|
+
onLog: silent,
|
|
89
|
+
resolveCommand,
|
|
90
|
+
onExit(record) {
|
|
91
|
+
records.push(record);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
supervisor,
|
|
97
|
+
records,
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const crashSupervisor = (command: () => string, ready: RegExp) => {
|
|
102
|
+
return argvSupervisor(async () => {
|
|
103
|
+
return [
|
|
104
|
+
"bash",
|
|
105
|
+
"-c",
|
|
106
|
+
command(),
|
|
107
|
+
];
|
|
108
|
+
}, ready);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const waitFor = async (settled: () => boolean, timeoutMs: number) => {
|
|
112
|
+
for (let waited = 0; waited < timeoutMs && !settled(); waited += 50) {
|
|
113
|
+
await Bun.sleep(50);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return settled();
|
|
117
|
+
};
|
|
118
|
+
|
|
58
119
|
const startWrapper = async (script: string, stopTimeoutSeconds: number) => {
|
|
59
120
|
const supervisor = wrapperSupervisor(script, stopTimeoutSeconds);
|
|
60
121
|
const started = supervisor.watch(CHILD_PATTERN, 10_000);
|
|
@@ -73,6 +134,41 @@ const startWrapper = async (script: string, stopTimeoutSeconds: number) => {
|
|
|
73
134
|
};
|
|
74
135
|
};
|
|
75
136
|
|
|
137
|
+
const timedSupervisor = (command: () => string, ready: RegExp, stableAfterMs: number) => {
|
|
138
|
+
const records: BridgeCrashRecord[] = [];
|
|
139
|
+
|
|
140
|
+
let stables = 0;
|
|
141
|
+
|
|
142
|
+
const supervisor = createSupervisor({
|
|
143
|
+
cwd: tmpdir(),
|
|
144
|
+
uid: process.getuid?.() ?? 0,
|
|
145
|
+
gid: process.getgid?.() ?? 0,
|
|
146
|
+
stopTimeoutSeconds: 1,
|
|
147
|
+
ready,
|
|
148
|
+
stableAfterMs,
|
|
149
|
+
onLog: silent,
|
|
150
|
+
resolveCommand: async () => {
|
|
151
|
+
return [
|
|
152
|
+
"bash",
|
|
153
|
+
"-c",
|
|
154
|
+
command(),
|
|
155
|
+
];
|
|
156
|
+
},
|
|
157
|
+
onStable() {
|
|
158
|
+
stables += 1;
|
|
159
|
+
},
|
|
160
|
+
onExit(record) {
|
|
161
|
+
records.push(record);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
supervisor,
|
|
167
|
+
records,
|
|
168
|
+
stables: () => stables,
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
76
172
|
describe("createSupervisor", () => {
|
|
77
173
|
test("runs the game outside the bridge's own process group", async () => {
|
|
78
174
|
const { supervisor, pid } = await startWrapper(WRAPPER, 1);
|
|
@@ -108,4 +204,197 @@ describe("createSupervisor", () => {
|
|
|
108
204
|
expect(supervisor.runtime).toBe(BridgeRuntime.Stopped);
|
|
109
205
|
expect(await waitGone(pid, 5000), "the game outlived the SIGKILL escalation").toBe(true);
|
|
110
206
|
}, 30_000);
|
|
207
|
+
|
|
208
|
+
test("does not restart a game that exited before it ever reached its ready line", async () => {
|
|
209
|
+
const { supervisor, records } = crashSupervisor(() => BOOT_CRASH, NEVER_READY);
|
|
210
|
+
|
|
211
|
+
await supervisor.start();
|
|
212
|
+
|
|
213
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
214
|
+
|
|
215
|
+
const record = records.at(0);
|
|
216
|
+
|
|
217
|
+
expect(record?.phase).toBe(BridgeCrashPhase.Booting);
|
|
218
|
+
expect(record?.restarting).toBe(false);
|
|
219
|
+
expect(record?.looping).toBe(false);
|
|
220
|
+
expect(record?.exitCode).toBe(3);
|
|
221
|
+
expect(record?.outOfMemory).toBe(false);
|
|
222
|
+
expect(record?.restarts).toBe(0);
|
|
223
|
+
|
|
224
|
+
await Bun.sleep(3000);
|
|
225
|
+
|
|
226
|
+
expect(records, "the booting crash was retried").toHaveLength(1);
|
|
227
|
+
expect(supervisor.running).toBe(false);
|
|
228
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Crashed);
|
|
229
|
+
expect(supervisor.restarts).toBe(0);
|
|
230
|
+
}, 30_000);
|
|
231
|
+
|
|
232
|
+
test("restarts a game that crashed after it reached its ready line", async () => {
|
|
233
|
+
const { supervisor, records } = crashSupervisor(() => RUNNING_CRASH, READY);
|
|
234
|
+
|
|
235
|
+
await supervisor.start();
|
|
236
|
+
|
|
237
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
238
|
+
|
|
239
|
+
const record = records.at(0);
|
|
240
|
+
|
|
241
|
+
expect(record?.phase).toBe(BridgeCrashPhase.Running);
|
|
242
|
+
expect(record?.restarting).toBe(true);
|
|
243
|
+
expect(record?.looping).toBe(false);
|
|
244
|
+
expect(record?.exitCode).toBe(4);
|
|
245
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Starting);
|
|
246
|
+
|
|
247
|
+
await supervisor.stop();
|
|
248
|
+
}, 30_000);
|
|
249
|
+
|
|
250
|
+
test("flags a game killed with SIGKILL as out of memory", async () => {
|
|
251
|
+
const { supervisor, records } = crashSupervisor(() => OOM_CRASH, NEVER_READY);
|
|
252
|
+
|
|
253
|
+
await supervisor.start();
|
|
254
|
+
|
|
255
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
256
|
+
|
|
257
|
+
const record = records.at(0);
|
|
258
|
+
|
|
259
|
+
expect(record?.outOfMemory).toBe(true);
|
|
260
|
+
expect(record?.exitCode).toBe(137);
|
|
261
|
+
expect(supervisor.outOfMemory).toBe(true);
|
|
262
|
+
|
|
263
|
+
await supervisor.stop();
|
|
264
|
+
}, 30_000);
|
|
265
|
+
|
|
266
|
+
test("trips the breaker on the third running crash inside the window", async () => {
|
|
267
|
+
const { supervisor, records } = crashSupervisor(() => RUNNING_CRASH, READY);
|
|
268
|
+
|
|
269
|
+
await supervisor.start();
|
|
270
|
+
|
|
271
|
+
expect(await waitFor(() => records.length >= 3, 25_000)).toBe(true);
|
|
272
|
+
|
|
273
|
+
const record = records.at(2);
|
|
274
|
+
|
|
275
|
+
expect(record?.looping).toBe(true);
|
|
276
|
+
expect(record?.restarting).toBe(false);
|
|
277
|
+
expect(record?.restarts, "the record carries the count the supervisor reached").toBe(3);
|
|
278
|
+
expect(records.map((entry) => entry.restarts)).toEqual([
|
|
279
|
+
1,
|
|
280
|
+
2,
|
|
281
|
+
3,
|
|
282
|
+
]);
|
|
283
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Crashed);
|
|
284
|
+
expect(supervisor.restarts).toBe(3);
|
|
285
|
+
|
|
286
|
+
await supervisor.stop();
|
|
287
|
+
}, 40_000);
|
|
288
|
+
|
|
289
|
+
test("launches again when start is asked after a booting crash", async () => {
|
|
290
|
+
let script = BOOT_CRASH;
|
|
291
|
+
|
|
292
|
+
const { supervisor, records } = crashSupervisor(() => script, NEVER_READY);
|
|
293
|
+
|
|
294
|
+
await supervisor.start();
|
|
295
|
+
|
|
296
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
297
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Crashed);
|
|
298
|
+
|
|
299
|
+
script = WRAPPER;
|
|
300
|
+
|
|
301
|
+
await supervisor.start();
|
|
302
|
+
|
|
303
|
+
expect(supervisor.running).toBe(true);
|
|
304
|
+
|
|
305
|
+
await supervisor.stop();
|
|
306
|
+
|
|
307
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Stopped);
|
|
308
|
+
}, 30_000);
|
|
309
|
+
|
|
310
|
+
test("carries the last line the game printed into the crash record", async () => {
|
|
311
|
+
const { supervisor, records } = crashSupervisor(() => LAST_LINE_CRASH, NEVER_READY);
|
|
312
|
+
|
|
313
|
+
await supervisor.start();
|
|
314
|
+
|
|
315
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
316
|
+
expect(records.at(0)?.lastLine).toBe(LAST_LINE);
|
|
317
|
+
}, 30_000);
|
|
318
|
+
|
|
319
|
+
test("carries the last line even when the game exits with no pause after printing it", async () => {
|
|
320
|
+
const { supervisor, records } = crashSupervisor(() => TIGHT_LAST_LINE_CRASH, NEVER_READY);
|
|
321
|
+
|
|
322
|
+
await supervisor.start();
|
|
323
|
+
|
|
324
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
325
|
+
expect(records.at(0)?.lastLine, "the crash record was built before the pumps drained").toBe(LAST_LINE);
|
|
326
|
+
expect(records.at(0)?.exitCode).toBe(3);
|
|
327
|
+
}, 30_000);
|
|
328
|
+
|
|
329
|
+
test("leaves no dead child behind when the game exits the instant it launches", async () => {
|
|
330
|
+
let adopted = 0;
|
|
331
|
+
|
|
332
|
+
for (let attempt = 0; attempt < 40 && adopted < 20; attempt += 1) {
|
|
333
|
+
const { supervisor, records } = argvSupervisor(async () => {
|
|
334
|
+
return [
|
|
335
|
+
INSTANT_EXIT,
|
|
336
|
+
];
|
|
337
|
+
}, NEVER_READY);
|
|
338
|
+
|
|
339
|
+
const launched = await supervisor.start().then(
|
|
340
|
+
() => true,
|
|
341
|
+
() => false,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
if (!launched) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
adopted += 1;
|
|
349
|
+
|
|
350
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
351
|
+
|
|
352
|
+
await Bun.sleep(100);
|
|
353
|
+
|
|
354
|
+
expect(records, "the instant exit was reported more than once").toHaveLength(1);
|
|
355
|
+
expect(records.at(0)?.exitCode).toBe(1);
|
|
356
|
+
expect(records.at(0)?.outOfMemory).toBe(false);
|
|
357
|
+
expect(supervisor.running, "the supervisor kept a child that had already exited").toBe(false);
|
|
358
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Crashed);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
expect(adopted, "the game never survived long enough to be adopted as the child").toBeGreaterThan(0);
|
|
362
|
+
}, 30_000);
|
|
363
|
+
|
|
364
|
+
test("treats a run that outlived the stable window as running even when the ready line never came", async () => {
|
|
365
|
+
const { supervisor, records, stables } = timedSupervisor(() => LATE_CRASH, NEVER_READY, 300);
|
|
366
|
+
|
|
367
|
+
await supervisor.start();
|
|
368
|
+
|
|
369
|
+
expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
|
|
370
|
+
|
|
371
|
+
const record = records.at(0);
|
|
372
|
+
|
|
373
|
+
expect(record?.phase).toBe(BridgeCrashPhase.Running);
|
|
374
|
+
expect(record?.restarting).toBe(true);
|
|
375
|
+
expect(record?.exitCode).toBe(5);
|
|
376
|
+
expect(stables(), "the run was reported stable more than once").toBe(1);
|
|
377
|
+
|
|
378
|
+
await supervisor.stop();
|
|
379
|
+
}, 30_000);
|
|
380
|
+
|
|
381
|
+
test("opens a new episode when start is asked after the breaker tripped", async () => {
|
|
382
|
+
let script = RUNNING_CRASH;
|
|
383
|
+
|
|
384
|
+
const { supervisor, records } = crashSupervisor(() => script, READY);
|
|
385
|
+
|
|
386
|
+
await supervisor.start();
|
|
387
|
+
|
|
388
|
+
expect(await waitFor(() => records.length >= 3, 25_000)).toBe(true);
|
|
389
|
+
expect(supervisor.restarts).toBe(3);
|
|
390
|
+
|
|
391
|
+
script = WRAPPER;
|
|
392
|
+
|
|
393
|
+
await supervisor.start();
|
|
394
|
+
|
|
395
|
+
expect(supervisor.restarts, "the cumulative count survived an explicit start").toBe(0);
|
|
396
|
+
expect(supervisor.running).toBe(true);
|
|
397
|
+
|
|
398
|
+
await supervisor.stop();
|
|
399
|
+
}, 40_000);
|
|
111
400
|
});
|