@serverkgg/bridge 0.0.14 → 0.0.16
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/cli/validate.test.ts +194 -2
- package/src/cli/validate.ts +83 -1
- package/src/guides/variables.ts +1 -0
- package/src/panel/bridgePanel.ts +11 -0
- package/src/panel/bridgePlace.ts +4 -0
- package/src/panel/index.ts +1 -0
- package/src/protocol/bridgeCrash.ts +83 -0
- package/src/protocol/bridgeFrame.ts +1 -0
- package/src/protocol/index.ts +1 -0
- package/src/rcon/rconAccess.test.ts +20 -2
- package/src/rcon/rconAccess.ts +9 -1
- package/src/runtime/bridgeDriver.test.ts +145 -0
- package/src/runtime/bridgeDriver.ts +44 -2
- package/src/runtime/bridgeEvents.test.ts +411 -9
- package/src/runtime/bridgeEvents.ts +53 -3
- package/src/runtime/bridgeRegistry.test.ts +96 -0
- package/src/runtime/bridgeRegistry.ts +33 -1
- package/src/runtime/bridgeServer.test.ts +1 -0
- package/src/runtime/bridgeSupervisor.test.ts +289 -0
- package/src/runtime/bridgeSupervisor.ts +166 -9
- package/src/runtime/serve.ts +10 -0
- package/src/steam/avatars.test.ts +306 -0
- package/src/steam/avatars.ts +107 -0
- package/src/steam/index.ts +1 -0
- package/src/wire/panel.test.ts +79 -0
- package/src/wire/panel.ts +11 -0
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import type { BridgeValues } from "../core";
|
|
2
2
|
import type { BridgeEventsModule } from "../modules";
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
BRIDGE_CRASH_EVIDENCE,
|
|
5
|
+
BRIDGE_CRASH_FLAG_NO,
|
|
6
|
+
BRIDGE_CRASH_FLAG_YES,
|
|
7
|
+
BRIDGE_CRASH_KEYS,
|
|
8
|
+
BridgeCrashCause,
|
|
9
|
+
type BridgeCrashRecord,
|
|
10
|
+
type BridgeEvent,
|
|
11
|
+
BridgeEventName,
|
|
12
|
+
isCrashEvidence,
|
|
13
|
+
} from "../protocol";
|
|
4
14
|
import { stripAnsi } from "../utils";
|
|
5
15
|
import type { BridgeSupervisor } from "./bridgeSupervisor";
|
|
6
16
|
|
|
@@ -8,6 +18,7 @@ const EVENT_BUFFER = 500;
|
|
|
8
18
|
|
|
9
19
|
export const createEventRing = (events: BridgeEventsModule | undefined, supervisor: BridgeSupervisor) => {
|
|
10
20
|
const ring: BridgeEvent[] = [];
|
|
21
|
+
const evidence = new Map<string, BridgeValues | null>();
|
|
11
22
|
|
|
12
23
|
let sequence = 0;
|
|
13
24
|
|
|
@@ -25,6 +36,16 @@ export const createEventRing = (events: BridgeEventsModule | undefined, supervis
|
|
|
25
36
|
}
|
|
26
37
|
};
|
|
27
38
|
|
|
39
|
+
const observe = (event: string, payload: BridgeValues | null) => {
|
|
40
|
+
if (isCrashEvidence(event)) {
|
|
41
|
+
evidence.set(event, payload);
|
|
42
|
+
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
push(event, payload);
|
|
47
|
+
};
|
|
48
|
+
|
|
28
49
|
if (events) {
|
|
29
50
|
supervisor.subscribe((entry) => {
|
|
30
51
|
const line = stripAnsi(entry.line);
|
|
@@ -48,7 +69,7 @@ export const createEventRing = (events: BridgeEventsModule | undefined, supervis
|
|
|
48
69
|
}
|
|
49
70
|
: null;
|
|
50
71
|
|
|
51
|
-
|
|
72
|
+
observe(pattern.emit, groups);
|
|
52
73
|
}
|
|
53
74
|
});
|
|
54
75
|
}
|
|
@@ -64,7 +85,36 @@ export const createEventRing = (events: BridgeEventsModule | undefined, supervis
|
|
|
64
85
|
},
|
|
65
86
|
|
|
66
87
|
emit(event: string, payload?: BridgeValues) {
|
|
67
|
-
|
|
88
|
+
observe(event, payload ?? null);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
crash(record: BridgeCrashRecord) {
|
|
92
|
+
const winner = record.outOfMemory ? undefined : BRIDGE_CRASH_EVIDENCE.find((entry) => evidence.has(entry.name));
|
|
93
|
+
|
|
94
|
+
const cause = record.outOfMemory ? BridgeCrashCause.OomKill : (winner?.cause ?? BridgeCrashCause.Unknown);
|
|
95
|
+
|
|
96
|
+
const payload: BridgeValues = {
|
|
97
|
+
...(winner ? evidence.get(winner.name) : null),
|
|
98
|
+
[BRIDGE_CRASH_KEYS.cause]: cause,
|
|
99
|
+
[BRIDGE_CRASH_KEYS.phase]: record.phase,
|
|
100
|
+
[BRIDGE_CRASH_KEYS.exitCode]: String(record.exitCode),
|
|
101
|
+
[BRIDGE_CRASH_KEYS.restarting]: record.restarting ? BRIDGE_CRASH_FLAG_YES : BRIDGE_CRASH_FLAG_NO,
|
|
102
|
+
[BRIDGE_CRASH_KEYS.looping]: record.looping ? BRIDGE_CRASH_FLAG_YES : BRIDGE_CRASH_FLAG_NO,
|
|
103
|
+
[BRIDGE_CRASH_KEYS.restarts]: String(record.restarts),
|
|
104
|
+
[BRIDGE_CRASH_KEYS.uptimeSeconds]: String(record.uptimeSeconds),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (cause === BridgeCrashCause.Unknown && record.lastLine !== null) {
|
|
108
|
+
payload[BRIDGE_CRASH_KEYS.lastLine] = record.lastLine;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
push(BridgeEventName.ServerCrashed, payload);
|
|
112
|
+
|
|
113
|
+
evidence.clear();
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
discard() {
|
|
117
|
+
evidence.clear();
|
|
68
118
|
},
|
|
69
119
|
|
|
70
120
|
drain(cursor: number) {
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { BridgeAction, BridgeKind, type BridgeRow, BridgeSetupStepKind, type BridgeValues } from "../core";
|
|
4
4
|
import type { BridgeDriver } from "../modules";
|
|
5
|
+
import { BridgeLayout } from "../panel";
|
|
5
6
|
import { type BridgeConfig, BridgeConfirm, BridgeEventName } from "../protocol";
|
|
6
7
|
import { createContext } from "./bridgeContext";
|
|
7
8
|
import { createEventRing } from "./bridgeEvents";
|
|
@@ -296,6 +297,101 @@ describe("collection row actions", () => {
|
|
|
296
297
|
});
|
|
297
298
|
});
|
|
298
299
|
|
|
300
|
+
describe("offline row actions", () => {
|
|
301
|
+
const rosterDriver = (
|
|
302
|
+
offline: boolean,
|
|
303
|
+
list: () => Promise<BridgeRow[]>,
|
|
304
|
+
ban: (row: BridgeRow) => void,
|
|
305
|
+
): BridgeDriver => ({
|
|
306
|
+
...baseDriver(),
|
|
307
|
+
panel: {
|
|
308
|
+
tabs: [
|
|
309
|
+
{
|
|
310
|
+
id: "players",
|
|
311
|
+
title: {
|
|
312
|
+
ar: "اللاعبين",
|
|
313
|
+
en: "Players",
|
|
314
|
+
},
|
|
315
|
+
sections: [
|
|
316
|
+
{
|
|
317
|
+
layout: BridgeLayout.Table,
|
|
318
|
+
id: "online",
|
|
319
|
+
module: "players",
|
|
320
|
+
columns: [
|
|
321
|
+
{
|
|
322
|
+
key: "name",
|
|
323
|
+
label: {
|
|
324
|
+
ar: "الاسم",
|
|
325
|
+
en: "Name",
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
actions: [
|
|
330
|
+
{
|
|
331
|
+
id: "ban",
|
|
332
|
+
label: {
|
|
333
|
+
ar: "حظر",
|
|
334
|
+
en: "Ban",
|
|
335
|
+
},
|
|
336
|
+
offline,
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
},
|
|
344
|
+
modules: {
|
|
345
|
+
players: {
|
|
346
|
+
kind: BridgeKind.Collection,
|
|
347
|
+
list,
|
|
348
|
+
actions: {
|
|
349
|
+
ban: async (_context, row) => {
|
|
350
|
+
ban(row);
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("hands an offline action the id alone when the row is not listed", async () => {
|
|
358
|
+
const seen: BridgeRow[] = [];
|
|
359
|
+
const registry = registryFor(
|
|
360
|
+
rosterDriver(
|
|
361
|
+
true,
|
|
362
|
+
async () => [],
|
|
363
|
+
(row) => seen.push(row),
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
await registry.invoke("players", "ban", {
|
|
368
|
+
id: "steam_1",
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
expect(seen).toEqual([
|
|
372
|
+
{
|
|
373
|
+
id: "steam_1",
|
|
374
|
+
},
|
|
375
|
+
]);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("still refuses an unlisted row for an action that is not offline", async () => {
|
|
379
|
+
const registry = registryFor(
|
|
380
|
+
rosterDriver(
|
|
381
|
+
false,
|
|
382
|
+
async () => [],
|
|
383
|
+
() => undefined,
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
await expect(
|
|
388
|
+
registry.invoke("players", "ban", {
|
|
389
|
+
id: "steam_1",
|
|
390
|
+
}),
|
|
391
|
+
).rejects.toThrow('module "players" has no row "steam_1"');
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
299
395
|
describe("preview modules", () => {
|
|
300
396
|
test("lists the preview action in the manifest", () => {
|
|
301
397
|
const registry = registryFor({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { BridgeContext } from "../context";
|
|
2
2
|
import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues, isReadAction } from "../core";
|
|
3
3
|
import type { BridgeDriver, BridgePanelModule, BridgeSetupModule, BridgeTerminalModule } from "../modules";
|
|
4
|
+
import { BridgeLayout } from "../panel";
|
|
4
5
|
import {
|
|
5
6
|
BRIDGE_VERSION,
|
|
6
7
|
type BridgeManifest,
|
|
@@ -148,6 +149,29 @@ const asPage = (args: BridgeValues) => {
|
|
|
148
149
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
149
150
|
};
|
|
150
151
|
|
|
152
|
+
const offlineActionsOf = (driver: BridgeDriver): Map<string, Set<string>> => {
|
|
153
|
+
const offline = new Map<string, Set<string>>();
|
|
154
|
+
|
|
155
|
+
for (const tab of driver.panel?.tabs ?? []) {
|
|
156
|
+
for (const section of tab.sections) {
|
|
157
|
+
if (section.layout !== BridgeLayout.Table && section.layout !== BridgeLayout.Cards) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const action of section.actions ?? []) {
|
|
162
|
+
if (action.offline === true) {
|
|
163
|
+
const actions = offline.get(section.module) ?? new Set<string>();
|
|
164
|
+
|
|
165
|
+
actions.add(action.id);
|
|
166
|
+
offline.set(section.module, actions);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return offline;
|
|
173
|
+
};
|
|
174
|
+
|
|
151
175
|
export const createRegistry = (
|
|
152
176
|
driver: BridgeDriver,
|
|
153
177
|
context: BridgeContext,
|
|
@@ -157,6 +181,7 @@ export const createRegistry = (
|
|
|
157
181
|
const cache = new Map<string, CacheEntry>();
|
|
158
182
|
const inflight = new Map<string, Promise<unknown>>();
|
|
159
183
|
const modules = driver.modules ?? {};
|
|
184
|
+
const offlineActions = offlineActionsOf(driver);
|
|
160
185
|
|
|
161
186
|
for (const [id, module] of Object.entries(modules)) {
|
|
162
187
|
if (module.kind !== BridgeKind.Actions && module.kind !== BridgeKind.Detail) {
|
|
@@ -297,7 +322,14 @@ export const createRegistry = (
|
|
|
297
322
|
if (rowAction) {
|
|
298
323
|
const rows = await module.list(context);
|
|
299
324
|
const id = asString(args, "id");
|
|
300
|
-
const
|
|
325
|
+
const listed = rows.find((entry) => entry.id === id);
|
|
326
|
+
const row =
|
|
327
|
+
listed
|
|
328
|
+
?? (offlineActions.get(name)?.has(action)
|
|
329
|
+
? {
|
|
330
|
+
id,
|
|
331
|
+
}
|
|
332
|
+
: undefined);
|
|
301
333
|
|
|
302
334
|
if (!row) {
|
|
303
335
|
throw new BridgeDriverError(`module "${name}" has no row "${id}"`);
|
|
@@ -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
|
});
|