pi-microsandbox 0.1.0

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.
@@ -0,0 +1,702 @@
1
+ import {
2
+ displayId,
3
+ sandboxNameFor,
4
+ STATE_SCHEMA_VERSION,
5
+ type BootRequest,
6
+ type Config,
7
+ type GitRepoInfo,
8
+ type LockHandle,
9
+ type PersistedSandboxState,
10
+ type PreparedStorage,
11
+ type PruneReport,
12
+ type RuntimeExecution,
13
+ type RuntimeState,
14
+ type SandboxManager,
15
+ type SandboxTransport,
16
+ type SeedResult,
17
+ type StorageMode,
18
+ type StoragePlan,
19
+ type ToolOperations,
20
+ } from "./types.ts";
21
+
22
+ /**
23
+ * Inputs to the storage planner. Kept local because the shared contract only
24
+ * describes the values crossing the manager boundary.
25
+ */
26
+ export interface StoragePlanInput {
27
+ cwd: string;
28
+ sessionId: string;
29
+ config: Config;
30
+ git: GitRepoInfo;
31
+ restored?: PersistedSandboxState | null;
32
+ }
33
+
34
+ /** The SDK adapter deliberately owns the concrete shape of an inspected handle. */
35
+ export interface InspectedSandbox {
36
+ name: string;
37
+ status?: string;
38
+ labels: Record<string, string>;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ export interface SandboxManagerDeps {
43
+ acquireOwnerLock(request: BootRequest): Promise<LockHandle | null>;
44
+ pruneOthers(currentSessionId: string): Promise<PruneReport>;
45
+ detectGit(cwd: string): Promise<GitRepoInfo>;
46
+ buildStoragePlan(input: StoragePlanInput): StoragePlan;
47
+ prepareStorage(
48
+ plan: StoragePlan,
49
+ restored: PersistedSandboxState | null,
50
+ ): Promise<PreparedStorage>;
51
+ inspectSandbox(name: string): Promise<InspectedSandbox | null>;
52
+ connectSandbox(value: InspectedSandbox): Promise<unknown>;
53
+ startSandbox(value: InspectedSandbox): Promise<unknown>;
54
+ createSandbox(request: BootRequest, prepared: PreparedStorage): Promise<unknown>;
55
+ stopAndRemove(name: string, timeoutMs: number): Promise<void>;
56
+ createTransport(raw: unknown): SandboxTransport;
57
+ createOperations(transport: SandboxTransport): ToolOperations;
58
+ probeAndBootstrap(runtime: RuntimeExecution, config: Config): Promise<void>;
59
+ seed(runtime: RuntimeExecution, prepared: PreparedStorage): Promise<SeedResult>;
60
+ persist(state: PersistedSandboxState): void;
61
+ now?: () => number;
62
+ }
63
+
64
+ const RUNNING_STATUSES = new Set(["running", "started", "active", "up"]);
65
+ const STOPPED_STATUSES = new Set([
66
+ "stopped",
67
+ "exited",
68
+ "idle",
69
+ "paused",
70
+ "dead",
71
+ "created",
72
+ ]);
73
+
74
+ function modeForPlan(plan: StoragePlan): StorageMode {
75
+ return plan.kind === "git-volume"
76
+ ? "git"
77
+ : plan.kind === "direct-mount"
78
+ ? "direct"
79
+ : "none";
80
+ }
81
+
82
+ function volumeNameForPlan(plan: StoragePlan): string | undefined {
83
+ return plan.kind === "git-volume" ? plan.volumeName : undefined;
84
+ }
85
+
86
+ function statusOf(value: InspectedSandbox): string | undefined {
87
+ if (typeof value.status === "string") return value.status.toLowerCase();
88
+ if (value.running === true) return "running";
89
+ if (value.running === false) return "stopped";
90
+ return undefined;
91
+ }
92
+
93
+ function isRunning(value: InspectedSandbox): boolean {
94
+ return RUNNING_STATUSES.has(statusOf(value) ?? "");
95
+ }
96
+
97
+ function isStopped(value: InspectedSandbox): boolean {
98
+ return STOPPED_STATUSES.has(statusOf(value) ?? "");
99
+ }
100
+
101
+ function labelsOf(value: InspectedSandbox): Record<string, string> {
102
+ return value.labels && typeof value.labels === "object" ? value.labels : {};
103
+ }
104
+
105
+ function isManagedOwner(value: InspectedSandbox, request: BootRequest): boolean {
106
+ const labels = labelsOf(value);
107
+ return (
108
+ labels["pi-msb.managed"] === "true" &&
109
+ labels["pi-msb.schema"] === String(STATE_SCHEMA_VERSION) &&
110
+ labels["pi-msb.session"] === request.sessionId
111
+ );
112
+ }
113
+
114
+ function matchesPlan(value: InspectedSandbox, request: BootRequest, plan: StoragePlan): boolean {
115
+ const labels = labelsOf(value);
116
+ const expectedMode = modeForPlan(plan);
117
+ const expectedVolume = volumeNameForPlan(plan);
118
+
119
+ if (request.config.mode !== "auto" && request.config.mode !== expectedMode) return false;
120
+ if (!isManagedOwner(value, request)) return false;
121
+ if (labels["pi-msb.mode"] !== expectedMode) return false;
122
+ if (labels["pi-msb.cwd"] !== request.cwd) return false;
123
+ if (labels["pi-msb.image"] !== request.config.image) return false;
124
+
125
+ const actualVolume = labels["pi-msb.volume"];
126
+ return expectedVolume === undefined
127
+ ? actualVolume === undefined
128
+ : actualVolume === expectedVolume;
129
+ }
130
+
131
+ function infoFor(
132
+ request: BootRequest,
133
+ plan: StoragePlan,
134
+ prepared: PreparedStorage,
135
+ seedBranch: string | null | undefined,
136
+ seedSha: string | null | undefined,
137
+ createdAt: number,
138
+ name: string,
139
+ ): NonNullable<RuntimeState["info"]> {
140
+ return {
141
+ name,
142
+ displayId: displayId(request.sessionId),
143
+ mode: modeForPlan(plan),
144
+ image: request.config.image,
145
+ pid: process.pid,
146
+ cwd: request.cwd,
147
+ volumeName: volumeNameForPlan(plan),
148
+ volumeHostPath: plan.kind === "git-volume" ? prepared.volume?.hostPath : undefined,
149
+ seedBranch: seedBranch ?? null,
150
+ seedSha: seedSha ?? null,
151
+ createdAt,
152
+ };
153
+ }
154
+
155
+ function redactMessage(error: unknown, config?: Config): string {
156
+ let message = error instanceof Error ? error.message : String(error);
157
+ for (const secret of config?.secrets ?? []) {
158
+ if (secret.value) message = message.split(secret.value).join("[REDACTED]");
159
+ }
160
+ return message || "unknown error";
161
+ }
162
+
163
+ function unavailableError(state: RuntimeState): Error {
164
+ const reason = state.reason ? `: ${state.reason}` : "";
165
+ return new Error(`sandbox is not available${reason}`);
166
+ }
167
+
168
+ function sameBootRequest(a: BootRequest, b: BootRequest): boolean {
169
+ return (
170
+ a.sessionId === b.sessionId &&
171
+ a.cwd === b.cwd &&
172
+ JSON.stringify(a.config) === JSON.stringify(b.config)
173
+ );
174
+ }
175
+
176
+ function sameSessionRestore(
177
+ request: BootRequest,
178
+ ): PersistedSandboxState | null {
179
+ const restored = request.restored;
180
+ if (!restored) return null;
181
+ if (restored.version !== STATE_SCHEMA_VERSION) return null;
182
+ if (restored.sessionId !== request.sessionId) return null;
183
+ if (restored.cwd !== request.cwd) return null;
184
+ return restored;
185
+ }
186
+
187
+ /**
188
+ * Lifecycle coordinator for the injected sandbox ports. In particular, no
189
+ * storage or SDK operation is reached until the owner lock has been acquired.
190
+ */
191
+ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
192
+ let state: RuntimeState = { status: "disabled", info: null };
193
+ let lock: LockHandle | null = null;
194
+ let runtime: RuntimeExecution | null = null;
195
+ let sandboxName: string | null = null;
196
+ let lastRequest: BootRequest | null = null;
197
+ let retainedState: PersistedSandboxState | null = null;
198
+ let activePlan: StoragePlan | null = null;
199
+ // activeUses includes reservations made before preflight. pendingUses marks
200
+ // those reservations; only activeUses - pendingUses are callbacks currently
201
+ // using a transport and therefore must drain before replacement/disposal.
202
+ let activeUses = 0;
203
+ let pendingUses = 0;
204
+ const usageDrainWaiters: Array<() => void> = [];
205
+
206
+ // Lifecycle transitions are serialized, but the tool callback itself is not
207
+ // put on this queue. This permits concurrent guest commands while ensuring a
208
+ // wake/replacement cannot race another wake or shutdown.
209
+ let lifecycle: Promise<unknown> = Promise.resolve();
210
+ let transition: Promise<unknown> = Promise.resolve();
211
+
212
+ function enqueue<T>(fn: () => Promise<T>): Promise<T> {
213
+ const result = lifecycle.then(fn, fn);
214
+ lifecycle = result.then(
215
+ () => undefined,
216
+ () => undefined,
217
+ );
218
+ return result;
219
+ }
220
+
221
+ function enqueueTransition<T>(fn: () => Promise<T>): Promise<T> {
222
+ const result = transition.then(fn, fn);
223
+ transition = result.then(
224
+ () => undefined,
225
+ () => undefined,
226
+ );
227
+ return result;
228
+ }
229
+
230
+ function setFailure(request: BootRequest | null, error: unknown): RuntimeState {
231
+ const reason = redactMessage(error, request?.config);
232
+ state = {
233
+ status: request?.config.fallbackMode === "host" ? "host-fallback" : "unavailable",
234
+ info: null,
235
+ reason,
236
+ };
237
+ return state;
238
+ }
239
+
240
+ async function disposeRuntime(): Promise<void> {
241
+ const current = runtime;
242
+ runtime = null;
243
+ if (!current) return;
244
+ try {
245
+ await current.transport.dispose();
246
+ } catch {
247
+ // Disposal is best effort. The owner lock and sandbox cleanup must still
248
+ // happen even when the transport has already gone down.
249
+ }
250
+ }
251
+
252
+ function activeCallbackCount(): number {
253
+ return activeUses - pendingUses;
254
+ }
255
+
256
+ function notifyUsageDrain(): void {
257
+ if (activeCallbackCount() !== 0 || usageDrainWaiters.length === 0) return;
258
+ const waiters = usageDrainWaiters.splice(0);
259
+ for (const resolve of waiters) resolve();
260
+ }
261
+
262
+ async function waitForActiveUses(): Promise<void> {
263
+ if (activeCallbackCount() === 0) return;
264
+ await new Promise<void>((resolve) => {
265
+ usageDrainWaiters.push(resolve);
266
+ });
267
+ }
268
+
269
+ function reserveUse(): void {
270
+ activeUses += 1;
271
+ pendingUses += 1;
272
+ }
273
+
274
+ function activateUse(): void {
275
+ pendingUses -= 1;
276
+ }
277
+
278
+ function releaseUse(activated: boolean): void {
279
+ activeUses -= 1;
280
+ if (!activated) pendingUses -= 1;
281
+ notifyUsageDrain();
282
+ }
283
+
284
+ function requestSandboxName(request: BootRequest): string {
285
+ return request.config.sandboxName ?? sandboxNameFor(request.sessionId);
286
+ }
287
+
288
+ async function connectAndBuild(raw: unknown): Promise<RuntimeExecution> {
289
+ const transport = deps.createTransport(raw);
290
+ try {
291
+ return {
292
+ transport,
293
+ operations: deps.createOperations(transport),
294
+ };
295
+ } catch (error) {
296
+ try {
297
+ await transport.dispose();
298
+ } catch {
299
+ // Preserve the operation-construction error.
300
+ }
301
+ throw error;
302
+ }
303
+ }
304
+
305
+ async function bootInternal(request: BootRequest): Promise<RuntimeState> {
306
+ // Do not mutate or reconnect a resource while a concurrent tool preflight is
307
+ // atomically replacing its runtime handle.
308
+ await transition;
309
+ const previousRequest = lastRequest;
310
+ if (
311
+ state.status === "active" &&
312
+ runtime &&
313
+ previousRequest &&
314
+ sandboxName === requestSandboxName(request) &&
315
+ sameBootRequest(previousRequest, request)
316
+ ) {
317
+ return state;
318
+ }
319
+ if (state.status === "active" && runtime) {
320
+ await shutdownInternal(false);
321
+ }
322
+
323
+ lastRequest = request;
324
+ state = { status: "booting", info: null };
325
+ let owner: LockHandle | null;
326
+ try {
327
+ owner = await deps.acquireOwnerLock(request);
328
+ } catch (error) {
329
+ // A lock implementation failure is fail-closed and must not permit any
330
+ // SDK/storage call to run without ownership.
331
+ return setFailure(request, error);
332
+ }
333
+ if (!owner) {
334
+ // A live owner is not a stale-resource condition. Do not inspect, prune,
335
+ // or otherwise mutate anything when the non-blocking lock says busy.
336
+ state = {
337
+ status: "unavailable",
338
+ info: null,
339
+ reason: "another process owns this session",
340
+ };
341
+ return state;
342
+ }
343
+ lock = owner;
344
+
345
+ let prepared: PreparedStorage | null = null;
346
+ let partialSandbox = false;
347
+ let currentRequestInfo: RuntimeState["info"] = null;
348
+ let bootedRuntime: RuntimeExecution | null = null;
349
+
350
+ try {
351
+ if (request.config.pruneOnStart) {
352
+ await deps.pruneOthers(request.sessionId);
353
+ }
354
+ const git = await deps.detectGit(request.cwd);
355
+ const restored = sameSessionRestore(request);
356
+ const plan = deps.buildStoragePlan({
357
+ cwd: request.cwd,
358
+ sessionId: request.sessionId,
359
+ config: request.config,
360
+ git,
361
+ restored,
362
+ });
363
+ prepared = await deps.prepareStorage(plan, restored);
364
+
365
+ const name = requestSandboxName(request);
366
+ sandboxName = name;
367
+ const inspected = await deps.inspectSandbox(name);
368
+ let raw: unknown;
369
+
370
+ if (!inspected) {
371
+ partialSandbox = true;
372
+ raw = await deps.createSandbox(request, prepared);
373
+ } else if (!isManagedOwner(inspected, request)) {
374
+ throw new Error(`sandbox name conflict: ${name}`);
375
+ } else if (!matchesPlan(inspected, request, plan)) {
376
+ // The labels prove this is ours, but its configuration is stale. Only
377
+ // that proven ownership permits a replacement.
378
+ await deps.stopAndRemove(name, request.config.stopTimeoutMs);
379
+ // The old instance was removed; a failed replacement still needs its
380
+ // own best-effort cleanup.
381
+ partialSandbox = true;
382
+ raw = await deps.createSandbox(request, prepared);
383
+ } else if (isRunning(inspected)) {
384
+ raw = await deps.connectSandbox(inspected);
385
+ partialSandbox = true;
386
+ } else if (isStopped(inspected)) {
387
+ partialSandbox = true;
388
+ raw = await deps.startSandbox(inspected);
389
+ if (raw === undefined || raw === null) raw = await deps.connectSandbox(inspected);
390
+ } else {
391
+ // An unknown state is not safe to replace. A connect gives adapters a
392
+ // chance to normalize SDK-specific states without granting replace.
393
+ raw = await deps.connectSandbox(inspected);
394
+ partialSandbox = true;
395
+ }
396
+
397
+ bootedRuntime = await connectAndBuild(raw);
398
+ runtime = bootedRuntime;
399
+ await deps.probeAndBootstrap(bootedRuntime, request.config);
400
+
401
+ let seed: SeedResult | null = null;
402
+ if (
403
+ prepared.plan.kind === "git-volume" &&
404
+ prepared.createdVolume &&
405
+ prepared.plan.seedRequired
406
+ ) {
407
+ seed = await deps.seed(bootedRuntime, prepared);
408
+ }
409
+
410
+ const createdAt = deps.now ? deps.now() : Date.now();
411
+ const previous = restored;
412
+ const seedBranch = prepared.createdVolume
413
+ ? prepared.plan.kind === "git-volume"
414
+ ? prepared.plan.branch
415
+ : null
416
+ : previous?.seedBranch ??
417
+ (prepared.plan.kind === "git-volume" ? prepared.plan.branch : null);
418
+ const seedSha = prepared.createdVolume
419
+ ? seed?.headSha ??
420
+ (prepared.plan.kind === "git-volume" ? prepared.plan.headSha : null)
421
+ : previous?.seedSha ??
422
+ (prepared.plan.kind === "git-volume" ? prepared.plan.headSha : null);
423
+ currentRequestInfo = infoFor(
424
+ request,
425
+ prepared.plan,
426
+ prepared,
427
+ seedBranch,
428
+ seedSha,
429
+ createdAt,
430
+ name,
431
+ );
432
+
433
+ // Bundle cleanup is deliberately before state publication: a successful
434
+ // boot never publishes a state whose temporary host artifact leaked.
435
+ if (prepared.bundle) await prepared.bundle.cleanup();
436
+ prepared.bundle = null;
437
+
438
+ const persisted: PersistedSandboxState = {
439
+ version: STATE_SCHEMA_VERSION,
440
+ sessionId: request.sessionId,
441
+ sandboxName: name,
442
+ mode: currentRequestInfo.mode,
443
+ cwd: request.cwd,
444
+ image: request.config.image,
445
+ volumeName: currentRequestInfo.volumeName,
446
+ volumeHostPath: currentRequestInfo.volumeHostPath,
447
+ seedBranch: currentRequestInfo.seedBranch,
448
+ seedSha: currentRequestInfo.seedSha,
449
+ enabled: true,
450
+ createdAt,
451
+ };
452
+ deps.persist(persisted);
453
+ retainedState = persisted;
454
+ activePlan = prepared.plan;
455
+ state = { status: "active", info: currentRequestInfo };
456
+ return state;
457
+ } catch (error) {
458
+ if (prepared?.bundle) {
459
+ try {
460
+ await prepared.bundle.cleanup();
461
+ } catch {
462
+ // The primary boot error is more useful, and no secret is exposed.
463
+ }
464
+ prepared.bundle = null;
465
+ }
466
+ await disposeRuntime();
467
+ if (partialSandbox && sandboxName) {
468
+ try {
469
+ await deps.stopAndRemove(sandboxName, request.config.stopTimeoutMs);
470
+ } catch {
471
+ // Leave an uncertain labelled sandbox for a later prune/recovery.
472
+ }
473
+ }
474
+ runtime = null;
475
+ bootedRuntime = null;
476
+ activePlan = null;
477
+ sandboxName = null;
478
+ if (lock === owner) {
479
+ lock = null;
480
+ try {
481
+ await owner.release();
482
+ } catch {
483
+ // The lock implementation is expected to be idempotent. There is no
484
+ // safe resource action to take if release itself fails.
485
+ }
486
+ }
487
+ return setFailure(request, error);
488
+ }
489
+ }
490
+
491
+ async function wakeIfNeeded(): Promise<RuntimeExecution> {
492
+ if (state.status !== "active" || !runtime || !lastRequest || !sandboxName || !activePlan) {
493
+ throw unavailableError(state);
494
+ }
495
+
496
+ const inspected = await deps.inspectSandbox(sandboxName);
497
+ if (!inspected) {
498
+ throw new Error(`sandbox ${sandboxName} is unavailable`);
499
+ }
500
+ if (inspected.name !== sandboxName) {
501
+ throw new Error(`sandbox name mismatch: expected ${sandboxName}`);
502
+ }
503
+ if (!isManagedOwner(inspected, lastRequest)) {
504
+ throw new Error(`sandbox ${sandboxName} has conflicting labels`);
505
+ }
506
+ // Revalidate the complete managed runtime identity before waking or
507
+ // reconnecting. A same-name resource with changed mode/cwd/image/volume is
508
+ // not safe to attach to.
509
+ if (!matchesPlan(inspected, lastRequest, activePlan)) {
510
+ throw new Error(`sandbox ${sandboxName} configuration changed`);
511
+ }
512
+
513
+ if (isRunning(inspected)) {
514
+ // The boot-created transport remains authoritative while the sandbox is
515
+ // running. Reconnecting here would replace a valid handle and dispose a
516
+ // transport that may still have active callers.
517
+ return runtime;
518
+ }
519
+
520
+ // A stopped/unknown sandbox requires a replacement transport. Do not stop,
521
+ // start, or dispose the old one until every earlier callback has released
522
+ // its runtime-use reservation.
523
+ await waitForActiveUses();
524
+ let raw: unknown;
525
+ if (isStopped(inspected)) {
526
+ raw = await deps.startSandbox(inspected);
527
+ if (raw === undefined || raw === null) raw = await deps.connectSandbox(inspected);
528
+ } else {
529
+ raw = await deps.connectSandbox(inspected);
530
+ }
531
+
532
+ const replacement = await connectAndBuild(raw);
533
+ const previous = runtime;
534
+ runtime = replacement;
535
+ if (previous && previous !== replacement) {
536
+ try {
537
+ await previous.transport.dispose();
538
+ } catch {
539
+ // The replacement is already installed and is the authoritative one.
540
+ }
541
+ }
542
+ return replacement;
543
+ }
544
+
545
+ async function shutdownInternal(persistDisabled: boolean): Promise<void> {
546
+ // A preflight may be reconnecting while the lifecycle queue reaches
547
+ // shutdown. Finish that atomic replacement before disposing its handle.
548
+ await transition;
549
+ const hadSandbox = Boolean(sandboxName);
550
+ const name = sandboxName;
551
+ const request = lastRequest;
552
+ const oldInfo = state.info;
553
+ state = { status: "stopping", info: oldInfo };
554
+
555
+ await waitForActiveUses();
556
+ await disposeRuntime();
557
+
558
+ let cleanupError: unknown = null;
559
+ if (hadSandbox && name && request) {
560
+ try {
561
+ await deps.stopAndRemove(name, request.config.stopTimeoutMs);
562
+ } catch (error) {
563
+ cleanupError = error;
564
+ }
565
+ }
566
+
567
+ runtime = null;
568
+ activePlan = null;
569
+ sandboxName = null;
570
+
571
+ if (persistDisabled && request && oldInfo) {
572
+ const createdAt = oldInfo.createdAt;
573
+ const disabled: PersistedSandboxState = {
574
+ version: STATE_SCHEMA_VERSION,
575
+ sessionId: request.sessionId,
576
+ sandboxName: oldInfo.name,
577
+ mode: oldInfo.mode,
578
+ cwd: oldInfo.cwd,
579
+ image: oldInfo.image,
580
+ volumeName: oldInfo.volumeName,
581
+ volumeHostPath: oldInfo.volumeHostPath,
582
+ seedBranch: oldInfo.seedBranch,
583
+ seedSha: oldInfo.seedSha,
584
+ enabled: false,
585
+ createdAt,
586
+ };
587
+ retainedState = disabled;
588
+ deps.persist(disabled);
589
+ }
590
+
591
+ // Releasing ownership is last, after transport and sandbox cleanup. This
592
+ // ordering prevents prune from observing an owned resource as orphaned.
593
+ const held = lock;
594
+ lock = null;
595
+ if (held) {
596
+ try {
597
+ await held.release();
598
+ } catch {
599
+ cleanupError ??= new Error("could not release the session lock");
600
+ }
601
+ }
602
+
603
+ if (cleanupError) {
604
+ state = {
605
+ status: "unavailable",
606
+ info: null,
607
+ reason: redactMessage(cleanupError, request?.config),
608
+ };
609
+ } else if (persistDisabled) {
610
+ state = { status: "off", info: null };
611
+ } else {
612
+ state = { status: "disabled", info: null };
613
+ }
614
+ }
615
+
616
+ const manager: SandboxManager = {
617
+ isActive(): boolean {
618
+ return state.status === "active";
619
+ },
620
+
621
+ getState(): RuntimeState {
622
+ return {
623
+ status: state.status,
624
+ info: state.info ? { ...state.info } : null,
625
+ ...(state.reason ? { reason: state.reason } : {}),
626
+ };
627
+ },
628
+
629
+ async withRuntime<T>(callback: (value: RuntimeExecution) => Promise<T>): Promise<T> {
630
+ // Reserve before queuing preflight. A later wake must see this call even
631
+ // while it is awaiting inspect/start/connect; otherwise it can dispose a
632
+ // transport immediately before the callback begins using it.
633
+ reserveUse();
634
+ let activated = false;
635
+ let current: RuntimeExecution;
636
+ try {
637
+ current = await enqueueTransition(async () => {
638
+ const value = await wakeIfNeeded();
639
+ activateUse();
640
+ activated = true;
641
+ return value;
642
+ });
643
+ } catch (error) {
644
+ if (activated) releaseUse(true);
645
+ else releaseUse(false);
646
+ // A wake/reconnect failure must not leave a dead transport owned by a
647
+ // runtime that is now being offered as host fallback. First wait for
648
+ // earlier callbacks, then dispose; their transport remains usable until
649
+ // their callbacks finish.
650
+ await waitForActiveUses();
651
+ await disposeRuntime();
652
+ const held = lock;
653
+ lock = null;
654
+ if (held) {
655
+ try {
656
+ await held.release();
657
+ } catch {
658
+ // The next boot will fail closed if ownership cannot be reacquired.
659
+ }
660
+ }
661
+ if (state.status === "active") setFailure(lastRequest, error);
662
+ throw error;
663
+ }
664
+ try {
665
+ return await callback(current);
666
+ } finally {
667
+ releaseUse(true);
668
+ }
669
+ },
670
+
671
+ async boot(request: BootRequest): Promise<RuntimeState> {
672
+ return enqueue(() => bootInternal(request));
673
+ },
674
+
675
+ async shutdown(): Promise<void> {
676
+ return enqueue(() => shutdownInternal(false));
677
+ },
678
+
679
+ async setEnabled(enabled: boolean): Promise<RuntimeState> {
680
+ return enqueue(async () => {
681
+ if (!enabled) {
682
+ if (state.status === "off" && !lock && !runtime) return manager.getState();
683
+ await shutdownInternal(true);
684
+ return manager.getState();
685
+ }
686
+
687
+ if (state.status === "active") return manager.getState();
688
+ if (!lastRequest) {
689
+ state = { status: "unavailable", info: null, reason: "no sandbox boot request" };
690
+ return manager.getState();
691
+ }
692
+ const request: BootRequest = {
693
+ ...lastRequest,
694
+ restored: retainedState ?? lastRequest.restored,
695
+ };
696
+ return bootInternal(request);
697
+ });
698
+ },
699
+ };
700
+
701
+ return manager;
702
+ }