@cloudflare/sandbox 0.12.1 → 0.12.3

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,5 @@
1
1
  import { _ as GitLogger, b as getEnvString, c as parseSSEFrames, d as createNoOpLogger, f as TraceContext, g as DEFAULT_GIT_CLONE_TIMEOUT_MS, h as ResultImpl, i as isWSStreamChunk, l as shellEscape, m as Execution, n as isWSError, p as logCanonicalEvent, r as isWSResponse, t as generateRequestId, u as createLogger, v as extractRepoName, x as partitionEnvVars, y as filterEnvVars } from "./dist-B_eXrP83.js";
2
- import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-aRUdk9K8.js";
2
+ import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-CpkKPbWC.js";
3
3
  import { Container, ContainerProxy, getContainer, switchPort } from "@cloudflare/containers";
4
4
  import { AwsClient } from "aws4fetch";
5
5
  import { RpcSession, RpcTarget } from "capnweb";
@@ -630,6 +630,26 @@ var BackupRestoreError = class extends SandboxError {
630
630
  }
631
631
  };
632
632
  /**
633
+ * Raised when the sandbox container cannot accept an incoming RPC connection.
634
+ * The container may be starting up, temporarily unhealthy, or was replaced
635
+ * while the connection attempt was in progress.
636
+ *
637
+ * Always retryable: the operation that triggered the connection attempt can
638
+ * be retried once the container is ready.
639
+ */
640
+ var ContainerUnavailableError = class extends SandboxError {
641
+ constructor(errorResponse) {
642
+ super(errorResponse);
643
+ this.name = "ContainerUnavailableError";
644
+ }
645
+ get reason() {
646
+ return this.context.reason;
647
+ }
648
+ get retryAfterMs() {
649
+ return this.context.retryAfterMs;
650
+ }
651
+ };
652
+ /**
633
653
  * Raised when the capnweb WebSocket session itself fails on the SDK side.
634
654
  * Unlike the rest of the SandboxError tree, the container never produces
635
655
  * this error — it is synthesised by `translateRPCError` from the plain
@@ -653,6 +673,20 @@ var RPCTransportError = class extends SandboxError {
653
673
  return this.errorResponse.context.originalMessage;
654
674
  }
655
675
  };
676
+ /**
677
+ * Raised when a sandbox-owned operation was interrupted by a runtime
678
+ * replacement or sandbox lifetime change after the operation was admitted.
679
+ * The caller can retry the full operation when `context.retryable` is true.
680
+ */
681
+ var OperationInterruptedError = class extends SandboxError {
682
+ constructor(errorResponse, options) {
683
+ super(errorResponse, options);
684
+ this.name = "OperationInterruptedError";
685
+ }
686
+ get reason() {
687
+ return this.context.reason;
688
+ }
689
+ };
656
690
 
657
691
  //#endregion
658
692
  //#region src/errors/adapter.ts
@@ -710,6 +744,8 @@ function createErrorFromResponse(errorResponse, options) {
710
744
  case ErrorCode.INTERPRETER_NOT_READY: return new InterpreterNotReadyError(errorResponse);
711
745
  case ErrorCode.CONTEXT_NOT_FOUND: return new ContextNotFoundError(errorResponse);
712
746
  case ErrorCode.CODE_EXECUTION_ERROR: return new CodeExecutionError(errorResponse);
747
+ case ErrorCode.OPERATION_INTERRUPTED: return new OperationInterruptedError(errorResponse, options);
748
+ case ErrorCode.CONTAINER_UNAVAILABLE: return new ContainerUnavailableError(errorResponse);
713
749
  case ErrorCode.RPC_TRANSPORT_ERROR: return new RPCTransportError(errorResponse, options);
714
750
  case ErrorCode.VALIDATION_FAILED: return new ValidationFailedError(errorResponse);
715
751
  case ErrorCode.INVALID_JSON_RESPONSE:
@@ -2451,6 +2487,52 @@ function createTunnelsNotImplemented() {
2451
2487
  } });
2452
2488
  }
2453
2489
 
2490
+ //#endregion
2491
+ //#region src/platform-errors.ts
2492
+ const SUPERSEDED_ISOLATE_PATTERN = /reset because its code was updated|this script has been upgraded/i;
2493
+ const CONNECTION_LOST_PATTERN = /network connection lost/i;
2494
+ const DO_STORAGE_STARTUP_RESET_PATTERN = /internal error while starting up durable object storage caused object to be reset/i;
2495
+ function errorMessageOf(error) {
2496
+ return error instanceof Error ? error.message : typeof error === "string" ? error : "";
2497
+ }
2498
+ function* selfAndCauses(error) {
2499
+ let current = error;
2500
+ for (let depth = 0; depth < 8 && current != null; depth += 1) {
2501
+ yield current;
2502
+ current = typeof current === "object" ? current.cause : void 0;
2503
+ }
2504
+ }
2505
+ function isErrorRetryable(error) {
2506
+ if (typeof error !== "object" || error === null) return false;
2507
+ const message = String(error);
2508
+ const typed = error;
2509
+ return typed.retryable === true && typed.overloaded !== true && !message.includes("Durable Object is overloaded");
2510
+ }
2511
+ /**
2512
+ * Whether an error was raised because the current Durable Object isolate was
2513
+ * superseded by a deploy/code update. In-process retries are futile for this
2514
+ * class because the invocation keeps running on the old isolate; callers
2515
+ * should return control to the platform or retry from a fresh request.
2516
+ */
2517
+ function isDurableObjectCodeUpdateReset(error) {
2518
+ for (const candidate of selfAndCauses(error)) if (SUPERSEDED_ISOLATE_PATTERN.test(errorMessageOf(candidate))) return true;
2519
+ return false;
2520
+ }
2521
+ /**
2522
+ * Whether an error represents a transient platform lifecycle/storage failure,
2523
+ * not an application-level sandbox failure.
2524
+ */
2525
+ function isPlatformTransientError(error) {
2526
+ for (const candidate of selfAndCauses(error)) {
2527
+ const message = errorMessageOf(candidate);
2528
+ if (SUPERSEDED_ISOLATE_PATTERN.test(message)) return true;
2529
+ if (CONNECTION_LOST_PATTERN.test(message)) return true;
2530
+ if (DO_STORAGE_STARTUP_RESET_PATTERN.test(message)) return true;
2531
+ if (isErrorRetryable(candidate)) return true;
2532
+ }
2533
+ return false;
2534
+ }
2535
+
2454
2536
  //#endregion
2455
2537
  //#region ../shared/src/backup.ts
2456
2538
  /**
@@ -2476,8 +2558,484 @@ function normalizeBackupExcludePattern(pattern) {
2476
2558
  //#region ../shared/src/internal.ts
2477
2559
  const DISABLE_SESSION_TOKEN = "__DISABLE_SESSION__";
2478
2560
 
2561
+ //#endregion
2562
+ //#region src/current-runtime-identity.ts
2563
+ const CURRENT_RUNTIME_IDENTITY_STORAGE_KEY = "currentRuntimeIdentity";
2564
+ var RuntimeIdentityInactiveError = class extends Error {
2565
+ constructor() {
2566
+ super("Runtime identity is no longer active");
2567
+ this.name = "RuntimeIdentityInactiveError";
2568
+ }
2569
+ };
2570
+ var RuntimeIdentity = class {
2571
+ id;
2572
+ constructor(record) {
2573
+ this.id = record.id;
2574
+ }
2575
+ owns(record) {
2576
+ return record.runtimeIdentityID === this.id;
2577
+ }
2578
+ scope(value) {
2579
+ return {
2580
+ ...value,
2581
+ runtimeIdentityID: this.id
2582
+ };
2583
+ }
2584
+ };
2585
+ var CurrentRuntimeIdentity = class {
2586
+ /**
2587
+ * Runtime identity is stored in Durable Object storage so a reconstructed DO
2588
+ * can still recognize the live container runtime it owns. In-memory state is
2589
+ * only a cache and cannot define runtime-scoped correctness.
2590
+ */
2591
+ constructor(storage, getContainerState, isContainerRunning) {
2592
+ this.storage = storage;
2593
+ this.getContainerState = getContainerState;
2594
+ this.isContainerRunning = isContainerRunning;
2595
+ }
2596
+ async get() {
2597
+ const status = await this.getStatus();
2598
+ return status.status === "active" ? status.runtime : null;
2599
+ }
2600
+ async getStatus() {
2601
+ const state = await this.getContainerState();
2602
+ if (state.status !== "healthy") return {
2603
+ status: "inactive",
2604
+ reason: "runtime-not-healthy",
2605
+ containerStatus: state.status
2606
+ };
2607
+ if (!this.isContainerRunning()) return {
2608
+ status: "inactive",
2609
+ reason: "runtime-not-running",
2610
+ containerStatus: state.status
2611
+ };
2612
+ const runtime = await this.getStored();
2613
+ if (!runtime) return {
2614
+ status: "inactive",
2615
+ reason: "missing-runtime-id",
2616
+ containerStatus: state.status
2617
+ };
2618
+ return {
2619
+ status: "active",
2620
+ runtime,
2621
+ containerStatus: state.status
2622
+ };
2623
+ }
2624
+ async getStored(storage = this.storage) {
2625
+ const record = await storage.get(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY) ?? null;
2626
+ return record ? new RuntimeIdentity(record) : null;
2627
+ }
2628
+ async markStarted() {
2629
+ const record = { id: crypto.randomUUID() };
2630
+ await this.storage.put(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY, record);
2631
+ return new RuntimeIdentity(record);
2632
+ }
2633
+ async clear() {
2634
+ await this.storage.delete(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY);
2635
+ }
2636
+ async isActive(runtime) {
2637
+ return (await this.get())?.id === runtime.id;
2638
+ }
2639
+ async assertActive(runtime) {
2640
+ if (!await this.isActive(runtime)) throw new RuntimeIdentityInactiveError();
2641
+ }
2642
+ };
2643
+
2644
+ //#endregion
2645
+ //#region src/sandbox-lifetime.ts
2646
+ const SANDBOX_LIFETIME_STORAGE_KEY = "sandbox:lifetime";
2647
+ var SandboxLifetimeChangedError = class extends Error {
2648
+ constructor() {
2649
+ super("Sandbox lifetime is no longer current");
2650
+ this.name = "SandboxLifetimeChangedError";
2651
+ }
2652
+ };
2653
+ var SandboxLifetime = class {
2654
+ id;
2655
+ generation;
2656
+ createdAt;
2657
+ updatedAt;
2658
+ constructor(record) {
2659
+ this.record = record;
2660
+ this.id = record.id;
2661
+ this.generation = record.generation;
2662
+ this.createdAt = record.createdAt;
2663
+ this.updatedAt = record.updatedAt;
2664
+ }
2665
+ owns(record) {
2666
+ return record.sandboxLifetimeID === this.id;
2667
+ }
2668
+ scope(value) {
2669
+ return {
2670
+ ...value,
2671
+ sandboxLifetimeID: this.id
2672
+ };
2673
+ }
2674
+ };
2675
+ var CurrentSandboxLifetime = class {
2676
+ constructor(storage) {
2677
+ this.storage = storage;
2678
+ }
2679
+ async get() {
2680
+ const record = await this.storage.get(SANDBOX_LIFETIME_STORAGE_KEY) ?? null;
2681
+ return record ? new SandboxLifetime(record) : null;
2682
+ }
2683
+ /**
2684
+ * Returns the current lifetime if one exists, or creates and persists a new
2685
+ * one. The id is stable for the lifetime of the sandbox — it only changes
2686
+ * when `rotate()` is called (on `destroy()`).
2687
+ */
2688
+ async getOrCreate() {
2689
+ const existing = await this.get();
2690
+ if (existing) return existing;
2691
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2692
+ const record = {
2693
+ id: crypto.randomUUID(),
2694
+ generation: 1,
2695
+ createdAt: now,
2696
+ updatedAt: now
2697
+ };
2698
+ await this.storage.put(SANDBOX_LIFETIME_STORAGE_KEY, record);
2699
+ return new SandboxLifetime(record);
2700
+ }
2701
+ /**
2702
+ * Creates and persists a new lifetime id, incrementing the generation
2703
+ * counter. Called during `sandbox.destroy()` before runtime state is
2704
+ * cleared so in-flight operations can detect the lifetime change.
2705
+ */
2706
+ async rotate() {
2707
+ const existing = await this.get();
2708
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2709
+ const record = {
2710
+ id: crypto.randomUUID(),
2711
+ generation: (existing?.generation ?? 0) + 1,
2712
+ createdAt: now,
2713
+ updatedAt: now
2714
+ };
2715
+ await this.storage.put(SANDBOX_LIFETIME_STORAGE_KEY, record);
2716
+ return new SandboxLifetime(record);
2717
+ }
2718
+ async isCurrent(lifetime) {
2719
+ return (await this.get())?.id === lifetime.id;
2720
+ }
2721
+ /**
2722
+ * Throws `SandboxLifetimeChangedError` if the given lifetime is no longer
2723
+ * the current one. Used by operation runners to abort operations that
2724
+ * started before a `destroy()` call.
2725
+ */
2726
+ async assertCurrent(lifetime) {
2727
+ if (!await this.isCurrent(lifetime)) throw new SandboxLifetimeChangedError();
2728
+ }
2729
+ };
2730
+
2731
+ //#endregion
2732
+ //#region src/backup/restore-operation-store.ts
2733
+ const OPERATION_STORAGE_PREFIX = "operations:";
2734
+ function backupRestoreOperationKey(backupId, dir) {
2735
+ return `restore:${backupId}:${dir}`;
2736
+ }
2737
+ function createBackupRestoreOperationRecord(params) {
2738
+ return {
2739
+ operationKey: backupRestoreOperationKey(params.backupId, params.dir),
2740
+ kind: "backup.restore",
2741
+ sandboxLifetimeID: params.sandboxLifetimeID,
2742
+ phase: "validating",
2743
+ status: "running",
2744
+ payload: {
2745
+ backupId: params.backupId,
2746
+ dir: params.dir
2747
+ },
2748
+ createdAt: params.now,
2749
+ updatedAt: params.now
2750
+ };
2751
+ }
2752
+ var BackupRestoreOperationStore = class {
2753
+ constructor(storage) {
2754
+ this.storage = storage;
2755
+ }
2756
+ async get(operationKey) {
2757
+ return await this.storage.get(this.storageKey(operationKey)) ?? null;
2758
+ }
2759
+ async put(record) {
2760
+ await this.storage.put(this.storageKey(record.operationKey), record);
2761
+ }
2762
+ storageKey(operationKey) {
2763
+ return `${OPERATION_STORAGE_PREFIX}${operationKey}`;
2764
+ }
2765
+ };
2766
+
2767
+ //#endregion
2768
+ //#region src/backup/restore-lifecycle.ts
2769
+ const BACKUP_RESTORE_MAX_RECOVERY_ATTEMPTS = 2;
2770
+ var RestoreLifecycleRunner = class {
2771
+ operationRecords;
2772
+ constructor(deps) {
2773
+ this.deps = deps;
2774
+ this.operationRecords = new BackupRestoreOperationStore(deps.storage);
2775
+ }
2776
+ async execute(params) {
2777
+ return await this.runWithRecovery(async () => {
2778
+ const { lifetime, operation } = await this.startOperation(params.backupId, params.dir);
2779
+ let currentOperation = operation;
2780
+ let runtime;
2781
+ const context = {
2782
+ lifetime,
2783
+ runtimeReady: async () => {
2784
+ try {
2785
+ runtime = await this.captureRuntime();
2786
+ await this.deps.currentLifetime.assertCurrent(lifetime);
2787
+ } catch (error) {
2788
+ throw await this.translateFenceError(error, currentOperation, "validating", "unknown") ?? error;
2789
+ }
2790
+ currentOperation = await this.markRuntimeReady(currentOperation, runtime);
2791
+ return {
2792
+ runtime,
2793
+ operation: currentOperation
2794
+ };
2795
+ },
2796
+ archiveReady: async (archiveSize) => {
2797
+ if (!runtime) throw new Error("Backup restore archiveReady requires runtimeReady()");
2798
+ try {
2799
+ await this.assertFences(runtime, lifetime);
2800
+ } catch (error) {
2801
+ throw await this.translateFenceError(error, currentOperation, currentOperation.phase, true) ?? error;
2802
+ }
2803
+ currentOperation = await this.markArchiveReady(currentOperation, runtime, archiveSize);
2804
+ return {
2805
+ runtime,
2806
+ operation: currentOperation
2807
+ };
2808
+ },
2809
+ verify: async (result) => {
2810
+ if (!runtime) throw new Error("Backup restore verification requires runtimeReady()");
2811
+ try {
2812
+ await this.assertFences(runtime, lifetime);
2813
+ } catch (error) {
2814
+ throw await this.translateFenceError(error, currentOperation, "archive_ready", true) ?? error;
2815
+ }
2816
+ currentOperation = await this.markVerified(currentOperation, runtime, result);
2817
+ return currentOperation;
2818
+ }
2819
+ };
2820
+ try {
2821
+ return await params.attempt(context);
2822
+ } catch (error) {
2823
+ const translatedRPC = await this.translateRPCError(error, currentOperation);
2824
+ if (translatedRPC) throw translatedRPC;
2825
+ const translatedFence = await this.translateFenceError(error, currentOperation, currentOperation.phase, "unknown");
2826
+ if (translatedFence) throw translatedFence;
2827
+ if (!(error instanceof OperationInterruptedError)) await this.markFailed(currentOperation, error);
2828
+ throw error;
2829
+ }
2830
+ });
2831
+ }
2832
+ async runWithRecovery(attempt) {
2833
+ let recoveryAttempts = 0;
2834
+ while (true) try {
2835
+ return await attempt();
2836
+ } catch (error) {
2837
+ if (!(error instanceof OperationInterruptedError)) throw error;
2838
+ if (!error.context.retryable) throw error;
2839
+ if (recoveryAttempts >= BACKUP_RESTORE_MAX_RECOVERY_ATTEMPTS) throw this.createRecoveryExhaustedError(error, recoveryAttempts);
2840
+ recoveryAttempts++;
2841
+ }
2842
+ }
2843
+ async startOperation(backupId, dir) {
2844
+ const lifetime = await this.deps.currentLifetime.getOrCreate();
2845
+ const operation = createBackupRestoreOperationRecord({
2846
+ sandboxLifetimeID: lifetime.id,
2847
+ backupId,
2848
+ dir,
2849
+ now: (/* @__PURE__ */ new Date()).toISOString()
2850
+ });
2851
+ await this.operationRecords.put(operation);
2852
+ return {
2853
+ lifetime,
2854
+ operation
2855
+ };
2856
+ }
2857
+ async captureRuntime() {
2858
+ const runtime = await this.deps.currentRuntime.get() ?? await this.deps.currentRuntime.markStarted();
2859
+ await this.deps.currentRuntime.assertActive(runtime);
2860
+ return runtime;
2861
+ }
2862
+ async assertFences(runtime, lifetime) {
2863
+ await this.deps.currentRuntime.assertActive(runtime);
2864
+ await this.deps.currentLifetime.assertCurrent(lifetime);
2865
+ }
2866
+ async markRuntimeReady(operation, runtime) {
2867
+ const next = {
2868
+ ...operation,
2869
+ phase: "runtime_ready",
2870
+ runtimeIdentityID: runtime.id,
2871
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2872
+ };
2873
+ await this.operationRecords.put(next);
2874
+ return next;
2875
+ }
2876
+ async markArchiveReady(operation, runtime, archiveSize) {
2877
+ const next = {
2878
+ ...operation,
2879
+ phase: "archive_ready",
2880
+ runtimeIdentityID: runtime.id,
2881
+ payload: {
2882
+ ...operation.payload,
2883
+ ...archiveSize !== void 0 && { archiveSize }
2884
+ },
2885
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2886
+ };
2887
+ await this.operationRecords.put(next);
2888
+ return next;
2889
+ }
2890
+ async markVerified(operation, runtime, result) {
2891
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
2892
+ const next = {
2893
+ ...operation,
2894
+ phase: "verified",
2895
+ status: "committed",
2896
+ runtimeIdentityID: runtime.id,
2897
+ result,
2898
+ completedAt,
2899
+ updatedAt: completedAt
2900
+ };
2901
+ await this.operationRecords.put(next);
2902
+ return next;
2903
+ }
2904
+ async markFailed(operation, error) {
2905
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
2906
+ const message = error instanceof Error ? error.message : String(error);
2907
+ const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "UNKNOWN";
2908
+ const next = {
2909
+ ...operation,
2910
+ phase: "failed",
2911
+ status: "failed",
2912
+ error: {
2913
+ code,
2914
+ message,
2915
+ retryable: false
2916
+ },
2917
+ completedAt,
2918
+ updatedAt: completedAt
2919
+ };
2920
+ await this.operationRecords.put(next);
2921
+ return next;
2922
+ }
2923
+ async markInterrupted(operation, message, retryable) {
2924
+ const next = {
2925
+ ...operation,
2926
+ phase: "interrupted",
2927
+ status: "interrupted",
2928
+ error: {
2929
+ code: ErrorCode.OPERATION_INTERRUPTED,
2930
+ message,
2931
+ retryable
2932
+ },
2933
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2934
+ };
2935
+ await this.operationRecords.put(next);
2936
+ return next;
2937
+ }
2938
+ async translateFenceError(error, operation, phase, admitted) {
2939
+ if (!(error instanceof RuntimeIdentityInactiveError || error instanceof SandboxLifetimeChangedError)) return null;
2940
+ const reason = error instanceof RuntimeIdentityInactiveError ? "runtime_replaced" : "sandbox_lifetime_changed";
2941
+ const retryable = reason !== "sandbox_lifetime_changed";
2942
+ const message = "Backup restore was interrupted before completion could be verified";
2943
+ const interrupted = await this.markInterrupted(operation, message, retryable);
2944
+ return this.createInterruptedError({
2945
+ reason,
2946
+ phase,
2947
+ admitted,
2948
+ retryable,
2949
+ message,
2950
+ operation: interrupted
2951
+ });
2952
+ }
2953
+ async translateRPCError(error, operation) {
2954
+ if (!(error instanceof RPCTransportError)) return null;
2955
+ const message = "Backup restore was interrupted before completion could be verified";
2956
+ const interruptedPhase = operation.phase;
2957
+ const interrupted = await this.markInterrupted(operation, message, true);
2958
+ return this.createInterruptedError({
2959
+ reason: "transport_disposed",
2960
+ phase: interruptedPhase,
2961
+ admitted: "unknown",
2962
+ retryable: true,
2963
+ message,
2964
+ operation: interrupted
2965
+ });
2966
+ }
2967
+ createInterruptedError(params) {
2968
+ return new OperationInterruptedError({
2969
+ message: params.message,
2970
+ code: ErrorCode.OPERATION_INTERRUPTED,
2971
+ httpStatus: 409,
2972
+ context: {
2973
+ reason: params.reason,
2974
+ operation: "backup.restore",
2975
+ phase: params.phase,
2976
+ admitted: params.admitted,
2977
+ retryable: params.retryable
2978
+ },
2979
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2980
+ suggestion: this.suggestionFor(params.retryable)
2981
+ });
2982
+ }
2983
+ createRecoveryExhaustedError(error, recoveryAttempts) {
2984
+ return new OperationInterruptedError({
2985
+ message: "Backup restore recovery attempts were exhausted",
2986
+ code: ErrorCode.OPERATION_INTERRUPTED,
2987
+ httpStatus: 409,
2988
+ context: {
2989
+ reason: "recovery_exhausted",
2990
+ operation: error.context.operation,
2991
+ phase: "interrupted",
2992
+ admitted: error.context.admitted,
2993
+ retryable: false,
2994
+ recoveryAttempts,
2995
+ maxRecoveryAttempts: BACKUP_RESTORE_MAX_RECOVERY_ATTEMPTS
2996
+ },
2997
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2998
+ suggestion: this.suggestionFor(false)
2999
+ });
3000
+ }
3001
+ suggestionFor(retryable) {
3002
+ return retryable ? "Retry restoreBackup() with the same backup handle so the SDK can reconcile the restore operation." : "Start a new restoreBackup() call only if restoring this backup is still desired for the current sandbox.";
3003
+ }
3004
+ };
3005
+
2479
3006
  //#endregion
2480
3007
  //#region src/container-control/connection.ts
3008
+ /**
3009
+ * Attempt to parse a structured CONTAINER_UNAVAILABLE JSON body from a
3010
+ * non-101 upgrade response. Returns a typed ContainerUnavailableError when
3011
+ * the body contains a recognized code, or null if the response is not
3012
+ * JSON / not a container availability error.
3013
+ *
3014
+ * The response body is consumed only once via `clone()` so the caller still
3015
+ * holds the original Response.
3016
+ */
3017
+ async function tryParseContainerUnavailable(response) {
3018
+ if (!(response.headers.get("content-type") ?? "").includes("application/json")) return null;
3019
+ try {
3020
+ const body = await response.clone().json();
3021
+ if (body.code !== ErrorCode.CONTAINER_UNAVAILABLE) return null;
3022
+ const context = {
3023
+ reason: body.context?.reason === "container_starting" || body.context?.reason === "container_unhealthy" || body.context?.reason === "container_replaced" || body.context?.reason === "rpc_upgrade_failed" ? body.context.reason : "container_replaced",
3024
+ retryable: true,
3025
+ ...typeof body.context?.retryAfterMs === "number" && { retryAfterMs: body.context.retryAfterMs }
3026
+ };
3027
+ return createErrorFromResponse({
3028
+ code: ErrorCode.CONTAINER_UNAVAILABLE,
3029
+ message: body.message ?? "Container is unavailable",
3030
+ context,
3031
+ httpStatus: getHttpStatus(ErrorCode.CONTAINER_UNAVAILABLE),
3032
+ suggestion: getSuggestion(ErrorCode.CONTAINER_UNAVAILABLE, context),
3033
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3034
+ });
3035
+ } catch {
3036
+ return null;
3037
+ }
3038
+ }
2481
3039
  const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
2482
3040
  const DEFAULT_RETRY_TIMEOUT_MS = 12e4;
2483
3041
  const MIN_TIME_FOR_RETRY_MS = 15e3;
@@ -2604,7 +3162,22 @@ var ContainerControlConnection = class {
2604
3162
  async doConnect() {
2605
3163
  try {
2606
3164
  const response = await this.fetchUpgradeWithRetry();
2607
- if (response.status !== 101) throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
3165
+ if (response.status !== 101) {
3166
+ const structured = await tryParseContainerUnavailable(response);
3167
+ if (structured) throw structured;
3168
+ if (isRetryableWebSocketUpgradeResponse(response)) throw createErrorFromResponse({
3169
+ code: ErrorCode.CONTAINER_UNAVAILABLE,
3170
+ message: `Container was unavailable after exhausting upgrade retry budget (status ${response.status})`,
3171
+ context: {
3172
+ reason: "rpc_upgrade_failed",
3173
+ retryable: true
3174
+ },
3175
+ httpStatus: getHttpStatus(ErrorCode.CONTAINER_UNAVAILABLE),
3176
+ suggestion: getSuggestion(ErrorCode.CONTAINER_UNAVAILABLE, {}),
3177
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3178
+ });
3179
+ throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
3180
+ }
2608
3181
  const ws = response.webSocket;
2609
3182
  if (!ws) throw new Error("No WebSocket in upgrade response");
2610
3183
  ws.accept();
@@ -2617,6 +3190,7 @@ var ContainerControlConnection = class {
2617
3190
  } catch (error) {
2618
3191
  this.connected = false;
2619
3192
  this.transport.abort(error);
3193
+ this.fireOnClose();
2620
3194
  this.logger.error("ContainerControlConnection failed", error instanceof Error ? error : new Error(String(error)));
2621
3195
  throw error;
2622
3196
  }
@@ -2749,32 +3323,17 @@ const BUSY_POLL_INTERVAL_MS = 1e3;
2749
3323
  */
2750
3324
  const IDLE_IMPORT_THRESHOLD = 1;
2751
3325
  const IDLE_EXPORT_THRESHOLD = 1;
2752
- /**
2753
- * Translate a capnweb-propagated error into a typed SandboxError.
2754
- *
2755
- * Two wire formats are supported for backward compatibility with older
2756
- * container images:
2757
- *
2758
- * 1. Propagated error properties (capnweb >= 0.8.0). The container throws a
2759
- * `ServiceError`-shaped object with own enumerable `code` and `details`
2760
- * properties. capnweb walks `Object.keys()` and reconstructs those fields
2761
- * on the SDK side.
2762
- * 2. Legacy JSON-encoded message. Older containers encoded the structured
2763
- * payload as a JSON string in `error.message`.
2764
- *
2765
- * The JSON-fallback branch can be removed once all older container images are
2766
- * no longer in service.
2767
- */
2768
- function translateRPCError(error) {
3326
+ function translateRPCError(error, context = {}) {
3327
+ if (error instanceof SandboxError) throw error;
2769
3328
  if (error instanceof Error) {
2770
3329
  const propagated = error;
2771
3330
  if (typeof propagated.code === "string" && Object.hasOwn(ErrorCode, propagated.code)) {
2772
3331
  const code = propagated.code;
2773
- const context = propagated.details && typeof propagated.details === "object" ? propagated.details : {};
3332
+ const context$1 = propagated.details && typeof propagated.details === "object" ? propagated.details : {};
2774
3333
  throw createErrorFromResponse({
2775
3334
  code,
2776
3335
  message: error.message,
2777
- context,
3336
+ context: context$1,
2778
3337
  httpStatus: getHttpStatus(code),
2779
3338
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
2780
3339
  });
@@ -2790,7 +3349,8 @@ function translateRPCError(error) {
2790
3349
  httpStatus: getHttpStatus(payload.code),
2791
3350
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
2792
3351
  });
2793
- throw createErrorFromResponse(buildTransportErrorResponse(error), { cause: error });
3352
+ const transportResponse = buildTransportErrorResponse(error);
3353
+ throw createErrorFromResponse(buildInterruptedOperationResponse(transportResponse, context) ?? transportResponse, { cause: error });
2794
3354
  }
2795
3355
  throw createErrorFromResponse(buildTransportErrorResponse(new Error(String(error))), { cause: error });
2796
3356
  }
@@ -2838,6 +3398,27 @@ function buildTransportErrorResponse(error) {
2838
3398
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
2839
3399
  };
2840
3400
  }
3401
+ function buildInterruptedOperationResponse(transportResponse, context) {
3402
+ if (!context.operation) return null;
3403
+ const { kind } = transportResponse.context;
3404
+ if (kind !== "session_disposed" && kind !== "peer_closed" && kind !== "connection_failed") return null;
3405
+ const interruptedContext = {
3406
+ reason: kind === "session_disposed" ? "transport_disposed" : "runtime_replaced",
3407
+ operation: context.operation,
3408
+ phase: "rpc_call",
3409
+ admitted: "unknown",
3410
+ retryable: false
3411
+ };
3412
+ const action = kind === "session_disposed" ? "was closing" : "closed unexpectedly";
3413
+ return {
3414
+ code: ErrorCode.OPERATION_INTERRUPTED,
3415
+ message: `Sandbox operation ${context.operation} was interrupted while the runtime connection ${action}`,
3416
+ context: interruptedContext,
3417
+ httpStatus: getHttpStatus(ErrorCode.OPERATION_INTERRUPTED),
3418
+ suggestion: getSuggestion(ErrorCode.OPERATION_INTERRUPTED, interruptedContext),
3419
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3420
+ };
3421
+ }
2841
3422
  /**
2842
3423
  * Wrap a capnweb RPC stub so that every method call translates errors
2843
3424
  * from the JSON wire format into typed SandboxError instances and signals
@@ -2853,18 +3434,19 @@ function buildTransportErrorResponse(error) {
2853
3434
  * settles — capnweb keeps the export alive until the stream ends. The
2854
3435
  * busy/idle poll on `getStats()` is the source of truth for that.
2855
3436
  */
2856
- function wrapStub(stub, onCallStarted) {
3437
+ function wrapStub(stub, domain, onCallStarted) {
2857
3438
  return new Proxy(stub, { get(target, prop, receiver) {
2858
3439
  const value = Reflect.get(target, prop, receiver);
2859
3440
  if (typeof value !== "function") return value;
2860
3441
  return (...args) => {
2861
3442
  onCallStarted();
3443
+ const operation = typeof prop === "string" ? `${domain}.${prop}` : domain;
2862
3444
  try {
2863
3445
  const result = Reflect.apply(value, target, args);
2864
- if (result != null && typeof result.then === "function") return result.catch(translateRPCError);
3446
+ if (result != null && typeof result.then === "function") return result.catch((err) => translateRPCError(err, { operation }));
2865
3447
  return result;
2866
3448
  } catch (err) {
2867
- translateRPCError(err);
3449
+ translateRPCError(err, { operation });
2868
3450
  }
2869
3451
  };
2870
3452
  } });
@@ -3003,34 +3585,34 @@ var ContainerControlClient = class {
3003
3585
  }
3004
3586
  }
3005
3587
  get commands() {
3006
- return wrapStub(this.getConnection().rpc().commands, this.renewActivity);
3588
+ return wrapStub(this.getConnection().rpc().commands, "commands", this.renewActivity);
3007
3589
  }
3008
3590
  get files() {
3009
- return wrapStub(this.getConnection().rpc().files, this.renewActivity);
3591
+ return wrapStub(this.getConnection().rpc().files, "files", this.renewActivity);
3010
3592
  }
3011
3593
  get processes() {
3012
- return wrapStub(this.getConnection().rpc().processes, this.renewActivity);
3594
+ return wrapStub(this.getConnection().rpc().processes, "processes", this.renewActivity);
3013
3595
  }
3014
3596
  get ports() {
3015
- return wrapStub(this.getConnection().rpc().ports, this.renewActivity);
3597
+ return wrapStub(this.getConnection().rpc().ports, "ports", this.renewActivity);
3016
3598
  }
3017
3599
  get git() {
3018
- return wrapStub(this.getConnection().rpc().git, this.renewActivity);
3600
+ return wrapStub(this.getConnection().rpc().git, "git", this.renewActivity);
3019
3601
  }
3020
3602
  get utils() {
3021
- return wrapStub(this.getConnection().rpc().utils, this.renewActivity);
3603
+ return wrapStub(this.getConnection().rpc().utils, "utils", this.renewActivity);
3022
3604
  }
3023
3605
  get backup() {
3024
- return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
3606
+ return wrapStub(this.getConnection().rpc().backup, "backup", this.renewActivity);
3025
3607
  }
3026
3608
  get watch() {
3027
- return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
3609
+ return wrapStub(this.getConnection().rpc().watch, "watch", this.renewActivity);
3028
3610
  }
3029
3611
  get tunnels() {
3030
- return wrapStub(this.getConnection().rpc().tunnels, this.renewActivity);
3612
+ return wrapStub(this.getConnection().rpc().tunnels, "tunnels", this.renewActivity);
3031
3613
  }
3032
3614
  get interpreter() {
3033
- return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
3615
+ return wrapStub(this.getConnection().rpc().interpreter, "interpreter", this.renewActivity);
3034
3616
  }
3035
3617
  /**
3036
3618
  * Update the upgrade retry budget. Applies to the current connection
@@ -3055,89 +3637,6 @@ var ContainerControlClient = class {
3055
3637
  }
3056
3638
  };
3057
3639
 
3058
- //#endregion
3059
- //#region src/current-runtime-identity.ts
3060
- const CURRENT_RUNTIME_IDENTITY_STORAGE_KEY = "currentRuntimeIdentity";
3061
- var RuntimeIdentityInactiveError = class extends Error {
3062
- constructor() {
3063
- super("Runtime identity is no longer active");
3064
- this.name = "RuntimeIdentityInactiveError";
3065
- }
3066
- };
3067
- var RuntimeIdentity = class {
3068
- id;
3069
- constructor(record) {
3070
- this.id = record.id;
3071
- }
3072
- owns(record) {
3073
- return record.runtimeIdentityID === this.id;
3074
- }
3075
- scope(value) {
3076
- return {
3077
- ...value,
3078
- runtimeIdentityID: this.id
3079
- };
3080
- }
3081
- };
3082
- var CurrentRuntimeIdentity = class {
3083
- /**
3084
- * Runtime identity is stored in Durable Object storage so a reconstructed DO
3085
- * can still recognize the live container runtime it owns. In-memory state is
3086
- * only a cache and cannot define runtime-scoped correctness.
3087
- */
3088
- constructor(storage, getContainerState, isContainerRunning) {
3089
- this.storage = storage;
3090
- this.getContainerState = getContainerState;
3091
- this.isContainerRunning = isContainerRunning;
3092
- }
3093
- async get() {
3094
- const status = await this.getStatus();
3095
- return status.status === "active" ? status.runtime : null;
3096
- }
3097
- async getStatus() {
3098
- const state = await this.getContainerState();
3099
- if (state.status !== "healthy") return {
3100
- status: "inactive",
3101
- reason: "runtime-not-healthy",
3102
- containerStatus: state.status
3103
- };
3104
- if (!this.isContainerRunning()) return {
3105
- status: "inactive",
3106
- reason: "runtime-not-running",
3107
- containerStatus: state.status
3108
- };
3109
- const runtime = await this.getStored();
3110
- if (!runtime) return {
3111
- status: "inactive",
3112
- reason: "missing-runtime-id",
3113
- containerStatus: state.status
3114
- };
3115
- return {
3116
- status: "active",
3117
- runtime,
3118
- containerStatus: state.status
3119
- };
3120
- }
3121
- async getStored(storage = this.storage) {
3122
- const record = await storage.get(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY) ?? null;
3123
- return record ? new RuntimeIdentity(record) : null;
3124
- }
3125
- async markStarted() {
3126
- const record = { id: crypto.randomUUID() };
3127
- await this.storage.put(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY, record);
3128
- return new RuntimeIdentity(record);
3129
- }
3130
- async clear() {
3131
- await this.storage.delete(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY);
3132
- }
3133
- async isActive(runtime) {
3134
- return (await this.get())?.id === runtime.id;
3135
- }
3136
- async assertActive(runtime) {
3137
- if (!await this.isActive(runtime)) throw new RuntimeIdentityInactiveError();
3138
- }
3139
- };
3140
-
3141
3640
  //#endregion
3142
3641
  //#region src/file-stream.ts
3143
3642
  /**
@@ -4143,6 +4642,18 @@ async function proxyTerminal(stub, sessionId, request, options) {
4143
4642
  return stub.fetch(switchPort(ptyRequest, 3e3));
4144
4643
  }
4145
4644
 
4645
+ //#endregion
4646
+ //#region src/session-init.ts
4647
+ var SessionInitInvalidatedError = class extends Error {
4648
+ constructor() {
4649
+ super("Default session initialization was invalidated by a container stop");
4650
+ this.name = "SessionInitInvalidatedError";
4651
+ }
4652
+ };
4653
+ function isSessionInitInvalidated(error) {
4654
+ return error instanceof SessionInitInvalidatedError;
4655
+ }
4656
+
4146
4657
  //#endregion
4147
4658
  //#region src/storage-mount/r2-egress-handler.ts
4148
4659
  const XML_NS = "xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"";
@@ -5058,17 +5569,18 @@ var SandboxControlCallbackImpl = class extends RpcTarget {
5058
5569
  this.getHandler = getHandler;
5059
5570
  this.logger = logger;
5060
5571
  }
5061
- async onTunnelExit(id, port, exitCode) {
5572
+ async onTunnelExit(id, port, exitCode, runId) {
5062
5573
  const handler = this.getHandler();
5063
5574
  if (!handler) {
5064
5575
  this.logger.debug("onTunnelExit: no handler bound; ignoring", {
5065
5576
  id,
5066
5577
  port,
5067
- exitCode
5578
+ exitCode,
5579
+ runId
5068
5580
  });
5069
5581
  return;
5070
5582
  }
5071
- await handler(id, port, exitCode);
5583
+ await handler(id, port, exitCode, runId);
5072
5584
  }
5073
5585
  };
5074
5586
 
@@ -5295,6 +5807,111 @@ async function deleteDNSRecord(args) {
5295
5807
  });
5296
5808
  }
5297
5809
 
5810
+ //#endregion
5811
+ //#region src/tunnels/random-id.ts
5812
+ const ID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
5813
+ /**
5814
+ * Generate a URL- and DNS-safe random id. The default 20 characters give
5815
+ * ~100 bits of entropy, which keeps collisions negligible at any realistic
5816
+ * volume without a uniqueness retry loop. 32 divides 256 evenly, so masking
5817
+ * the low 5 bits of each random byte stays uniform.
5818
+ */
5819
+ function randomId(size = 20) {
5820
+ const bytes = new Uint8Array(size);
5821
+ crypto.getRandomValues(bytes);
5822
+ let id = "";
5823
+ for (let i = 0; i < size; i += 1) id += ID_ALPHABET[bytes[i] & 31];
5824
+ return id;
5825
+ }
5826
+
5827
+ //#endregion
5828
+ //#region src/tunnels/lifecycle.ts
5829
+ const TUNNEL_GET_MAX_RECOVERY_ATTEMPTS = 2;
5830
+ function runtimeRunId() {
5831
+ return `run-${randomId()}`;
5832
+ }
5833
+ function createTunnelInterruptedError(params) {
5834
+ return new OperationInterruptedError({
5835
+ message: params.reason === "recovery_exhausted" ? "Tunnel recovery attempts were exhausted" : "Tunnel operation was interrupted by a sandbox runtime change",
5836
+ code: ErrorCode.OPERATION_INTERRUPTED,
5837
+ httpStatus: 409,
5838
+ context: {
5839
+ reason: params.reason,
5840
+ operation: "tunnel.get",
5841
+ phase: params.phase,
5842
+ admitted: params.admitted,
5843
+ retryable: params.retryable,
5844
+ ...params.recoveryAttempts !== void 0 && { recoveryAttempts: params.recoveryAttempts },
5845
+ ...params.maxRecoveryAttempts !== void 0 && { maxRecoveryAttempts: params.maxRecoveryAttempts }
5846
+ },
5847
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5848
+ suggestion: params.retryable ? "Retry tunnels.get() for the same port so the SDK can establish a fresh tunnel run." : "Start a new tunnels.get() call only if this tunnel is still desired for the current sandbox."
5849
+ });
5850
+ }
5851
+ function translateTunnelInterruption(error, phase, admitted) {
5852
+ if (error instanceof OperationInterruptedError) return error;
5853
+ if (error instanceof RuntimeIdentityInactiveError) return createTunnelInterruptedError({
5854
+ reason: "runtime_replaced",
5855
+ phase,
5856
+ admitted,
5857
+ retryable: true
5858
+ });
5859
+ if (error instanceof SandboxLifetimeChangedError) return createTunnelInterruptedError({
5860
+ reason: "sandbox_lifetime_changed",
5861
+ phase,
5862
+ admitted,
5863
+ retryable: false
5864
+ });
5865
+ if (error instanceof RPCTransportError) return createTunnelInterruptedError({
5866
+ reason: "transport_disposed",
5867
+ phase,
5868
+ admitted: "unknown",
5869
+ retryable: true
5870
+ });
5871
+ return null;
5872
+ }
5873
+
5874
+ //#endregion
5875
+ //#region src/tunnels/storage.ts
5876
+ const STORAGE_KEY = "tunnels";
5877
+ const META_STORAGE_KEY = "tunnels:meta";
5878
+ async function readMap(storage) {
5879
+ return await storage.get(STORAGE_KEY) ?? {};
5880
+ }
5881
+ async function readMetaMap(storage) {
5882
+ return await storage.get(META_STORAGE_KEY) ?? {};
5883
+ }
5884
+ function computeOptionsHash(options) {
5885
+ if (!options || !options.name) return "v1:quick";
5886
+ return `v1:named:${options.name}`;
5887
+ }
5888
+ function normaliseHash(hash) {
5889
+ return hash.startsWith("v1:") ? hash.slice(3) : hash;
5890
+ }
5891
+ function optionsHashesEqual(a, b) {
5892
+ return normaliseHash(a) === normaliseHash(b);
5893
+ }
5894
+
5895
+ //#endregion
5896
+ //#region src/tunnels/restart.ts
5897
+ async function pruneTunnelsForRestart(storage) {
5898
+ await storage.transaction(async (txn) => {
5899
+ const map = await readMap(txn);
5900
+ const meta = await readMetaMap(txn);
5901
+ const nextMap = {};
5902
+ const nextMeta = {};
5903
+ for (const [portKey, info] of Object.entries(map)) if (info.name) {
5904
+ nextMap[portKey] = info;
5905
+ nextMeta[portKey] = {
5906
+ ...meta[portKey] ?? { optionsHash: `v1:named:${info.name}` },
5907
+ needsRespawn: true
5908
+ };
5909
+ }
5910
+ await txn.put(STORAGE_KEY, nextMap);
5911
+ await txn.put(META_STORAGE_KEY, nextMeta);
5912
+ });
5913
+ }
5914
+
5298
5915
  //#endregion
5299
5916
  //#region src/tunnels/tunnels-handler.ts
5300
5917
  /**
@@ -5302,21 +5919,11 @@ async function deleteDNSRecord(args) {
5302
5919
  * `createTunnelsHandler(host)` and exposed as `sandbox.tunnels`.
5303
5920
  *
5304
5921
  * Storage is the source of truth. The DO holds a `Record<portString, TunnelInfo>`
5305
- * under the `tunnels` storage key. `Sandbox.onStart()` clears the key on every
5306
- * container restart so any record in storage is by construction backed by a
5307
- * running `cloudflared` process; the handler never needs to verify that
5308
- * separately against the container.
5309
- */
5310
- /** DO storage key for the `port → TunnelInfo` map. */
5311
- const STORAGE_KEY = "tunnels";
5312
- /**
5313
- * Sidecar storage key for per-port metadata the handler needs but the
5314
- * public `TunnelInfo` shape does not carry: the options hash used to
5315
- * detect divergent retries, and (for named tunnels) the DNS record id
5316
- * needed for cleanup. Kept under a separate key so the existing
5317
- * `tunnels` shape remains a clean `Record<port, TunnelInfo>`.
5922
+ * under the `tunnels` storage key, with internal lifecycle metadata under
5923
+ * `tunnels:meta`. Container restarts drop quick tunnel records and mark named
5924
+ * tunnels for respawn, so public list/get calls only return records that are
5925
+ * usable for the current runtime.
5318
5926
  */
5319
- const META_STORAGE_KEY = "tunnels:meta";
5320
5927
  function validateTunnelPort(port) {
5321
5928
  if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding reserved ports.`);
5322
5929
  }
@@ -5332,9 +5939,9 @@ function shortId() {
5332
5939
  * the nested `errorResponse.code`. Used for the few error codes the SDK
5333
5940
  * recognises and recovers from (TUNNEL_NOT_FOUND, TUNNEL_ALREADY_RUNNING).
5334
5941
  *
5335
- * Previous versions matched by substring on `error.message`, which
5336
- * false-positived on any error whose message merely quoted the literal
5337
- * code token.
5942
+ * Message strings may quote error-code tokens for diagnostics, so matching
5943
+ * only structured fields keeps recovery branches tied to explicit error
5944
+ * contracts.
5338
5945
  */
5339
5946
  function hasErrorCode(error, code) {
5340
5947
  if (!error || typeof error !== "object") return false;
@@ -5349,35 +5956,6 @@ function isTunnelNotFoundError(error) {
5349
5956
  function isTunnelAlreadyRunningError(error) {
5350
5957
  return hasErrorCode(error, "TUNNEL_ALREADY_RUNNING");
5351
5958
  }
5352
- async function readMap(storage) {
5353
- return await storage.get(STORAGE_KEY) ?? {};
5354
- }
5355
- async function readMetaMap(storage) {
5356
- return await storage.get(META_STORAGE_KEY) ?? {};
5357
- }
5358
- /**
5359
- * Stable hash of `options`. Empty/undefined options collapse to the same
5360
- * hash so `get(port)`, `get(port, {})`, and `get(port, { name: undefined })`
5361
- * all hit the same cache entry. Named tunnels hash on `name` alone (the
5362
- * only option today).
5363
- *
5364
- * The `v1:` prefix exists so a future addition of a second option (e.g.
5365
- * `subdomain`) can change the canonical form without colliding with an
5366
- * older record's hash. Comparison goes through `optionsHashesEqual`, which
5367
- * normalises legacy unversioned hashes (`quick`, `named:foo`) to their v1
5368
- * form before equality, so upgrading does not invalidate cached records.
5369
- */
5370
- function computeOptionsHash(options) {
5371
- if (!options || !options.name) return "v1:quick";
5372
- return `v1:named:${options.name}`;
5373
- }
5374
- /** Strip the optional `v1:` prefix so legacy hashes compare equal. */
5375
- function normaliseHash(hash) {
5376
- return hash.startsWith("v1:") ? hash.slice(3) : hash;
5377
- }
5378
- function optionsHashesEqual(a, b) {
5379
- return normaliseHash(a) === normaliseHash(b);
5380
- }
5381
5959
  /**
5382
5960
  * Concrete `TunnelsHandler` implementation.
5383
5961
  *
@@ -5442,11 +6020,15 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
5442
6020
  validateTunnelPort(port);
5443
6021
  if (options?.name !== void 0) validateTunnelName(options.name);
5444
6022
  const requestedHash = computeOptionsHash(options);
5445
- const info = await this.#withPortLock(port, async () => {
6023
+ const info = await this.#withPortLock(port, () => this.#getWithRecovery(port, options, async (recovery) => {
5446
6024
  const existing = (await readMap(this.#host.storage))[port.toString()];
5447
6025
  if (existing) {
5448
6026
  const metaEntry = (await readMetaMap(this.#host.storage))[port.toString()];
5449
6027
  if (!optionsHashesEqual(metaEntry?.optionsHash ?? (existing.name ? `v1:named:${existing.name}` : "v1:quick"), requestedHash)) throw new Error(`Tunnel on port ${port} was created with different options. Call destroy(${port}) before changing tunnel options.`);
6028
+ if (!existing.name && await this.#quickTunnelRecordIsStale(metaEntry)) {
6029
+ await this.#stopStaleQuickTunnel(existing, metaEntry);
6030
+ return await this.#provisionQuickTunnel(port, recovery);
6031
+ }
5450
6032
  if (metaEntry?.needsRespawn && existing.name) return await this.#provisionNamedTunnel(port, existing.name);
5451
6033
  if (existing.name && this.#host.getNamedTunnelConfig) {
5452
6034
  const currentConfig = await this.#host.getNamedTunnelConfig();
@@ -5461,8 +6043,8 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
5461
6043
  return existing;
5462
6044
  }
5463
6045
  if (options?.name) return await this.#provisionNamedTunnel(port, options.name);
5464
- return await this.#provisionQuickTunnel(port);
5465
- });
6046
+ return await this.#provisionQuickTunnel(port, recovery);
6047
+ }));
5466
6048
  outcome = "success";
5467
6049
  return info;
5468
6050
  } catch (error) {
@@ -5479,6 +6061,58 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
5479
6061
  });
5480
6062
  }
5481
6063
  }
6064
+ async #getWithRecovery(port, options, run) {
6065
+ let recoveryAttempts = 0;
6066
+ const recovery = {};
6067
+ while (true) try {
6068
+ return await run(recovery);
6069
+ } catch (error) {
6070
+ const interrupted = translateTunnelInterruption(error, "provisioning", "unknown");
6071
+ if (!interrupted || options?.name || !interrupted.context.retryable) throw error;
6072
+ if (recoveryAttempts >= TUNNEL_GET_MAX_RECOVERY_ATTEMPTS) {
6073
+ await this.#clearQuickTunnelStorage(port);
6074
+ throw createTunnelInterruptedError({
6075
+ reason: "recovery_exhausted",
6076
+ phase: "interrupted",
6077
+ admitted: interrupted.context.admitted,
6078
+ retryable: false,
6079
+ recoveryAttempts,
6080
+ maxRecoveryAttempts: TUNNEL_GET_MAX_RECOVERY_ATTEMPTS
6081
+ });
6082
+ }
6083
+ recoveryAttempts++;
6084
+ if (interrupted.context.reason === "runtime_replaced") recovery.quickRun = void 0;
6085
+ await this.#clearQuickTunnelStorage(port);
6086
+ }
6087
+ }
6088
+ async #clearQuickTunnelStorage(port) {
6089
+ await this.#host.storage.transaction(async (txn) => {
6090
+ const map = await readMap(txn);
6091
+ delete map[port.toString()];
6092
+ await txn.put(STORAGE_KEY, map);
6093
+ const meta = await readMetaMap(txn);
6094
+ delete meta[port.toString()];
6095
+ await txn.put(META_STORAGE_KEY, meta);
6096
+ });
6097
+ }
6098
+ async #quickTunnelRecordIsStale(metaEntry) {
6099
+ if (!this.#host.currentRuntime || !this.#host.currentLifetime) return false;
6100
+ if (!metaEntry?.runtimeIdentityID || !metaEntry.sandboxLifetimeID) return true;
6101
+ const runtime = await this.#host.currentRuntime.get();
6102
+ const lifetime = await this.#host.currentLifetime.getOrCreate();
6103
+ return runtime?.id !== metaEntry.runtimeIdentityID || lifetime.id !== metaEntry.sandboxLifetimeID;
6104
+ }
6105
+ async #stopStaleQuickTunnel(existing, metaEntry) {
6106
+ try {
6107
+ if (metaEntry?.tunnelRunId) await this.#host.client.tunnels.stopTunnelRun({
6108
+ tunnelId: existing.id,
6109
+ runId: metaEntry.tunnelRunId
6110
+ });
6111
+ else await this.#host.client.tunnels.destroyTunnel(existing.id);
6112
+ } catch (error) {
6113
+ if (!isTunnelNotFoundError(error)) throw error;
6114
+ }
6115
+ }
5482
6116
  /**
5483
6117
  * Provision a fresh quick tunnel and persist it. Caller holds the
5484
6118
  * per-port lock.
@@ -5490,7 +6124,8 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
5490
6124
  * surfacing the confusing error — the retry budget caps the loop so a
5491
6125
  * persistent failure still surfaces.
5492
6126
  */
5493
- async #provisionQuickTunnel(port) {
6127
+ async #provisionQuickTunnel(port, recovery) {
6128
+ if (this.#host.currentRuntime && this.#host.currentLifetime) return await this.#provisionQuickTunnelRun(port, recovery);
5494
6129
  const MAX_ID_RETRIES = 3;
5495
6130
  let lastError;
5496
6131
  for (let attempt = 0; attempt < MAX_ID_RETRIES; attempt += 1) {
@@ -5513,6 +6148,65 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
5513
6148
  }
5514
6149
  throw lastError ?? /* @__PURE__ */ new Error("Failed to mint a unique quick-tunnel id");
5515
6150
  }
6151
+ async #provisionQuickTunnelRun(port, recovery) {
6152
+ if (!this.#host.currentRuntime || !this.#host.currentLifetime) throw new Error("Quick tunnel runtime fences are not configured");
6153
+ const lifetime = await this.#host.currentLifetime.getOrCreate();
6154
+ const runtimeBeforeAdmission = await this.#host.currentRuntime.get();
6155
+ try {
6156
+ await this.#host.currentLifetime.assertCurrent(lifetime);
6157
+ } catch (error) {
6158
+ throw translateTunnelInterruption(error, "runtime_ready", "unknown") ?? error;
6159
+ }
6160
+ recovery.quickRun ??= {
6161
+ tunnelId: `quick-${randomId()}`,
6162
+ runId: runtimeRunId()
6163
+ };
6164
+ const { tunnelId, runId } = recovery.quickRun;
6165
+ let ensureResult;
6166
+ let runtime = runtimeBeforeAdmission;
6167
+ try {
6168
+ ensureResult = await this.#host.client.tunnels.ensureTunnelRun({
6169
+ tunnelId,
6170
+ runId,
6171
+ mode: "quick",
6172
+ port
6173
+ });
6174
+ runtime ??= await this.#host.currentRuntime.get() ?? await this.#host.currentRuntime.markStarted();
6175
+ await this.#host.currentRuntime.assertActive(runtime);
6176
+ await this.#host.currentLifetime.assertCurrent(lifetime);
6177
+ } catch (error) {
6178
+ throw translateTunnelInterruption(error, "runtime_ready", "unknown") ?? error;
6179
+ }
6180
+ const { run } = ensureResult;
6181
+ if (!run.url || !run.hostname) throw new Error("Quick tunnel run did not produce a public URL");
6182
+ const spawned = {
6183
+ id: run.tunnelId,
6184
+ port: run.port,
6185
+ url: run.url,
6186
+ hostname: run.hostname,
6187
+ createdAt: run.startedAt
6188
+ };
6189
+ await this.#host.storage.transaction(async (txn) => {
6190
+ const nextMap = await readMap(txn);
6191
+ nextMap[port.toString()] = spawned;
6192
+ await txn.put(STORAGE_KEY, nextMap);
6193
+ const nextMeta = await readMetaMap(txn);
6194
+ nextMeta[port.toString()] = {
6195
+ optionsHash: "v1:quick",
6196
+ runtimeIdentityID: runtime.id,
6197
+ sandboxLifetimeID: lifetime.id,
6198
+ tunnelRunId: run.runId
6199
+ };
6200
+ await txn.put(META_STORAGE_KEY, nextMeta);
6201
+ });
6202
+ try {
6203
+ await this.#host.currentRuntime.assertActive(runtime);
6204
+ await this.#host.currentLifetime.assertCurrent(lifetime);
6205
+ } catch (error) {
6206
+ throw translateTunnelInterruption(error, "storage_committed", true) ?? error;
6207
+ }
6208
+ return spawned;
6209
+ }
5516
6210
  /**
5517
6211
  * Provision a named tunnel end-to-end:
5518
6212
  * 1. resolve credentials + zone name
@@ -5623,7 +6317,11 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
5623
6317
  await txn.put(META_STORAGE_KEY, currentMeta);
5624
6318
  });
5625
6319
  try {
5626
- await this.#host.client.tunnels.destroyTunnel(existing.id);
6320
+ if (metaBefore?.tunnelRunId) await this.#host.client.tunnels.stopTunnelRun({
6321
+ tunnelId: existing.id,
6322
+ runId: metaBefore.tunnelRunId
6323
+ });
6324
+ else await this.#host.client.tunnels.destroyTunnel(existing.id);
5627
6325
  } catch (error) {
5628
6326
  if (isTunnelNotFoundError(error)) {} else if (metaBefore?.dnsRecordId) this.#host.logger.warn("tunnel.destroy: container tunnel cleanup failed", {
5629
6327
  port,
@@ -5704,7 +6402,7 @@ function createTunnelsHandler(host) {
5704
6402
  return next;
5705
6403
  };
5706
6404
  const tunnels = new TunnelsRpcTarget(host, withPortLock);
5707
- const handleTunnelExit = async (id, port, exitCode) => {
6405
+ const handleTunnelExit = async (id, port, exitCode, runId) => {
5708
6406
  const startTime = Date.now();
5709
6407
  let outcome = "error";
5710
6408
  let caughtError;
@@ -5714,19 +6412,20 @@ function createTunnelsHandler(host) {
5714
6412
  const map = await readMap(txn);
5715
6413
  const existing = map[port.toString()];
5716
6414
  if (existing?.id !== id) return;
6415
+ const meta = await readMetaMap(txn);
6416
+ const metaEntry = meta[port.toString()];
6417
+ if (runId && metaEntry?.tunnelRunId && metaEntry.tunnelRunId !== runId) return;
5717
6418
  if (existing.name) {
5718
- const meta$1 = await readMetaMap(txn);
5719
- meta$1[port.toString()] = {
5720
- ...meta$1[port.toString()],
5721
- optionsHash: meta$1[port.toString()]?.optionsHash ?? `v1:named:${existing.name}`,
6419
+ meta[port.toString()] = {
6420
+ ...metaEntry,
6421
+ optionsHash: metaEntry?.optionsHash ?? `v1:named:${existing.name}`,
5722
6422
  needsRespawn: true
5723
6423
  };
5724
- await txn.put(META_STORAGE_KEY, meta$1);
6424
+ await txn.put(META_STORAGE_KEY, meta);
5725
6425
  return;
5726
6426
  }
5727
6427
  delete map[port.toString()];
5728
6428
  await txn.put(STORAGE_KEY, map);
5729
- const meta = await readMetaMap(txn);
5730
6429
  delete meta[port.toString()];
5731
6430
  await txn.put(META_STORAGE_KEY, meta);
5732
6431
  });
@@ -5780,52 +6479,6 @@ function createTunnelsHandler(host) {
5780
6479
  destroyAll
5781
6480
  };
5782
6481
  }
5783
- /**
5784
- * Reconcile storage with a fresh container.
5785
- *
5786
- * Called from `Sandbox.onStart()` after every container restart. The
5787
- * `cloudflared` processes the container was running all died with it, so
5788
- * any stored record is *not* currently backed by a running tunnel.
5789
- *
5790
- * Two tunnel flavours, two recovery stories:
5791
- *
5792
- * - Quick tunnels: the `*.trycloudflare.com` URL is bound to the dead
5793
- * `cloudflared` process. Nothing on Cloudflare's side outlives the
5794
- * container, and the URL is unrecoverable. Drop the record from both
5795
- * maps so the next `get(port)` takes the miss branch and mints a new
5796
- * URL.
5797
- * - Named tunnels: the Cloudflare-side tunnel + DNS record survive.
5798
- * The hostname is stable, the DNS still resolves to
5799
- * `<tunnelId>.cfargotunnel.com`, and the next caller can reuse both
5800
- * by walking the same `findTunnelByName` / `upsertCNAME` path the
5801
- * SDK uses for retries. Keep the record in storage and mark the
5802
- * meta entry `needsRespawn: true`; the next `get(port, { name })`
5803
- * cache hit falls through to `#provisionNamedTunnel` to respawn
5804
- * `cloudflared`.
5805
- *
5806
- * Crucially, named-tunnel metadata (including `dnsRecordId`) is
5807
- * preserved so `destroy(port)` and `sandbox.destroy()` can still clean
5808
- * up the Cloudflare-side resources after a restart. Wiping meta
5809
- * unconditionally — the previous behaviour — silently leaked the tunnel
5810
- * and DNS record on every restart.
5811
- */
5812
- async function pruneTunnelsForRestart(storage) {
5813
- await storage.transaction(async (txn) => {
5814
- const map = await readMap(txn);
5815
- const meta = await readMetaMap(txn);
5816
- const nextMap = {};
5817
- const nextMeta = {};
5818
- for (const [portKey, info] of Object.entries(map)) if (info.name) {
5819
- nextMap[portKey] = info;
5820
- nextMeta[portKey] = {
5821
- ...meta[portKey] ?? { optionsHash: `v1:named:${info.name}` },
5822
- needsRespawn: true
5823
- };
5824
- }
5825
- await txn.put(STORAGE_KEY, nextMap);
5826
- await txn.put(META_STORAGE_KEY, nextMeta);
5827
- });
5828
- }
5829
6482
 
5830
6483
  //#endregion
5831
6484
  //#region src/version.ts
@@ -5834,7 +6487,7 @@ async function pruneTunnelsForRestart(storage) {
5834
6487
  * This file is auto-updated by .github/changeset-version.ts during releases
5835
6488
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
5836
6489
  */
5837
- const SDK_VERSION = "0.12.1";
6490
+ const SDK_VERSION = "0.12.3";
5838
6491
 
5839
6492
  //#endregion
5840
6493
  //#region src/sandbox.ts
@@ -5997,6 +6650,38 @@ function applySandboxConfiguration(stub, configuration) {
5997
6650
  if (configuration.transport !== void 0) operations.push(stub.setTransport?.(configuration.transport) ?? Promise.resolve());
5998
6651
  return Promise.all(operations).then(() => void 0);
5999
6652
  }
6653
+ function createPlatformInterruptedError(error, operation) {
6654
+ if (!isPlatformTransientError(error)) return null;
6655
+ const context = {
6656
+ reason: "runtime_replaced",
6657
+ operation,
6658
+ phase: "durable_object_call",
6659
+ admitted: "unknown",
6660
+ retryable: false
6661
+ };
6662
+ return new OperationInterruptedError({
6663
+ code: ErrorCode.OPERATION_INTERRUPTED,
6664
+ message: `Sandbox operation ${operation} was interrupted while the platform was updating the sandbox runtime`,
6665
+ context,
6666
+ httpStatus: getHttpStatus(ErrorCode.OPERATION_INTERRUPTED),
6667
+ suggestion: getSuggestion(ErrorCode.OPERATION_INTERRUPTED, context),
6668
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6669
+ }, { cause: error });
6670
+ }
6671
+ function translatePlatformInterruption(error, operation) {
6672
+ throw createPlatformInterruptedError(error, operation) ?? error;
6673
+ }
6674
+ function withSandboxOperationContext(operation, fn) {
6675
+ return (...args) => {
6676
+ try {
6677
+ const result = fn(...args);
6678
+ if (result != null && typeof result.then === "function") return result.catch((error) => translatePlatformInterruption(error, operation));
6679
+ return result;
6680
+ } catch (error) {
6681
+ translatePlatformInterruption(error, operation);
6682
+ }
6683
+ };
6684
+ }
6000
6685
  function getSandbox(ns, id, options) {
6001
6686
  const sanitizedId = sanitizeSandboxId(id);
6002
6687
  const effectiveId = options?.normalizeId ? sanitizedId.toLowerCase() : sanitizedId;
@@ -6079,11 +6764,15 @@ function getSandbox(ns, id, options) {
6079
6764
  wsConnect: connect(stub),
6080
6765
  tunnels: new Proxy({}, { get: (_, method) => {
6081
6766
  if (typeof method !== "string" || method === "then") return void 0;
6082
- return (...args) => stub.callTunnels(method, args);
6767
+ return withSandboxOperationContext(`sandbox.tunnels.${method}`, (...args) => stub.callTunnels(method, args));
6083
6768
  } })
6084
6769
  };
6085
6770
  return new Proxy(stub, { get(target, prop) {
6086
- if (typeof prop === "string" && prop in enhancedMethods) return enhancedMethods[prop];
6771
+ if (typeof prop === "string" && prop in enhancedMethods) {
6772
+ const method = enhancedMethods[prop];
6773
+ if (typeof method === "function") return withSandboxOperationContext(`sandbox.${prop}`, method);
6774
+ return method;
6775
+ }
6087
6776
  return target[prop];
6088
6777
  } });
6089
6778
  }
@@ -6120,6 +6809,7 @@ var Sandbox = class Sandbox extends Container {
6120
6809
  activeMounts = /* @__PURE__ */ new Map();
6121
6810
  mountOperationQueue = Promise.resolve();
6122
6811
  currentRuntime;
6812
+ currentLifetime;
6123
6813
  transport = "http";
6124
6814
  /**
6125
6815
  * True once transport has been written to storage at least once (either
@@ -6271,6 +6961,7 @@ var Sandbox = class Sandbox extends Container {
6271
6961
  sandboxId: this.ctx.id.toString()
6272
6962
  });
6273
6963
  this.currentRuntime = new CurrentRuntimeIdentity(this.ctx.storage, () => this.getState(), () => this.ctx.container?.running === true);
6964
+ this.currentLifetime = new CurrentSandboxLifetime(this.ctx.storage);
6274
6965
  const transportEnv = envObj?.SANDBOX_TRANSPORT;
6275
6966
  if (transportEnv === "websocket" || transportEnv === "rpc") this.transport = transportEnv;
6276
6967
  else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http", "websocket", or "rpc". Defaulting to "http".`);
@@ -7096,6 +7787,7 @@ var Sandbox = class Sandbox extends Container {
7096
7787
  try {
7097
7788
  await this.ctx.storage.delete(PORT_TOKENS_STORAGE_KEY);
7098
7789
  await this.clearActivePreviewPorts();
7790
+ await this.currentLifetime.rotate();
7099
7791
  await this.currentRuntime.clear();
7100
7792
  for (const [mountPath, mountInfo] of this.activeMounts.entries()) {
7101
7793
  mountsProcessed++;
@@ -7610,6 +8302,14 @@ var Sandbox = class Sandbox extends Container {
7610
8302
  this.defaultSessionInit = init;
7611
8303
  try {
7612
8304
  return await promise;
8305
+ } catch (err) {
8306
+ if (isSessionInitInvalidated(err)) {
8307
+ if (this.defaultSession === sessionId) return this.defaultSession;
8308
+ const freshPending = this.defaultSessionInit;
8309
+ if (freshPending != null && freshPending !== init && freshPending.sessionId === sessionId && freshPending.generation === this.containerGeneration) return freshPending.promise;
8310
+ return await this.initializeDefaultSession(sessionId, this.containerGeneration);
8311
+ }
8312
+ throw err;
7613
8313
  } finally {
7614
8314
  if (this.defaultSessionInit === init) this.defaultSessionInit = null;
7615
8315
  }
@@ -7627,7 +8327,7 @@ var Sandbox = class Sandbox extends Container {
7627
8327
  placementId = error.containerPlacementId;
7628
8328
  this.logger.debug("Session exists in container but not in DO state, syncing", { sessionId });
7629
8329
  }
7630
- if (generation !== this.containerGeneration) throw new Error("Default session initialization was invalidated by a container stop");
8330
+ if (generation !== this.containerGeneration) throw new SessionInitInvalidatedError();
7631
8331
  await this.ctx.storage.put("defaultSession", sessionId);
7632
8332
  await this.capturePlacementId(placementId);
7633
8333
  this.defaultSession = sessionId;
@@ -8499,6 +9199,8 @@ var Sandbox = class Sandbox extends Container {
8499
9199
  storage: this.ctx.storage,
8500
9200
  logger: this.logger,
8501
9201
  sandboxId: this.ctx.id.toString(),
9202
+ currentRuntime: this.currentRuntime,
9203
+ currentLifetime: this.currentLifetime,
8502
9204
  getNamedTunnelConfig: async () => {
8503
9205
  const envObj = this.env;
8504
9206
  const token = getEnvString(envObj, "CLOUDFLARE_API_TOKEN");
@@ -9665,11 +10367,24 @@ var Sandbox = class Sandbox extends Container {
9665
10367
  * Concurrent backup/restore calls on the same sandbox are serialized.
9666
10368
  */
9667
10369
  async restoreBackup(backup) {
9668
- if (backup.localBucket) return await this.enqueueBackupOp(() => this.doRestoreBackupLocal(backup));
9669
- this.requireBackupBucket();
9670
- return await this.enqueueBackupOp(() => this.doRestoreBackup(backup));
10370
+ if (!backup.localBucket) this.requireBackupBucket();
10371
+ return await this.enqueueBackupOp(() => this.doRestoreBackupWithRecovery(backup));
10372
+ }
10373
+ async doRestoreBackupWithRecovery(backup) {
10374
+ return await new RestoreLifecycleRunner({
10375
+ storage: this.ctx.storage,
10376
+ currentRuntime: this.currentRuntime,
10377
+ currentLifetime: this.currentLifetime
10378
+ }).execute({
10379
+ backupId: backup.id,
10380
+ dir: backup.dir,
10381
+ attempt: async (lifecycle) => {
10382
+ if (backup.localBucket) return await this.doRestoreBackupLocal(backup, lifecycle);
10383
+ return await this.doRestoreBackup(backup, lifecycle);
10384
+ }
10385
+ });
9671
10386
  }
9672
- async doRestoreBackup(backup) {
10387
+ async doRestoreBackup(backup, lifecycle) {
9673
10388
  const restoreStartTime = Date.now();
9674
10389
  const bucket = this.requireBackupBucket();
9675
10390
  this.requirePresignedURLSupport();
@@ -9736,12 +10451,14 @@ var Sandbox = class Sandbox extends Container {
9736
10451
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
9737
10452
  });
9738
10453
  backupSession = await this.ensureBackupSession();
10454
+ await lifecycle.runtimeReady();
9739
10455
  const archivePath = `${BACKUP_CONTAINER_DIR}/${id}.sqsh`;
9740
10456
  const mountGlob = `${BACKUP_CONTAINER_DIR}/mounts/${id}`;
9741
10457
  await this.execWithSession(`/usr/bin/fusermount3 -uz ${shellEscape(dir)} 2>/dev/null || true`, backupSession, { origin: "internal" }).catch(() => {});
9742
10458
  await this.execWithSession(`for d in ${shellEscape(mountGlob)}_*/lower ${shellEscape(mountGlob)}/lower; do [ -d "$d" ] && /usr/bin/fusermount3 -uz "$d" 2>/dev/null; done; true`, backupSession, { origin: "internal" }).catch(() => {});
9743
10459
  const sizeCheck = await this.execWithSession(`stat -c %s ${shellEscape(archivePath)} 2>/dev/null || echo 0`, backupSession, { origin: "internal" }).catch(() => ({ stdout: "0" }));
9744
10460
  if (Number.parseInt((sizeCheck.stdout ?? "0").trim(), 10) !== archiveHead.size) await this.downloadBackupParallel(archivePath, r2Key, archiveHead.size, id, dir, backupSession);
10461
+ await lifecycle.archiveReady(archiveHead.size);
9745
10462
  if (!(await this.client.backup.restoreArchive(dir, archivePath, backupSession)).success) throw new BackupRestoreError({
9746
10463
  message: "Container failed to restore backup archive",
9747
10464
  code: ErrorCode.BACKUP_RESTORE_FAILED,
@@ -9752,12 +10469,14 @@ var Sandbox = class Sandbox extends Container {
9752
10469
  },
9753
10470
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
9754
10471
  });
9755
- outcome = "success";
9756
- return {
10472
+ const result = {
9757
10473
  success: true,
9758
10474
  dir,
9759
10475
  id
9760
10476
  };
10477
+ await lifecycle.verify(result);
10478
+ outcome = "success";
10479
+ return result;
9761
10480
  } catch (error) {
9762
10481
  caughtError = error instanceof Error ? error : new Error(String(error));
9763
10482
  if (id && backupSession) {
@@ -9782,7 +10501,7 @@ var Sandbox = class Sandbox extends Container {
9782
10501
  * Uses the R2 binding directly instead of presigned URLs, and
9783
10502
  * unsquashfs for extraction instead of squashfuse + fuse-overlayfs.
9784
10503
  */
9785
- async doRestoreBackupLocal(backup) {
10504
+ async doRestoreBackupLocal(backup, lifecycle) {
9786
10505
  const restoreStartTime = Date.now();
9787
10506
  const { id, dir } = backup;
9788
10507
  let outcome = "error";
@@ -9854,7 +10573,9 @@ var Sandbox = class Sandbox extends Container {
9854
10573
  context: { backupId: id },
9855
10574
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
9856
10575
  });
10576
+ const archiveSize = metadata.sizeBytes;
9857
10577
  backupSession = await this.ensureBackupSession();
10578
+ await lifecycle.runtimeReady();
9858
10579
  const archivePath = `${BACKUP_CONTAINER_DIR}/${id}.sqsh`;
9859
10580
  await this.execWithSession(`mkdir -p ${BACKUP_CONTAINER_DIR}`, backupSession, { origin: "internal" });
9860
10581
  if (this.transport === "rpc") {
@@ -9885,6 +10606,7 @@ var Sandbox = class Sandbox extends Container {
9885
10606
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
9886
10607
  });
9887
10608
  }
10609
+ await lifecycle.archiveReady(archiveSize);
9888
10610
  const extractResult = await this.execWithSession(`/usr/bin/unsquashfs -f -d ${shellEscape(dir)} ${shellEscape(archivePath)}`, backupSession, { origin: "internal" });
9889
10611
  if (extractResult.exitCode !== 0) throw new BackupRestoreError({
9890
10612
  message: `unsquashfs extraction failed (exit code ${extractResult.exitCode}): ${extractResult.stderr}`,
@@ -9896,13 +10618,15 @@ var Sandbox = class Sandbox extends Container {
9896
10618
  },
9897
10619
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
9898
10620
  });
9899
- await this.execWithSession(`rm -f ${shellEscape(archivePath)}`, backupSession, { origin: "internal" }).catch(() => {});
9900
- outcome = "success";
9901
- return {
10621
+ const result = {
9902
10622
  success: true,
9903
10623
  dir,
9904
10624
  id
9905
10625
  };
10626
+ await lifecycle.verify(result);
10627
+ await this.execWithSession(`rm -f ${shellEscape(archivePath)}`, backupSession, { origin: "internal" }).catch(() => {});
10628
+ outcome = "success";
10629
+ return result;
9906
10630
  } catch (error) {
9907
10631
  caughtError = error instanceof Error ? error : new Error(String(error));
9908
10632
  if (id && backupSession) {
@@ -9984,5 +10708,5 @@ var Sandbox = class Sandbox extends Container {
9984
10708
  };
9985
10709
 
9986
10710
  //#endregion
9987
- export { FileClient as A, RPCTransportError as B, collectFile as C, ProcessClient as D, UtilityClient as E, BackupNotFoundError as F, BackupRestoreError as I, InvalidBackupConfigError as L, BackupClient as M, BackupCreateError as N, PortClient as O, BackupExpiredError as P, ProcessExitedBeforeReadyError as R, validateTunnelName as S, SandboxClient as T, SessionTerminatedError as V, responseToAsyncIterable as _, PREVIEW_PROXY_HEADER as a, sanitizeSandboxId as b, PREVIEW_PROXY_SANDBOX_ID_HEADER as c, BucketUnmountError as d, InvalidMountConfigError as f, parseSSEStream as g, asyncIterableToSSEStream as h, proxyTerminal as i, CommandClient as j, GitClient as k, PREVIEW_PROXY_TOKEN_HEADER as l, S3FSMountError as m, Sandbox as n, PREVIEW_PROXY_HEADERS as o, MissingCredentialsError as p, getSandbox as r, PREVIEW_PROXY_PORT_HEADER as s, ContainerProxy$1 as t, BucketMountError as u, CodeInterpreter as v, streamFile as w, validatePort as x, SandboxSecurityError as y, ProcessReadyTimeoutError as z };
9988
- //# sourceMappingURL=sandbox-DKG3H156.js.map
10711
+ export { PortClient as A, InvalidBackupConfigError as B, collectFile as C, SandboxClient as D, isPlatformTransientError as E, BackupCreateError as F, SessionTerminatedError as G, ProcessExitedBeforeReadyError as H, BackupExpiredError as I, BackupNotFoundError as L, FileClient as M, CommandClient as N, UtilityClient as O, BackupClient as P, BackupRestoreError as R, validateTunnelName as S, isDurableObjectCodeUpdateReset as T, ProcessReadyTimeoutError as U, OperationInterruptedError as V, RPCTransportError as W, responseToAsyncIterable as _, PREVIEW_PROXY_HEADER as a, sanitizeSandboxId as b, PREVIEW_PROXY_SANDBOX_ID_HEADER as c, BucketUnmountError as d, InvalidMountConfigError as f, parseSSEStream as g, asyncIterableToSSEStream as h, proxyTerminal as i, GitClient as j, ProcessClient as k, PREVIEW_PROXY_TOKEN_HEADER as l, S3FSMountError as m, Sandbox as n, PREVIEW_PROXY_HEADERS as o, MissingCredentialsError as p, getSandbox as r, PREVIEW_PROXY_PORT_HEADER as s, ContainerProxy$1 as t, BucketMountError as u, CodeInterpreter as v, streamFile as w, validatePort as x, SandboxSecurityError as y, ContainerUnavailableError as z };
10712
+ //# sourceMappingURL=sandbox-DI6suZAc.js.map