@serverkgg/bridge 0.0.15 → 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/protocol/bridgeCrash.ts +83 -0
- package/src/protocol/bridgeFrame.ts +1 -0
- package/src/protocol/index.ts +1 -0
- package/src/runtime/bridgeEvents.test.ts +411 -9
- package/src/runtime/bridgeEvents.ts +53 -3
- 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/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { BridgeEventName } from "./bridgeEvents";
|
|
2
|
+
|
|
3
|
+
export enum BridgeCrashCause {
|
|
4
|
+
OomKill = "oom-kill",
|
|
5
|
+
OutOfMemory = "out-of-memory",
|
|
6
|
+
WrongJavaVersion = "wrong-java",
|
|
7
|
+
PortBusy = "port-busy",
|
|
8
|
+
WorldCorrupt = "world-corrupt",
|
|
9
|
+
MissingDependency = "missing-dependency",
|
|
10
|
+
ModCrashed = "mod-crashed",
|
|
11
|
+
Unknown = "unknown",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export enum BridgeCrashPhase {
|
|
15
|
+
Booting = "booting",
|
|
16
|
+
Running = "running",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BridgeCrashEvidence {
|
|
20
|
+
name: BridgeEventName;
|
|
21
|
+
cause: BridgeCrashCause;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
|
|
25
|
+
{
|
|
26
|
+
name: BridgeEventName.ServerCrashed,
|
|
27
|
+
cause: BridgeCrashCause.OutOfMemory,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: BridgeEventName.WrongJavaVersion,
|
|
31
|
+
cause: BridgeCrashCause.WrongJavaVersion,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: BridgeEventName.PortBindFailed,
|
|
35
|
+
cause: BridgeCrashCause.PortBusy,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: BridgeEventName.WorldCorrupt,
|
|
39
|
+
cause: BridgeCrashCause.WorldCorrupt,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: BridgeEventName.MissingDependency,
|
|
43
|
+
cause: BridgeCrashCause.MissingDependency,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: BridgeEventName.ModCrashed,
|
|
47
|
+
cause: BridgeCrashCause.ModCrashed,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const BRIDGE_CRASH_EVIDENCE_NAMES: ReadonlySet<string> = new Set(BRIDGE_CRASH_EVIDENCE.map((entry) => entry.name));
|
|
52
|
+
|
|
53
|
+
export const isCrashEvidence = (name: string): boolean => {
|
|
54
|
+
return BRIDGE_CRASH_EVIDENCE_NAMES.has(name);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const BRIDGE_CRASH_KEYS = {
|
|
58
|
+
cause: "cause",
|
|
59
|
+
phase: "phase",
|
|
60
|
+
exitCode: "exitCode",
|
|
61
|
+
restarting: "restarting",
|
|
62
|
+
looping: "looping",
|
|
63
|
+
restarts: "restarts",
|
|
64
|
+
uptimeSeconds: "uptimeSeconds",
|
|
65
|
+
lastLine: "lastLine",
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
export const BRIDGE_CRASH_FLAG_YES = "yes";
|
|
69
|
+
|
|
70
|
+
export const BRIDGE_CRASH_FLAG_NO = "no";
|
|
71
|
+
|
|
72
|
+
export const BRIDGE_CRASH_LINE_LIMIT = 256;
|
|
73
|
+
|
|
74
|
+
export interface BridgeCrashRecord {
|
|
75
|
+
exitCode: number;
|
|
76
|
+
outOfMemory: boolean;
|
|
77
|
+
phase: BridgeCrashPhase;
|
|
78
|
+
uptimeSeconds: number;
|
|
79
|
+
restarting: boolean;
|
|
80
|
+
looping: boolean;
|
|
81
|
+
restarts: number;
|
|
82
|
+
lastLine: string | null;
|
|
83
|
+
}
|
package/src/protocol/index.ts
CHANGED
|
@@ -4,8 +4,8 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { BridgeKind } from "../core";
|
|
6
6
|
import type { BridgeEventsModule } from "../modules";
|
|
7
|
-
import { BridgeEventName } from "../protocol";
|
|
8
|
-
import { createEventRing } from "./bridgeEvents";
|
|
7
|
+
import { BridgeCrashPhase, type BridgeCrashRecord, BridgeEventName } from "../protocol";
|
|
8
|
+
import { type BridgeEventRing, createEventRing } from "./bridgeEvents";
|
|
9
9
|
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
10
10
|
import { createSupervisor } from "./bridgeSupervisor";
|
|
11
11
|
|
|
@@ -13,6 +13,10 @@ const RESET = "\u001B[m";
|
|
|
13
13
|
|
|
14
14
|
const PLAYER_JOINED = /^\[World\|(?<world>[^\]]+)\] Player '(?<player>[^']+)' joined world (?<target>\S+)$/;
|
|
15
15
|
|
|
16
|
+
const MOD_CRASHED = /^Exception in mod (?<mod>\S+) from (?<jar>\S+)$/;
|
|
17
|
+
|
|
18
|
+
const READY_LINE = /^ready$/;
|
|
19
|
+
|
|
16
20
|
const silent: BridgeLeveledLogger = Object.assign(() => {}, {
|
|
17
21
|
warn: () => {},
|
|
18
22
|
error: () => {},
|
|
@@ -25,16 +29,18 @@ const events: BridgeEventsModule = {
|
|
|
25
29
|
match: PLAYER_JOINED,
|
|
26
30
|
emit: BridgeEventName.PlayerJoined,
|
|
27
31
|
},
|
|
32
|
+
{
|
|
33
|
+
match: MOD_CRASHED,
|
|
34
|
+
emit: BridgeEventName.ModCrashed,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
emits: [
|
|
38
|
+
BridgeEventName.WorldCorrupt,
|
|
28
39
|
],
|
|
29
40
|
};
|
|
30
41
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const path = join(dir, "line.txt");
|
|
34
|
-
|
|
35
|
-
await Bun.write(path, `${line}\n`);
|
|
36
|
-
|
|
37
|
-
const supervisor = createSupervisor({
|
|
42
|
+
const lineSupervisor = (dir: string, path: string) => {
|
|
43
|
+
return createSupervisor({
|
|
38
44
|
cwd: dir,
|
|
39
45
|
uid: process.getuid?.() ?? 0,
|
|
40
46
|
gid: process.getgid?.() ?? 0,
|
|
@@ -47,7 +53,15 @@ const emittedFor = async (line: string) => {
|
|
|
47
53
|
path,
|
|
48
54
|
],
|
|
49
55
|
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const emittedFor = async (line: string) => {
|
|
59
|
+
const dir = await mkdtemp(join(tmpdir(), "bridge-events-"));
|
|
60
|
+
const path = join(dir, "line.txt");
|
|
50
61
|
|
|
62
|
+
await Bun.write(path, `${line}\n`);
|
|
63
|
+
|
|
64
|
+
const supervisor = lineSupervisor(dir, path);
|
|
51
65
|
const ring = createEventRing(events, supervisor);
|
|
52
66
|
|
|
53
67
|
await supervisor.start();
|
|
@@ -71,6 +85,72 @@ const emittedFor = async (line: string) => {
|
|
|
71
85
|
}
|
|
72
86
|
};
|
|
73
87
|
|
|
88
|
+
const crashAfter = async (line: string, record: BridgeCrashRecord) => {
|
|
89
|
+
const dir = await mkdtemp(join(tmpdir(), "bridge-events-"));
|
|
90
|
+
const path = join(dir, "line.txt");
|
|
91
|
+
|
|
92
|
+
await Bun.write(path, `${line}\n`);
|
|
93
|
+
|
|
94
|
+
const supervisor = lineSupervisor(dir, path);
|
|
95
|
+
const ring = createEventRing(events, supervisor);
|
|
96
|
+
|
|
97
|
+
await supervisor.start();
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
for (let waited = 0; waited < 5000; waited += 50) {
|
|
101
|
+
if (supervisor.tail(50).includes(line)) {
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await Bun.sleep(50);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const held = ring.drain(0).events;
|
|
109
|
+
|
|
110
|
+
ring.crash(record);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
held,
|
|
114
|
+
events: ring.drain(0).events,
|
|
115
|
+
};
|
|
116
|
+
} finally {
|
|
117
|
+
await supervisor.stop();
|
|
118
|
+
await rm(dir, {
|
|
119
|
+
force: true,
|
|
120
|
+
recursive: true,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const idleRing = () => {
|
|
126
|
+
const supervisor = createSupervisor({
|
|
127
|
+
cwd: tmpdir(),
|
|
128
|
+
uid: process.getuid?.() ?? 0,
|
|
129
|
+
gid: process.getgid?.() ?? 0,
|
|
130
|
+
stopTimeoutSeconds: 1,
|
|
131
|
+
onLog: silent,
|
|
132
|
+
resolveCommand: async () => [
|
|
133
|
+
"true",
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return createEventRing(events, supervisor);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const crashRecord = (overrides: Partial<BridgeCrashRecord> = {}): BridgeCrashRecord => {
|
|
141
|
+
return {
|
|
142
|
+
exitCode: 1,
|
|
143
|
+
outOfMemory: false,
|
|
144
|
+
phase: BridgeCrashPhase.Running,
|
|
145
|
+
uptimeSeconds: 12,
|
|
146
|
+
restarting: false,
|
|
147
|
+
looping: false,
|
|
148
|
+
restarts: 0,
|
|
149
|
+
lastLine: null,
|
|
150
|
+
...overrides,
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
74
154
|
const joined = {
|
|
75
155
|
seq: 1,
|
|
76
156
|
event: BridgeEventName.PlayerJoined,
|
|
@@ -93,4 +173,326 @@ describe("matching a game line against the driver patterns", () => {
|
|
|
93
173
|
joined,
|
|
94
174
|
]);
|
|
95
175
|
});
|
|
176
|
+
|
|
177
|
+
test("holds a matched diagnostic line as evidence and spends it on the crash record", async () => {
|
|
178
|
+
const { held, events: drained } = await crashAfter(
|
|
179
|
+
"Exception in mod journeymap from journeymap-5.9.jar",
|
|
180
|
+
crashRecord({
|
|
181
|
+
exitCode: 1,
|
|
182
|
+
phase: BridgeCrashPhase.Booting,
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
expect(held, "the diagnostic line reached the ring as a standalone event").toEqual([]);
|
|
187
|
+
expect(drained).toEqual([
|
|
188
|
+
{
|
|
189
|
+
seq: 1,
|
|
190
|
+
event: BridgeEventName.ServerCrashed,
|
|
191
|
+
payload: {
|
|
192
|
+
mod: "journeymap",
|
|
193
|
+
jar: "journeymap-5.9.jar",
|
|
194
|
+
cause: "mod-crashed",
|
|
195
|
+
phase: "booting",
|
|
196
|
+
exitCode: "1",
|
|
197
|
+
restarting: "no",
|
|
198
|
+
looping: "no",
|
|
199
|
+
restarts: "0",
|
|
200
|
+
uptimeSeconds: "12",
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
]);
|
|
204
|
+
}, 20_000);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("the crash record the ring builds", () => {
|
|
208
|
+
test("holds evidence out of the ring and lets every other event drain", () => {
|
|
209
|
+
const ring = idleRing();
|
|
210
|
+
|
|
211
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
212
|
+
jar: "broken.jar",
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
ring.emit(BridgeEventName.TickLagging, {
|
|
216
|
+
ms: 900,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(ring.drain(0).events.map((entry) => entry.event)).toEqual([
|
|
220
|
+
BridgeEventName.TickLagging,
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("prefers the oom kill over any evidence it holds", () => {
|
|
225
|
+
const ring = idleRing();
|
|
226
|
+
|
|
227
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
228
|
+
jar: "broken.jar",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
ring.crash(
|
|
232
|
+
crashRecord({
|
|
233
|
+
exitCode: 137,
|
|
234
|
+
outOfMemory: true,
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(ring.drain(0).events.at(0)?.payload).toEqual({
|
|
239
|
+
cause: "oom-kill",
|
|
240
|
+
phase: "running",
|
|
241
|
+
exitCode: "137",
|
|
242
|
+
restarting: "no",
|
|
243
|
+
looping: "no",
|
|
244
|
+
restarts: "0",
|
|
245
|
+
uptimeSeconds: "12",
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("follows the evidence precedence when more than one name is held", () => {
|
|
250
|
+
const ring = idleRing();
|
|
251
|
+
|
|
252
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
253
|
+
jar: "broken.jar",
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
ring.emit(BridgeEventName.WrongJavaVersion, {
|
|
257
|
+
detail: "class file version 65",
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
ring.crash(crashRecord());
|
|
261
|
+
|
|
262
|
+
expect(ring.drain(0).events.at(0)?.payload).toEqual({
|
|
263
|
+
detail: "class file version 65",
|
|
264
|
+
cause: "wrong-java",
|
|
265
|
+
phase: "running",
|
|
266
|
+
exitCode: "1",
|
|
267
|
+
restarting: "no",
|
|
268
|
+
looping: "no",
|
|
269
|
+
restarts: "0",
|
|
270
|
+
uptimeSeconds: "12",
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("lets the out of memory log pattern outrank the java version", () => {
|
|
275
|
+
const ring = idleRing();
|
|
276
|
+
|
|
277
|
+
ring.emit(BridgeEventName.WrongJavaVersion, {
|
|
278
|
+
detail: "class file version 65",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
ring.emit(BridgeEventName.ServerCrashed, {
|
|
282
|
+
error: "java.lang.OutOfMemoryError",
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
ring.crash(crashRecord());
|
|
286
|
+
|
|
287
|
+
expect(ring.drain(0).events.at(0)?.payload).toMatchObject({
|
|
288
|
+
cause: "out-of-memory",
|
|
289
|
+
error: "java.lang.OutOfMemoryError",
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("never lets the evidence overwrite the record keys", () => {
|
|
294
|
+
const ring = idleRing();
|
|
295
|
+
|
|
296
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
297
|
+
cause: "not this one",
|
|
298
|
+
phase: "not this one either",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
ring.crash(
|
|
302
|
+
crashRecord({
|
|
303
|
+
phase: BridgeCrashPhase.Booting,
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
expect(ring.drain(0).events.at(0)?.payload).toMatchObject({
|
|
308
|
+
cause: "mod-crashed",
|
|
309
|
+
phase: "booting",
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("clears the evidence once it is spent", () => {
|
|
314
|
+
const ring = idleRing();
|
|
315
|
+
|
|
316
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
317
|
+
jar: "broken.jar",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
ring.crash(crashRecord());
|
|
321
|
+
|
|
322
|
+
const first = ring.drain(0);
|
|
323
|
+
|
|
324
|
+
ring.crash(crashRecord());
|
|
325
|
+
|
|
326
|
+
expect(first.events.at(0)?.payload).toMatchObject({
|
|
327
|
+
cause: "mod-crashed",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(ring.drain(first.cursor).events.at(0)?.payload).toMatchObject({
|
|
331
|
+
cause: "unknown",
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("carries the last line only when nothing explains the crash", () => {
|
|
336
|
+
const ring = idleRing();
|
|
337
|
+
|
|
338
|
+
ring.emit(BridgeEventName.ModCrashed, {
|
|
339
|
+
jar: "broken.jar",
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
ring.crash(
|
|
343
|
+
crashRecord({
|
|
344
|
+
lastLine: "explained, so this line is noise",
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const explained = ring.drain(0);
|
|
349
|
+
|
|
350
|
+
ring.crash(
|
|
351
|
+
crashRecord({
|
|
352
|
+
lastLine: "Segmentation fault",
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expect(explained.events.at(0)?.payload).not.toHaveProperty("lastLine");
|
|
357
|
+
expect(ring.drain(explained.cursor).events.at(0)?.payload).toMatchObject({
|
|
358
|
+
cause: "unknown",
|
|
359
|
+
lastLine: "Segmentation fault",
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("keeps listing the evidence names the driver declares", () => {
|
|
364
|
+
expect(idleRing().names()).toEqual([
|
|
365
|
+
BridgeEventName.PlayerJoined,
|
|
366
|
+
BridgeEventName.ModCrashed,
|
|
367
|
+
BridgeEventName.WorldCorrupt,
|
|
368
|
+
]);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const EVIDENCE_LINE = "Exception in mod journeymap from journeymap-5.9.jar";
|
|
373
|
+
|
|
374
|
+
const EVIDENCE_BEFORE_READY = `echo "${EVIDENCE_LINE}"; sleep 0.3; echo ready; sleep 0.5; exit 4`;
|
|
375
|
+
|
|
376
|
+
const EVIDENCE_AFTER_READY = `echo ready; sleep 0.3; echo "${EVIDENCE_LINE}"; sleep 0.5; exit 4`;
|
|
377
|
+
|
|
378
|
+
const EVIDENCE_THEN_IDLE = `echo "${EVIDENCE_LINE}"; sleep 300`;
|
|
379
|
+
|
|
380
|
+
const wired = async (script: string, ready?: RegExp) => {
|
|
381
|
+
const dir = await mkdtemp(join(tmpdir(), "bridge-events-"));
|
|
382
|
+
|
|
383
|
+
const supervisor = createSupervisor({
|
|
384
|
+
cwd: dir,
|
|
385
|
+
uid: process.getuid?.() ?? 0,
|
|
386
|
+
gid: process.getgid?.() ?? 0,
|
|
387
|
+
stopTimeoutSeconds: 1,
|
|
388
|
+
ready,
|
|
389
|
+
onLog: silent,
|
|
390
|
+
resolveCommand: async () => [
|
|
391
|
+
"bash",
|
|
392
|
+
"-c",
|
|
393
|
+
script,
|
|
394
|
+
],
|
|
395
|
+
onStable() {
|
|
396
|
+
ring.discard();
|
|
397
|
+
},
|
|
398
|
+
onStopped() {
|
|
399
|
+
ring.discard();
|
|
400
|
+
},
|
|
401
|
+
onExit(record) {
|
|
402
|
+
ring.crash(record);
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const ring = createEventRing(events, supervisor);
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
supervisor,
|
|
410
|
+
ring,
|
|
411
|
+
async cleanup() {
|
|
412
|
+
await supervisor.stop();
|
|
413
|
+
await rm(dir, {
|
|
414
|
+
force: true,
|
|
415
|
+
recursive: true,
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const crashedEvent = async (ring: BridgeEventRing) => {
|
|
422
|
+
for (let waited = 0; waited < 10_000; waited += 50) {
|
|
423
|
+
const found = ring.drain(0).events.find((entry) => entry.event === BridgeEventName.ServerCrashed);
|
|
424
|
+
|
|
425
|
+
if (found) {
|
|
426
|
+
return found;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await Bun.sleep(50);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return null;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
describe("the evidence a run may spend", () => {
|
|
436
|
+
test("spends nothing printed before the run became stable on a running crash", async () => {
|
|
437
|
+
const wiring = await wired(EVIDENCE_BEFORE_READY, READY_LINE);
|
|
438
|
+
|
|
439
|
+
await wiring.supervisor.start();
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
const crashed = await crashedEvent(wiring.ring);
|
|
443
|
+
|
|
444
|
+
expect(crashed?.payload).toMatchObject({
|
|
445
|
+
cause: "unknown",
|
|
446
|
+
phase: "running",
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
expect(crashed?.payload).not.toHaveProperty("jar");
|
|
450
|
+
} finally {
|
|
451
|
+
await wiring.cleanup();
|
|
452
|
+
}
|
|
453
|
+
}, 20_000);
|
|
454
|
+
|
|
455
|
+
test("spends what the run printed after it became stable", async () => {
|
|
456
|
+
const wiring = await wired(EVIDENCE_AFTER_READY, READY_LINE);
|
|
457
|
+
|
|
458
|
+
await wiring.supervisor.start();
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
const crashed = await crashedEvent(wiring.ring);
|
|
462
|
+
|
|
463
|
+
expect(crashed?.payload).toMatchObject({
|
|
464
|
+
cause: "mod-crashed",
|
|
465
|
+
phase: "running",
|
|
466
|
+
jar: "journeymap-5.9.jar",
|
|
467
|
+
});
|
|
468
|
+
} finally {
|
|
469
|
+
await wiring.cleanup();
|
|
470
|
+
}
|
|
471
|
+
}, 20_000);
|
|
472
|
+
|
|
473
|
+
test("drops what it holds when the stop was requested", async () => {
|
|
474
|
+
const wiring = await wired(EVIDENCE_THEN_IDLE);
|
|
475
|
+
|
|
476
|
+
await wiring.supervisor.start();
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
for (let waited = 0; waited < 5000; waited += 50) {
|
|
480
|
+
if (wiring.supervisor.tail(50).includes(EVIDENCE_LINE)) {
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
await Bun.sleep(50);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
await wiring.supervisor.stop();
|
|
488
|
+
|
|
489
|
+
wiring.ring.crash(crashRecord());
|
|
490
|
+
|
|
491
|
+
expect(wiring.ring.drain(0).events.at(0)?.payload).toMatchObject({
|
|
492
|
+
cause: "unknown",
|
|
493
|
+
});
|
|
494
|
+
} finally {
|
|
495
|
+
await wiring.cleanup();
|
|
496
|
+
}
|
|
497
|
+
}, 20_000);
|
|
96
498
|
});
|
|
@@ -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) {
|
|
@@ -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
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BridgeCommandOptions, BridgeReply } from "../context";
|
|
2
2
|
import { BridgeRuntime, BridgeStream } from "../core";
|
|
3
|
+
import { BRIDGE_CRASH_LINE_LIMIT, BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
|
|
3
4
|
import { stripAnsi } from "../utils";
|
|
4
5
|
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
5
6
|
import { errorText } from "./bridgeLog";
|
|
@@ -14,9 +15,11 @@ const KILL_GRACE_MS = 5000;
|
|
|
14
15
|
|
|
15
16
|
const RESTART_DELAY_MS = 2000;
|
|
16
17
|
|
|
17
|
-
const RESTART_WINDOW_MS =
|
|
18
|
+
const RESTART_WINDOW_MS = 600_000;
|
|
18
19
|
|
|
19
|
-
const RESTART_LIMIT =
|
|
20
|
+
const RESTART_LIMIT = 3;
|
|
21
|
+
|
|
22
|
+
const STABLE_UPTIME_MS = 180_000;
|
|
20
23
|
|
|
21
24
|
const GAME_OOM_SCORE_ADJ = 500;
|
|
22
25
|
|
|
@@ -35,7 +38,11 @@ export interface SupervisorOptions {
|
|
|
35
38
|
ready?: RegExp;
|
|
36
39
|
resolveCommand: () => Promise<string[]>;
|
|
37
40
|
gracefulStop?: () => Promise<void>;
|
|
41
|
+
stableAfterMs?: number;
|
|
38
42
|
onReady?: () => void;
|
|
43
|
+
onStable?: () => void;
|
|
44
|
+
onStopped?: () => void;
|
|
45
|
+
onExit?: (record: BridgeCrashRecord) => void;
|
|
39
46
|
onLog: BridgeLeveledLogger;
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -119,6 +126,15 @@ const splitLines = (buffer: string, chunk: string) => {
|
|
|
119
126
|
};
|
|
120
127
|
};
|
|
121
128
|
|
|
129
|
+
const PUMP_DRAIN_MS = 500;
|
|
130
|
+
|
|
131
|
+
const drainPumps = async (pumps: readonly Promise<unknown>[]) => {
|
|
132
|
+
await Promise.race([
|
|
133
|
+
Promise.all(pumps),
|
|
134
|
+
Bun.sleep(PUMP_DRAIN_MS),
|
|
135
|
+
]);
|
|
136
|
+
};
|
|
137
|
+
|
|
122
138
|
export const createSupervisor = (options: SupervisorOptions) => {
|
|
123
139
|
const tail: SupervisorLine[] = [];
|
|
124
140
|
const watchers = new Set<LineWatcher>();
|
|
@@ -132,8 +148,21 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
132
148
|
let exitCode: number | null = null;
|
|
133
149
|
let outOfMemory = false;
|
|
134
150
|
let restarts = 0;
|
|
151
|
+
let launchedAt = performance.now();
|
|
152
|
+
let stable = false;
|
|
153
|
+
let stableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
135
154
|
let queue: Promise<unknown> = Promise.resolve();
|
|
136
155
|
|
|
156
|
+
const markStable = () => {
|
|
157
|
+
if (stable) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
stable = true;
|
|
162
|
+
|
|
163
|
+
options.onStable?.();
|
|
164
|
+
};
|
|
165
|
+
|
|
137
166
|
const emit = (entry: SupervisorLine) => {
|
|
138
167
|
if (entry.stream === BridgeStream.Stderr) {
|
|
139
168
|
process.stderr.write(`${entry.line}\n`);
|
|
@@ -223,6 +252,18 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
223
252
|
return restartStamps.length >= RESTART_LIMIT;
|
|
224
253
|
};
|
|
225
254
|
|
|
255
|
+
const lastLine = () => {
|
|
256
|
+
for (let index = tail.length - 1; index >= 0; index -= 1) {
|
|
257
|
+
const line = stripAnsi(tail.at(index)?.line ?? "").trim();
|
|
258
|
+
|
|
259
|
+
if (line.length > 0) {
|
|
260
|
+
return line.slice(0, BRIDGE_CRASH_LINE_LIMIT);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return null;
|
|
265
|
+
};
|
|
266
|
+
|
|
226
267
|
const launch = async () => {
|
|
227
268
|
const command = await options.resolveCommand();
|
|
228
269
|
const [executable, ...rest] = command;
|
|
@@ -237,6 +278,34 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
237
278
|
];
|
|
238
279
|
|
|
239
280
|
const { argv: spawnArgv, credentials } = await unprivilegedArgv(argv, options.uid, options.gid);
|
|
281
|
+
|
|
282
|
+
launchedAt = performance.now();
|
|
283
|
+
stable = false;
|
|
284
|
+
|
|
285
|
+
if (stableTimer) {
|
|
286
|
+
clearTimeout(stableTimer);
|
|
287
|
+
|
|
288
|
+
stableTimer = null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let accepted = false;
|
|
292
|
+
let reported = false;
|
|
293
|
+
let pumps: Promise<unknown>[] = [];
|
|
294
|
+
|
|
295
|
+
const finish = (code: number) => {
|
|
296
|
+
if (!accepted || reported) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
reported = true;
|
|
301
|
+
|
|
302
|
+
void handleExit(code, pumps).catch((error: unknown) => {
|
|
303
|
+
options.onLog.error("the game exit was not reported", {
|
|
304
|
+
error: errorText(error),
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
|
|
240
309
|
const spawned = Bun.spawn(spawnArgv, {
|
|
241
310
|
...credentials,
|
|
242
311
|
cwd: options.cwd,
|
|
@@ -245,7 +314,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
245
314
|
stdin: "pipe",
|
|
246
315
|
stdout: "pipe",
|
|
247
316
|
onExit(_subprocess, code, signal) {
|
|
248
|
-
|
|
317
|
+
finish(exitCodeOf(code, signal));
|
|
249
318
|
},
|
|
250
319
|
});
|
|
251
320
|
|
|
@@ -281,8 +350,22 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
281
350
|
childGroup = group;
|
|
282
351
|
exitCode = null;
|
|
283
352
|
|
|
284
|
-
|
|
285
|
-
|
|
353
|
+
const launchStamp = launchedAt;
|
|
354
|
+
|
|
355
|
+
stableTimer = setTimeout(() => {
|
|
356
|
+
if (launchStamp !== launchedAt || child === null) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
markStable();
|
|
361
|
+
}, options.stableAfterMs ?? STABLE_UPTIME_MS);
|
|
362
|
+
|
|
363
|
+
pumps = [
|
|
364
|
+
pump(spawned.stdout, BridgeStream.Stdout).catch(() => undefined),
|
|
365
|
+
pump(spawned.stderr, BridgeStream.Stderr).catch(() => undefined),
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
accepted = true;
|
|
286
369
|
|
|
287
370
|
options.onLog("game started", {
|
|
288
371
|
pid: spawned.pid,
|
|
@@ -290,6 +373,12 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
290
373
|
uid: options.uid,
|
|
291
374
|
});
|
|
292
375
|
|
|
376
|
+
if (spawned.exitCode !== null || spawned.signalCode !== null) {
|
|
377
|
+
finish(exitCodeOf(spawned.exitCode, spawned.signalCode));
|
|
378
|
+
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
293
382
|
if (!options.ready) {
|
|
294
383
|
runtime = BridgeRuntime.Running;
|
|
295
384
|
|
|
@@ -299,12 +388,30 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
299
388
|
runtime = BridgeRuntime.Starting;
|
|
300
389
|
|
|
301
390
|
void watch(options.ready, options.stopTimeoutSeconds * 1000 * 10).then((match) => {
|
|
302
|
-
if (
|
|
391
|
+
if (launchStamp !== launchedAt || child === null) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (match) {
|
|
396
|
+
if (runtime === BridgeRuntime.Starting) {
|
|
397
|
+
runtime = BridgeRuntime.Running;
|
|
398
|
+
|
|
399
|
+
options.onLog("game ready");
|
|
400
|
+
options.onReady?.();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
markStable();
|
|
404
|
+
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (runtime === BridgeRuntime.Starting) {
|
|
303
409
|
runtime = BridgeRuntime.Running;
|
|
304
410
|
|
|
305
|
-
options.onLog("game ready");
|
|
306
|
-
options.onReady?.();
|
|
411
|
+
options.onLog.warn("game ready line not seen, treating it as running");
|
|
307
412
|
}
|
|
413
|
+
|
|
414
|
+
markStable();
|
|
308
415
|
});
|
|
309
416
|
};
|
|
310
417
|
|
|
@@ -326,12 +433,18 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
326
433
|
}
|
|
327
434
|
};
|
|
328
435
|
|
|
329
|
-
function handleExit(code: number) {
|
|
436
|
+
async function handleExit(code: number, pumps: readonly Promise<unknown>[]) {
|
|
330
437
|
const finished = child;
|
|
331
438
|
|
|
332
439
|
child = null;
|
|
333
440
|
exitCode = code;
|
|
334
441
|
|
|
442
|
+
if (stableTimer) {
|
|
443
|
+
clearTimeout(stableTimer);
|
|
444
|
+
|
|
445
|
+
stableTimer = null;
|
|
446
|
+
}
|
|
447
|
+
|
|
335
448
|
if (finished) {
|
|
336
449
|
sweep(finished.pid, childGroup);
|
|
337
450
|
}
|
|
@@ -350,6 +463,9 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
350
463
|
return;
|
|
351
464
|
}
|
|
352
465
|
|
|
466
|
+
const uptimeMs = performance.now() - launchedAt;
|
|
467
|
+
const phase = stable ? BridgeCrashPhase.Running : BridgeCrashPhase.Booting;
|
|
468
|
+
|
|
353
469
|
outOfMemory = code === SIGKILL_EXIT_CODE;
|
|
354
470
|
|
|
355
471
|
if (outOfMemory) {
|
|
@@ -358,6 +474,38 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
358
474
|
});
|
|
359
475
|
}
|
|
360
476
|
|
|
477
|
+
const report = async (restarting: boolean, looping: boolean) => {
|
|
478
|
+
const settled = {
|
|
479
|
+
exitCode: code,
|
|
480
|
+
outOfMemory,
|
|
481
|
+
phase,
|
|
482
|
+
uptimeSeconds: Math.floor(uptimeMs / 1000),
|
|
483
|
+
restarting,
|
|
484
|
+
looping,
|
|
485
|
+
restarts,
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
await drainPumps(pumps);
|
|
489
|
+
|
|
490
|
+
options.onExit?.({
|
|
491
|
+
...settled,
|
|
492
|
+
lastLine: lastLine(),
|
|
493
|
+
});
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
if (phase === BridgeCrashPhase.Booting) {
|
|
497
|
+
runtime = BridgeRuntime.Crashed;
|
|
498
|
+
|
|
499
|
+
options.onLog.error("game exited while booting, not restarting", {
|
|
500
|
+
code,
|
|
501
|
+
phase,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
await report(false, false);
|
|
505
|
+
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
361
509
|
restarts += 1;
|
|
362
510
|
restartStamps.push(performance.now());
|
|
363
511
|
|
|
@@ -370,6 +518,8 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
370
518
|
restarts,
|
|
371
519
|
});
|
|
372
520
|
|
|
521
|
+
await report(false, true);
|
|
522
|
+
|
|
373
523
|
return;
|
|
374
524
|
}
|
|
375
525
|
|
|
@@ -380,6 +530,8 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
380
530
|
restarts,
|
|
381
531
|
});
|
|
382
532
|
|
|
533
|
+
await report(true, false);
|
|
534
|
+
|
|
383
535
|
void restart();
|
|
384
536
|
}
|
|
385
537
|
|
|
@@ -429,6 +581,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
429
581
|
shouldRun = true;
|
|
430
582
|
breaker = false;
|
|
431
583
|
outOfMemory = false;
|
|
584
|
+
restarts = 0;
|
|
432
585
|
restartStamps.length = 0;
|
|
433
586
|
|
|
434
587
|
if (child) {
|
|
@@ -444,6 +597,8 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
444
597
|
if (!child) {
|
|
445
598
|
runtime = BridgeRuntime.Stopped;
|
|
446
599
|
|
|
600
|
+
options.onStopped?.();
|
|
601
|
+
|
|
447
602
|
return;
|
|
448
603
|
}
|
|
449
604
|
|
|
@@ -488,6 +643,8 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
488
643
|
sweep(active.pid, group);
|
|
489
644
|
|
|
490
645
|
runtime = BridgeRuntime.Stopped;
|
|
646
|
+
|
|
647
|
+
options.onStopped?.();
|
|
491
648
|
},
|
|
492
649
|
|
|
493
650
|
async command(input: string, commandOptions: BridgeCommandOptions = {}): Promise<BridgeReply> {
|
package/src/runtime/serve.ts
CHANGED
|
@@ -96,6 +96,15 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
98
|
},
|
|
99
|
+
onStable() {
|
|
100
|
+
ring.discard();
|
|
101
|
+
},
|
|
102
|
+
onStopped() {
|
|
103
|
+
ring.discard();
|
|
104
|
+
},
|
|
105
|
+
onExit(record) {
|
|
106
|
+
ring.crash(record);
|
|
107
|
+
},
|
|
99
108
|
});
|
|
100
109
|
|
|
101
110
|
const ring = createEventRing(driver.events, supervisor);
|
|
@@ -130,6 +139,7 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
130
139
|
exitCode: supervisor.exitCode,
|
|
131
140
|
outOfMemory: supervisor.outOfMemory,
|
|
132
141
|
restarts: supervisor.restarts,
|
|
142
|
+
crashRecords: true,
|
|
133
143
|
installError,
|
|
134
144
|
installErrorText,
|
|
135
145
|
installHold,
|