@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.
@@ -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> {
@@ -1,4 +1,4 @@
1
- import { inflateRawSync } from "node:zlib";
1
+ import { crc32, inflateRawSync } from "node:zlib";
2
2
 
3
3
  const EOCD_SIGNATURE = 0x06_05_4b_50;
4
4
 
@@ -32,11 +32,14 @@ const DEFLATED = 8;
32
32
 
33
33
  const ENCRYPTED_FLAG = 0x1;
34
34
 
35
+ const DATA_DESCRIPTOR_FLAG = 0x8;
36
+
35
37
  export const ZIP_ENTRY_LIMIT = 2048;
36
38
 
37
39
  export const ZIP_TREE_ENTRY_LIMIT = 16_384;
38
40
 
39
41
  const MAX_ENTRY_BYTES = 128 * 1024 * 1024;
42
+ const MAX_CONFIGURED_ENTRY_BYTES = 512 * 1024 * 1024;
40
43
 
41
44
  const UNIX_MODE_FORMAT_MASK = 0xf0_00;
42
45
 
@@ -48,6 +51,8 @@ export interface ZipEntry {
48
51
  sizeBytes: number;
49
52
  compressedSize: number;
50
53
  method: number;
54
+ flags: number;
55
+ checksum: number;
51
56
  localOffset: number;
52
57
  }
53
58
 
@@ -155,7 +160,14 @@ const readZip64Extra = (extra: Buffer, sizeBytes: number, compressedSize: number
155
160
  return extended;
156
161
  };
157
162
 
158
- export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT): Promise<ZipArchive> => {
163
+ export const openZip = async (
164
+ path: string,
165
+ maxEntries: number = ZIP_ENTRY_LIMIT,
166
+ maxEntryBytes: number = MAX_ENTRY_BYTES,
167
+ ): Promise<ZipArchive> => {
168
+ if (!Number.isSafeInteger(maxEntryBytes) || maxEntryBytes <= 0 || maxEntryBytes > MAX_CONFIGURED_ENTRY_BYTES) {
169
+ throw new Error("archive entry byte limit is invalid");
170
+ }
159
171
  const size = Bun.file(path).size;
160
172
  const tailStart = Math.max(0, size - (EOCD_SEARCH_LIMIT + ZIP64_LOCATOR_LENGTH));
161
173
  const tail = await readRange(path, tailStart, size);
@@ -174,6 +186,9 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
174
186
  if (directory.total > maxEntries) {
175
187
  throw new Error(`archive has too many entries (${directory.total})`);
176
188
  }
189
+ if (directory.sizeBytes > 16 * 1024 * 1024 || directory.offset + directory.sizeBytes > size) {
190
+ throw new Error("archive central directory exceeds its bounds");
191
+ }
177
192
 
178
193
  const central = await readRange(path, directory.offset, directory.offset + directory.sizeBytes);
179
194
  const entries: ZipEntry[] = [];
@@ -187,6 +202,7 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
187
202
 
188
203
  const flags = central.readUInt16LE(cursor + 8);
189
204
  const method = central.readUInt16LE(cursor + 10);
205
+ const checksum = central.readUInt32LE(cursor + 16);
190
206
  const compressedSize = central.readUInt32LE(cursor + 20);
191
207
  const sizeBytes = central.readUInt32LE(cursor + 24);
192
208
  const nameLength = central.readUInt16LE(cursor + 28);
@@ -200,8 +216,14 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
200
216
  const extended = readZip64Extra(extra, sizeBytes, compressedSize, localOffset);
201
217
 
202
218
  cursor = nameStart + nameLength + extraLength + commentLength;
219
+ if (cursor > central.length) {
220
+ throw new Error("archive central entry exceeds its directory");
221
+ }
203
222
 
204
- if (name.endsWith("/") || (flags & ENCRYPTED_FLAG) !== 0) {
223
+ if ((flags & ENCRYPTED_FLAG) !== 0) {
224
+ throw new Error("encrypted archive entries are not supported");
225
+ }
226
+ if (name.endsWith("/")) {
205
227
  continue;
206
228
  }
207
229
 
@@ -211,6 +233,8 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
211
233
  sizeBytes: extended.sizeBytes ?? sizeBytes,
212
234
  compressedSize: extended.compressedSize ?? compressedSize,
213
235
  method,
236
+ flags,
237
+ checksum,
214
238
  localOffset: extended.localOffset ?? localOffset,
215
239
  });
216
240
  }
@@ -219,20 +243,59 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
219
243
  entries,
220
244
 
221
245
  async read(entry) {
222
- if (entry.sizeBytes > MAX_ENTRY_BYTES || entry.compressedSize > MAX_ENTRY_BYTES) {
246
+ if (entry.sizeBytes > maxEntryBytes || entry.compressedSize > maxEntryBytes) {
223
247
  throw new Error(`archive entry "${entry.name}" is too large`);
224
248
  }
225
249
 
250
+ if (entry.localOffset + LOCAL_HEADER_LENGTH > directory.offset) {
251
+ throw new Error(`archive entry "${entry.name}" has an out-of-bounds local header`);
252
+ }
253
+
226
254
  const header = await readRange(path, entry.localOffset, entry.localOffset + LOCAL_HEADER_LENGTH);
227
255
 
228
256
  if (header.length < LOCAL_HEADER_LENGTH || header.readUInt32LE(0) !== LOCAL_SIGNATURE) {
229
257
  throw new Error(`archive entry "${entry.name}" has a corrupt local header`);
230
258
  }
231
259
 
232
- const dataOffset = entry.localOffset + LOCAL_HEADER_LENGTH + header.readUInt16LE(26) + header.readUInt16LE(28);
260
+ if (header.readUInt16LE(8) !== entry.method || header.readUInt16LE(6) !== entry.flags) {
261
+ throw new Error(`archive entry "${entry.name}" has inconsistent local compression flags or method`);
262
+ }
263
+ if ((entry.flags & DATA_DESCRIPTOR_FLAG) === 0 && header.readUInt32LE(14) !== entry.checksum) {
264
+ throw new Error(`archive entry "${entry.name}" has inconsistent local checksum`);
265
+ }
266
+ if ((entry.flags & DATA_DESCRIPTOR_FLAG) === 0) {
267
+ const localCompressed = header.readUInt32LE(18);
268
+ const localSize = header.readUInt32LE(22);
269
+ if (
270
+ (localCompressed !== SIZE_SENTINEL && localCompressed !== entry.compressedSize)
271
+ || (localSize !== SIZE_SENTINEL && localSize !== entry.sizeBytes)
272
+ ) {
273
+ throw new Error(`archive entry "${entry.name}" has inconsistent local sizes`);
274
+ }
275
+ }
276
+
277
+ const nameOffset = entry.localOffset + LOCAL_HEADER_LENGTH;
278
+ const nameLength = header.readUInt16LE(26);
279
+ const dataOffset = nameOffset + nameLength + header.readUInt16LE(28);
280
+ if (dataOffset > directory.offset || entry.compressedSize > directory.offset - dataOffset) {
281
+ throw new Error(`archive entry "${entry.name}" data exceeds its local bounds`);
282
+ }
283
+ const name = await readRange(path, nameOffset, nameOffset + nameLength);
284
+ if (name.toString("utf8") !== entry.name) {
285
+ throw new Error(`archive entry "${entry.name}" has an inconsistent local name`);
286
+ }
233
287
  const compressed = await readRange(path, dataOffset, dataOffset + entry.compressedSize);
288
+ if (compressed.length !== entry.compressedSize) {
289
+ throw new Error(`archive entry "${entry.name}" has truncated compressed data`);
290
+ }
234
291
 
235
292
  if (entry.method === STORED) {
293
+ if (compressed.length !== entry.sizeBytes || compressed.length !== entry.compressedSize) {
294
+ throw new Error(`archive entry "${entry.name}" does not match its declared size`);
295
+ }
296
+ if (crc32(compressed) !== entry.checksum) {
297
+ throw new Error(`archive entry "${entry.name}" failed its CRC32 checksum`);
298
+ }
236
299
  return compressed;
237
300
  }
238
301
 
@@ -241,13 +304,17 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
241
304
  }
242
305
 
243
306
  const data = inflateRawSync(compressed, {
244
- maxOutputLength: MAX_ENTRY_BYTES,
307
+ maxOutputLength: maxEntryBytes,
245
308
  });
246
309
 
247
310
  if (data.length !== entry.sizeBytes) {
248
311
  throw new Error(`archive entry "${entry.name}" did not inflate to its declared size`);
249
312
  }
250
313
 
314
+ if (crc32(data) !== entry.checksum) {
315
+ throw new Error(`archive entry "${entry.name}" failed its CRC32 checksum`);
316
+ }
317
+
251
318
  return data;
252
319
  },
253
320
  };
@@ -9,6 +9,7 @@ import {
9
9
  type BridgeSampleResult,
10
10
  type BridgeStatus,
11
11
  } from "../protocol";
12
+ import { createBackupLease } from "./bridgeBackupLease";
12
13
  import { configPath, readConfig, socketPath, statePath } from "./bridgeConfigReader";
13
14
  import { createConsoleHandler } from "./bridgeConsoleHandler";
14
15
  import { createContext } from "./bridgeContext";
@@ -96,6 +97,15 @@ export const serve = async (driver: BridgeDriver) => {
96
97
  });
97
98
  });
98
99
  },
100
+ onStable() {
101
+ ring.discard();
102
+ },
103
+ onStopped() {
104
+ ring.discard();
105
+ },
106
+ onExit(record) {
107
+ ring.crash(record);
108
+ },
99
109
  });
100
110
 
101
111
  const ring = createEventRing(driver.events, supervisor);
@@ -111,6 +121,18 @@ export const serve = async (driver: BridgeDriver) => {
111
121
  });
112
122
 
113
123
  const registry = createRegistry(driver, context, supervisor, ring);
124
+ const backupLease = createBackupLease(async () => {
125
+ if (supervisor.running) {
126
+ await driver.backup?.release?.(context);
127
+ }
128
+ });
129
+ setInterval(() => {
130
+ void backupLease.expire().catch((error: unknown) => {
131
+ log.error("could not resume saves after the backup lease expired", {
132
+ error: errorText(error),
133
+ });
134
+ });
135
+ }, 10_000).unref();
114
136
 
115
137
  const runConsole = createConsoleHandler({
116
138
  driver,
@@ -130,6 +152,7 @@ export const serve = async (driver: BridgeDriver) => {
130
152
  exitCode: supervisor.exitCode,
131
153
  outOfMemory: supervisor.outOfMemory,
132
154
  restarts: supervisor.restarts,
155
+ crashRecords: true,
133
156
  installError,
134
157
  installErrorText,
135
158
  installHold,
@@ -219,8 +242,8 @@ export const serve = async (driver: BridgeDriver) => {
219
242
  timeoutMs,
220
243
  });
221
244
  },
222
- async onInvoke(module, action, args) {
223
- return await registry.invoke(module, action, args);
245
+ async onInvoke(module, action, args, recoveryOperationId) {
246
+ return await registry.invoke(module, action, args, recoveryOperationId);
224
247
  },
225
248
  onEvents(cursor) {
226
249
  return ring.drain(cursor);
@@ -228,7 +251,18 @@ export const serve = async (driver: BridgeDriver) => {
228
251
  async onSample() {
229
252
  return await sample();
230
253
  },
231
- async onQuiesce() {
254
+ async onQuiesce(supportsSnapshot, leaseId) {
255
+ if (leaseId) {
256
+ return {
257
+ supported: true,
258
+ quiesced: true,
259
+ settleSeconds: 0,
260
+ leaseId: backupLease.renew(leaseId),
261
+ };
262
+ }
263
+ if (backupLease.active) {
264
+ throw new Error("a backup already owns the save hold");
265
+ }
232
266
  const backup = driver.backup;
233
267
 
234
268
  if (!backup?.quiesce) {
@@ -247,19 +281,29 @@ export const serve = async (driver: BridgeDriver) => {
247
281
  };
248
282
  }
249
283
 
250
- await backup.quiesce(context);
251
-
252
- return {
253
- supported: true,
254
- quiesced: true,
255
- settleSeconds: backup.settleSeconds ?? 0,
256
- };
257
- },
258
- async onRelease() {
259
- if (supervisor.running) {
260
- await driver.backup?.release?.(context);
284
+ const quiesce = backup.quiesce;
285
+ const owner = backupLease.begin(!supportsSnapshot);
286
+ try {
287
+ const prepared = await backupLease.prepare(owner, () => quiesce(context));
288
+ if (prepared?.snapshot && !supportsSnapshot) {
289
+ throw new Error("this backup requires an agent that supports bounded snapshots");
290
+ }
291
+ backupLease.renew(owner);
292
+ return {
293
+ supported: true,
294
+ quiesced: true,
295
+ settleSeconds: backup.settleSeconds ?? 0,
296
+ snapshot: prepared?.snapshot,
297
+ leaseId: supportsSnapshot ? owner : undefined,
298
+ };
299
+ } catch (error) {
300
+ await backupLease.finish(owner);
301
+ throw error;
261
302
  }
262
303
  },
304
+ async onRelease(leaseId) {
305
+ await backupLease.finish(leaseId);
306
+ },
263
307
  async onDescribe() {
264
308
  const describe = driver.install.describe;
265
309
 
package/src/wire/panel.ts CHANGED
@@ -337,6 +337,7 @@ export const bridgeModuleManifestSchema = z.object({
337
337
  pageSize: z.int().positive().nullable().default(null),
338
338
  dependsOn: z.array(zKey).max(8),
339
339
  actions: z.array(z.string().min(1).max(64)).max(16),
340
+ protectedActions: z.array(z.string().min(1).max(64)).max(16).optional(),
340
341
  });
341
342
 
342
343
  export const bridgeManifestSchema: z.ZodType<BridgeManifest> = z.object({