@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.
@@ -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,
@@ -0,0 +1,306 @@
1
+ import { afterEach, describe, expect, setSystemTime, test } from "bun:test";
2
+ import type { BridgeContext } from "../context";
3
+ import type { BridgeValues } from "../core";
4
+ import { createSteamAvatars, STEAM_AVATAR_CACHE_TTL_MS, steamAvatarHashOf } from "./avatars";
5
+
6
+ const ALICE_HASH = "a".repeat(40);
7
+
8
+ const BOB_HASH = "b".repeat(40);
9
+
10
+ const ALICE = "76561198000000001";
11
+
12
+ const BOB = "76561198000000002";
13
+
14
+ const identity = (id: string) => (id.startsWith("7656") ? id : null);
15
+
16
+ interface Warning {
17
+ message: string;
18
+ fields: BridgeValues | undefined;
19
+ }
20
+
21
+ interface Summary {
22
+ steamid: string;
23
+ avatarfull: string;
24
+ }
25
+
26
+ interface FakeSteam {
27
+ context: BridgeContext;
28
+ calls: string[][];
29
+ warnings: Warning[];
30
+ }
31
+
32
+ const summaryOf = (steamId: string, hash: string): Summary => {
33
+ return {
34
+ steamid: steamId,
35
+ avatarfull: `https://avatars.steamstatic.com/${hash}_full.jpg`,
36
+ };
37
+ };
38
+
39
+ const fakeSteam = (options: { key?: string | null; answer?: (steamIds: string[]) => Summary[] }): FakeSteam => {
40
+ const calls: string[][] = [];
41
+ const warnings: Warning[] = [];
42
+
43
+ const context = {
44
+ secret(key: string) {
45
+ return key === "STEAM_WEB_API_KEY" ? (options.key === undefined ? "secret-key" : options.key) : null;
46
+ },
47
+ net: {
48
+ async json(url: string) {
49
+ const steamIds = (new URL(url).searchParams.get("steamids") ?? "").split(",").filter((id) => id.length > 0);
50
+
51
+ calls.push(steamIds);
52
+
53
+ return {
54
+ response: {
55
+ players: options.answer?.(steamIds) ?? [],
56
+ },
57
+ };
58
+ },
59
+ },
60
+ log: {
61
+ warn(message: string, fields?: BridgeValues) {
62
+ warnings.push({
63
+ message,
64
+ fields,
65
+ });
66
+ },
67
+ },
68
+ } as unknown as BridgeContext;
69
+
70
+ return {
71
+ context,
72
+ calls,
73
+ warnings,
74
+ };
75
+ };
76
+
77
+ afterEach(() => {
78
+ setSystemTime();
79
+ });
80
+
81
+ describe("reading the avatar hash out of a steam profile picture", () => {
82
+ test("takes the hash out of the full-size url steam answers with", () => {
83
+ expect(steamAvatarHashOf(`https://avatars.steamstatic.com/${ALICE_HASH}_full.jpg`)).toBe(ALICE_HASH);
84
+ });
85
+
86
+ test("takes the hash out of a url that carries no size suffix", () => {
87
+ expect(steamAvatarHashOf(`https://avatars.steamstatic.com/${BOB_HASH}.jpg`)).toBe(BOB_HASH);
88
+ });
89
+
90
+ test("reports no hash for a url that is not a steam avatar", () => {
91
+ expect(steamAvatarHashOf("https://example.com/avatar.png")).toBeNull();
92
+ expect(steamAvatarHashOf("")).toBeNull();
93
+ });
94
+ });
95
+
96
+ describe("keeping the steam avatars a roster shows", () => {
97
+ test("asks steam for a hash it has never seen, and answers with it", async () => {
98
+ const steam = fakeSteam({
99
+ answer: () => [
100
+ summaryOf(ALICE, ALICE_HASH),
101
+ ],
102
+ });
103
+ const avatars = createSteamAvatars({
104
+ steamIdOf: identity,
105
+ });
106
+
107
+ expect(
108
+ await avatars.resolve(steam.context, [
109
+ ALICE,
110
+ ]),
111
+ ).toEqual(
112
+ new Map([
113
+ [
114
+ ALICE,
115
+ ALICE_HASH,
116
+ ],
117
+ ]),
118
+ );
119
+ expect(steam.calls).toEqual([
120
+ [
121
+ ALICE,
122
+ ],
123
+ ]);
124
+ });
125
+
126
+ test("answers the second time without asking steam again", async () => {
127
+ const steam = fakeSteam({
128
+ answer: () => [
129
+ summaryOf(ALICE, ALICE_HASH),
130
+ ],
131
+ });
132
+ const avatars = createSteamAvatars({
133
+ steamIdOf: identity,
134
+ });
135
+
136
+ await avatars.resolve(steam.context, [
137
+ ALICE,
138
+ ]);
139
+
140
+ expect(
141
+ (
142
+ await avatars.resolve(steam.context, [
143
+ ALICE,
144
+ ])
145
+ ).get(ALICE),
146
+ ).toBe(ALICE_HASH);
147
+ expect(steam.calls.length).toBe(1);
148
+ });
149
+
150
+ test("asks only for the ids it is missing, and still answers for the cached one", async () => {
151
+ const steam = fakeSteam({
152
+ answer: (steamIds) => {
153
+ return steamIds.map((steamId) => {
154
+ return summaryOf(steamId, steamId.endsWith("1") ? ALICE_HASH : BOB_HASH);
155
+ });
156
+ },
157
+ });
158
+ const avatars = createSteamAvatars({
159
+ steamIdOf: identity,
160
+ });
161
+
162
+ await avatars.resolve(steam.context, [
163
+ ALICE,
164
+ ]);
165
+
166
+ const hashes = await avatars.resolve(steam.context, [
167
+ ALICE,
168
+ BOB,
169
+ ]);
170
+
171
+ expect(steam.calls.at(1)).toEqual([
172
+ BOB,
173
+ ]);
174
+ expect(hashes.get(ALICE)).toBe(ALICE_HASH);
175
+ expect(hashes.get(BOB)).toBe(BOB_HASH);
176
+ });
177
+
178
+ test("asks steam again once the cached hash is six hours old", async () => {
179
+ setSystemTime(new Date(0));
180
+
181
+ const steam = fakeSteam({
182
+ answer: () => [
183
+ summaryOf(ALICE, ALICE_HASH),
184
+ ],
185
+ });
186
+ const avatars = createSteamAvatars({
187
+ steamIdOf: identity,
188
+ });
189
+
190
+ await avatars.resolve(steam.context, [
191
+ ALICE,
192
+ ]);
193
+
194
+ setSystemTime(new Date(STEAM_AVATAR_CACHE_TTL_MS - 1));
195
+
196
+ await avatars.resolve(steam.context, [
197
+ ALICE,
198
+ ]);
199
+
200
+ expect(steam.calls.length).toBe(1);
201
+
202
+ setSystemTime(new Date(STEAM_AVATAR_CACHE_TTL_MS));
203
+
204
+ expect(
205
+ (
206
+ await avatars.resolve(steam.context, [
207
+ ALICE,
208
+ ])
209
+ ).get(ALICE),
210
+ ).toBe(ALICE_HASH);
211
+ expect(steam.calls.length).toBe(2);
212
+ });
213
+
214
+ test("never asks steam about a player the game does not map to a steam id", async () => {
215
+ const steam = fakeSteam({});
216
+ const avatars = createSteamAvatars({
217
+ steamIdOf: identity,
218
+ });
219
+
220
+ expect(
221
+ await avatars.resolve(steam.context, [
222
+ "gdk_2535000000000001",
223
+ ]),
224
+ ).toEqual(new Map());
225
+ expect(steam.calls).toEqual([]);
226
+ });
227
+
228
+ test("answers with nothing while the platform has no steam key", async () => {
229
+ const steam = fakeSteam({
230
+ key: null,
231
+ });
232
+ const avatars = createSteamAvatars({
233
+ steamIdOf: identity,
234
+ });
235
+
236
+ expect(
237
+ await avatars.resolve(steam.context, [
238
+ ALICE,
239
+ ]),
240
+ ).toEqual(new Map());
241
+ expect(steam.calls).toEqual([]);
242
+ expect(steam.warnings).toEqual([]);
243
+ });
244
+
245
+ test("answers with nothing and warns once when steam refuses", async () => {
246
+ const steam = fakeSteam({
247
+ answer: () => {
248
+ throw new Error("429 too many requests");
249
+ },
250
+ });
251
+ const avatars = createSteamAvatars({
252
+ steamIdOf: identity,
253
+ });
254
+
255
+ expect(
256
+ await avatars.resolve(steam.context, [
257
+ ALICE,
258
+ ]),
259
+ ).toEqual(new Map());
260
+ expect(steam.warnings.length).toBe(1);
261
+
262
+ await avatars.resolve(steam.context, [
263
+ ALICE,
264
+ ]);
265
+
266
+ expect(steam.warnings.length).toBe(1);
267
+ });
268
+
269
+ test("never writes the api key into the warning it logs", async () => {
270
+ const steam = fakeSteam({
271
+ key: "top-secret-key",
272
+ answer: () => {
273
+ throw new Error("steam said no");
274
+ },
275
+ });
276
+ const avatars = createSteamAvatars({
277
+ steamIdOf: identity,
278
+ });
279
+
280
+ await avatars.resolve(steam.context, [
281
+ ALICE,
282
+ ]);
283
+
284
+ expect(JSON.stringify(steam.warnings)).not.toContain("top-secret-key");
285
+ });
286
+
287
+ test("keeps the roster moving when steam sends a profile with no usable picture", async () => {
288
+ const steam = fakeSteam({
289
+ answer: () => [
290
+ {
291
+ steamid: ALICE,
292
+ avatarfull: "https://example.com/none.png",
293
+ },
294
+ ],
295
+ });
296
+ const avatars = createSteamAvatars({
297
+ steamIdOf: identity,
298
+ });
299
+
300
+ expect(
301
+ await avatars.resolve(steam.context, [
302
+ ALICE,
303
+ ]),
304
+ ).toEqual(new Map());
305
+ });
306
+ });