@serverkgg/bridge 0.0.15 → 0.0.17

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