@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -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
+ }
@@ -76,6 +76,7 @@ export interface BridgeStatus {
76
76
  exitCode: number | null;
77
77
  outOfMemory: boolean;
78
78
  restarts: number;
79
+ crashRecords: boolean;
79
80
  installError: string | null;
80
81
  installErrorText: LocalizedText | null;
81
82
  installHold: BridgeInstallHold | null;
@@ -3,6 +3,7 @@ export * from "../panel";
3
3
  export * from "../terminal";
4
4
  export * from "./bridgeCatalog";
5
5
  export * from "./bridgeConfig";
6
+ export * from "./bridgeCrash";
6
7
  export * from "./bridgeEgress";
7
8
  export * from "./bridgeEvents";
8
9
  export * from "./bridgeFrame";
@@ -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 emittedFor = async (line: string) => {
32
- const dir = await mkdtemp(join(tmpdir(), "bridge-events-"));
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 type { BridgeEvent } from "../protocol";
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
- push(pattern.emit, groups);
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
- push(event, payload ?? null);
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) {
@@ -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,
@@ -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 = 300_000;
18
+ const RESTART_WINDOW_MS = 600_000;
18
19
 
19
- const RESTART_LIMIT = 5;
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
- handleExit(exitCodeOf(code, signal));
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
- void pump(spawned.stdout, BridgeStream.Stdout);
285
- void pump(spawned.stderr, BridgeStream.Stderr);
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 (match && runtime === BridgeRuntime.Starting) {
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> {
@@ -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,