@cloudflare/sandbox 0.12.11 → 0.12.13

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
- 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-C3AQh4A0.js";
2
- import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-CpkKPbWC.js";
1
+ import { S as partitionEnvVars, _ as DEFAULT_GIT_CLONE_TIMEOUT_MS, a as isWSStreamChunk, b as filterEnvVars, d as createLogger, f as createNoOpLogger, g as ResultImpl, h as Execution, i as isWSResponse, l as parseSSEFrames, m as logCanonicalEvent, n as generateRequestId, p as TraceContext, r as isWSError, t as WS_KEEPALIVE_EVENT, u as shellEscape, v as GitLogger, x as getEnvString, y as extractRepoName } from "./dist-CqPJfREN.js";
2
+ import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-CHhwDJNh.js";
3
3
  import { Container, ContainerProxy, getContainer, switchPort } from "@cloudflare/containers";
4
4
  import { AwsClient } from "aws4fetch";
5
5
  import { RpcSession, RpcTarget } from "capnweb";
@@ -1431,6 +1431,7 @@ var WebSocketTransport = class extends BaseTransport {
1431
1431
  pending.onFirstChunk = void 0;
1432
1432
  }
1433
1433
  if (pending.isStreaming) this.resetStreamIdleTimeout(chunk.id, pending);
1434
+ if (chunk.event === WS_KEEPALIVE_EVENT) return;
1434
1435
  if (!pending.streamController) {
1435
1436
  if (!pending.bufferedChunks) pending.bufferedChunks = [];
1436
1437
  const encoder$1 = new TextEncoder();
@@ -1853,14 +1854,15 @@ var FileClient = class extends BaseHttpClient {
1853
1854
  * @param path - File path to write to
1854
1855
  * @param content - Content to write
1855
1856
  * @param sessionId - The session ID for this operation
1856
- * @param options - Optional settings (encoding)
1857
+ * @param options - Optional settings (encoding, ownerOnly)
1857
1858
  */
1858
1859
  async writeFile(path$1, content, sessionId, options) {
1859
1860
  const data = {
1860
1861
  path: path$1,
1861
1862
  content,
1862
1863
  sessionId,
1863
- encoding: options?.encoding
1864
+ encoding: options?.encoding,
1865
+ ...options?.ownerOnly === true && { ownerOnly: true }
1864
1866
  };
1865
1867
  return await this.post("/api/write", data);
1866
1868
  }
@@ -2602,6 +2604,19 @@ function isDurableObjectCodeUpdateReset(error) {
2602
2604
  return false;
2603
2605
  }
2604
2606
  /**
2607
+ * Whether an error was raised because the Durable Object itself was reset, by
2608
+ * a code update or a storage startup failure, rather than by the operation
2609
+ * that observed it.
2610
+ */
2611
+ function isDurableObjectReset(error) {
2612
+ for (const candidate of selfAndCauses(error)) {
2613
+ const message = errorMessageOf(candidate);
2614
+ if (SUPERSEDED_ISOLATE_PATTERN.test(message)) return true;
2615
+ if (DO_STORAGE_STARTUP_RESET_PATTERN.test(message)) return true;
2616
+ }
2617
+ return false;
2618
+ }
2619
+ /**
2605
2620
  * Whether an error represents a transient platform lifecycle/storage failure,
2606
2621
  * not an application-level sandbox failure.
2607
2622
  */
@@ -5304,8 +5319,12 @@ async function forwardPreviewRequest(tcpPort, request, lifecycle) {
5304
5319
  };
5305
5320
  } catch (error) {
5306
5321
  settleForward();
5322
+ if (isDurableObjectReset(error)) throw error;
5307
5323
  if (error instanceof Error && error.message.includes("Network connection lost.")) return { status: "network-lost" };
5308
- throw error;
5324
+ return {
5325
+ status: "failed",
5326
+ error
5327
+ };
5309
5328
  }
5310
5329
  }
5311
5330
  function bridgePreviewWebSocket(response, lifecycle, settleForward) {
@@ -6772,6 +6791,23 @@ function isTunnelAlreadyRunningError(error) {
6772
6791
  return hasErrorCode(error, "TUNNEL_ALREADY_RUNNING");
6773
6792
  }
6774
6793
  /**
6794
+ * The run holding `request.port` when the container rejected `request`
6795
+ * with a port conflict. Containers older than this SDK omit
6796
+ * `activeTunnelId`, which `stopTunnelRun` needs, so their conflicts yield
6797
+ * `null`.
6798
+ */
6799
+ function conflictingRunOnPort(error, request) {
6800
+ if (!hasErrorCode(error, "TUNNEL_RUN_CONFLICT")) return null;
6801
+ const context = error.context;
6802
+ const runId = context?.activeRunId;
6803
+ const tunnelId = context?.activeTunnelId;
6804
+ if (context?.port !== request.port || typeof runId !== "string" || typeof tunnelId !== "string" || runId === request.runId) return null;
6805
+ return {
6806
+ tunnelId,
6807
+ runId
6808
+ };
6809
+ }
6810
+ /**
6775
6811
  * Concrete `TunnelsHandler` implementation.
6776
6812
  *
6777
6813
  * Extends `RpcTarget` for forward compatibility with direct Workers RPC
@@ -6885,6 +6921,7 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
6885
6921
  const interrupted = translateTunnelInterruption(error, "provisioning", "unknown");
6886
6922
  if (!interrupted || options?.name || !interrupted.context.retryable) throw error;
6887
6923
  if (recoveryAttempts >= TUNNEL_GET_MAX_RECOVERY_ATTEMPTS) {
6924
+ await this.#stopAbandonedQuickRun(port, recovery.quickRun);
6888
6925
  await this.#clearQuickTunnelStorage(port);
6889
6926
  throw createTunnelInterruptedError({
6890
6927
  reason: "recovery_exhausted",
@@ -6896,10 +6933,50 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
6896
6933
  });
6897
6934
  }
6898
6935
  recoveryAttempts++;
6899
- if (interrupted.context.reason === "runtime_replaced") recovery.quickRun = void 0;
6936
+ if (interrupted.context.reason === "runtime_replaced") {
6937
+ await this.#stopAbandonedQuickRun(port, recovery.quickRun);
6938
+ recovery.quickRun = void 0;
6939
+ }
6900
6940
  await this.#clearQuickTunnelStorage(port);
6901
6941
  }
6902
6942
  }
6943
+ /**
6944
+ * Stop a quick tunnel run that `get()` is giving up on, so its
6945
+ * cloudflared does not keep holding the port. Best effort: the run may
6946
+ * have died with a replaced container.
6947
+ */
6948
+ async #stopAbandonedQuickRun(port, run) {
6949
+ if (!run) return;
6950
+ try {
6951
+ await this.#host.client.tunnels.stopTunnelRun(run);
6952
+ } catch (error) {
6953
+ this.#host.logger.warn("tunnel.get: abandoned run cleanup failed", {
6954
+ port,
6955
+ ...run,
6956
+ error: error instanceof Error ? error.message : String(error)
6957
+ });
6958
+ }
6959
+ }
6960
+ /**
6961
+ * Start or replay a quick tunnel run. The Durable Object owns every
6962
+ * tunnel run in its container and holds the port lock, so a different
6963
+ * run already on the port lost its storage record. Stop it and retry
6964
+ * once instead of failing every later `get()` for the port.
6965
+ */
6966
+ async #ensureQuickTunnelRun(request) {
6967
+ try {
6968
+ return await this.#host.client.tunnels.ensureTunnelRun(request);
6969
+ } catch (error) {
6970
+ const orphan = conflictingRunOnPort(error, request);
6971
+ if (!orphan) throw error;
6972
+ this.#host.logger.warn("tunnel.get: stopping orphaned run on port", {
6973
+ port: request.port,
6974
+ ...orphan
6975
+ });
6976
+ await this.#host.client.tunnels.stopTunnelRun(orphan);
6977
+ return await this.#host.client.tunnels.ensureTunnelRun(request);
6978
+ }
6979
+ }
6903
6980
  async #clearQuickTunnelStorage(port) {
6904
6981
  await this.#host.storage.transaction(async (txn) => {
6905
6982
  const map = await readMap(txn);
@@ -6980,7 +7057,7 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
6980
7057
  let ensureResult;
6981
7058
  let runtime = runtimeBeforeAdmission;
6982
7059
  try {
6983
- ensureResult = await this.#host.client.tunnels.ensureTunnelRun({
7060
+ ensureResult = await this.#ensureQuickTunnelRun({
6984
7061
  tunnelId,
6985
7062
  runId,
6986
7063
  mode: "quick",
@@ -7209,13 +7286,33 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
7209
7286
  return Object.values(map);
7210
7287
  }
7211
7288
  };
7212
- function createTunnelsHandler(host) {
7289
+ /**
7290
+ * Per-port serialization lock. Any operation that mutates the tunnel for
7291
+ * a given port — get() on a cache miss, destroy(), the exit callback —
7292
+ * queues behind the previous operation on the same port. Two
7293
+ * consequences:
7294
+ *
7295
+ * - get(8080) followed by destroy(8080) is well-ordered: the destroy
7296
+ * observes whatever the get just wrote, even though both yield to
7297
+ * external RPCs in the middle.
7298
+ * - Two concurrent get(8080) calls share the first call's record: the
7299
+ * second runs after the first writes storage and takes the hit branch
7300
+ * (so no double cloudflared spawn).
7301
+ *
7302
+ * The lock is a plain Promise chain keyed by port. `transaction()` on the
7303
+ * storage write still matters for *cross-port* writes — a get(8080) and
7304
+ * get(8081) running in parallel are independent here.
7305
+ */
7306
+ function createPortLock() {
7213
7307
  const portLocks = /* @__PURE__ */ new Map();
7214
- const withPortLock = (port, fn) => {
7308
+ return (port, fn) => {
7215
7309
  const next = (portLocks.get(port) ?? Promise.resolve()).then(fn, fn);
7216
7310
  portLocks.set(port, next.catch(() => void 0));
7217
7311
  return next;
7218
7312
  };
7313
+ }
7314
+ function createTunnelsHandler(host) {
7315
+ const withPortLock = host.withPortLock ?? createPortLock();
7219
7316
  const tunnels = new TunnelsRpcTarget(host, withPortLock);
7220
7317
  const handleTunnelExit = async (id, port, exitCode, runId) => {
7221
7318
  const startTime = Date.now();
@@ -7302,7 +7399,7 @@ function createTunnelsHandler(host) {
7302
7399
  * This file is auto-updated by .github/changeset-version.ts during releases
7303
7400
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
7304
7401
  */
7305
- const SDK_VERSION = "0.12.11";
7402
+ const SDK_VERSION = "0.12.13";
7306
7403
 
7307
7404
  //#endregion
7308
7405
  //#region src/sandbox.ts
@@ -7311,12 +7408,32 @@ const ACTIVE_PREVIEW_PORTS_STORAGE_KEY = "activePreviewPorts";
7311
7408
  const CONTAINER_PROXY_CLASS_NAME = "ContainerProxy";
7312
7409
  const S3_CREDENTIAL_PROXY_HOST = "s3-credential-proxy.internal";
7313
7410
  const S3_CREDENTIAL_PROXY_DIAGNOSTIC_HOST = "s3-credential-proxy.sandbox.test";
7314
- var ContainerProxyOutboundTarget = class extends Container {};
7315
- Object.defineProperty(ContainerProxyOutboundTarget, "name", { value: CONTAINER_PROXY_CLASS_NAME });
7316
- ContainerProxyOutboundTarget.outboundHandlers = {
7411
+ const SDK_OUTBOUND_HANDLERS = {
7317
7412
  r2EgressMount: r2EgressHandler,
7318
7413
  s3CredentialProxyMount: s3CredentialProxyHandler
7319
7414
  };
7415
+ var ContainerProxyOutboundTarget = class extends Container {};
7416
+ Object.defineProperty(ContainerProxyOutboundTarget, "name", { value: CONTAINER_PROXY_CLASS_NAME });
7417
+ ContainerProxyOutboundTarget.outboundHandlers = SDK_OUTBOUND_HANDLERS;
7418
+ function noRestoredMountRoutes() {
7419
+ return {
7420
+ r2Buckets: {},
7421
+ credentialProxyMounts: {}
7422
+ };
7423
+ }
7424
+ /**
7425
+ * Read the bucket mount routes that the base Container restored from storage
7426
+ * in its constructor. `outboundByHostOverrides` is private in its types.
7427
+ */
7428
+ function readRestoredMountRoutes(container) {
7429
+ const overrides = container.outboundByHostOverrides ?? {};
7430
+ const r2 = overrides["r2.internal"];
7431
+ const credentialProxy = overrides[S3_CREDENTIAL_PROXY_HOST];
7432
+ return {
7433
+ r2Buckets: r2?.method === "r2EgressMount" ? { ...r2.params?.buckets } : {},
7434
+ credentialProxyMounts: credentialProxy?.method === "s3CredentialProxyMount" ? { ...credentialProxy.params?.mounts } : {}
7435
+ };
7436
+ }
7320
7437
  /**
7321
7438
  * SDK-level ContainerProxy that directly dispatches SDK-internal mount hosts
7322
7439
  * (r2.internal, s3-credential-proxy.internal) without relying on
@@ -7644,6 +7761,7 @@ var Sandbox = class Sandbox extends Container {
7644
7761
  tunnelsHandler = null;
7645
7762
  tunnelExitHandler = null;
7646
7763
  destroyAllTunnels = null;
7764
+ tunnelPortLock = createPortLock();
7647
7765
  controlCallback;
7648
7766
  normalizeId = false;
7649
7767
  defaultSession = null;
@@ -7653,6 +7771,11 @@ var Sandbox = class Sandbox extends Container {
7653
7771
  logger;
7654
7772
  keepAliveEnabled = false;
7655
7773
  activeMounts = /* @__PURE__ */ new Map();
7774
+ restoredMountRoutes = noRestoredMountRoutes();
7775
+ staleMountRoutes = {
7776
+ r2: false,
7777
+ credentialProxy: false
7778
+ };
7656
7779
  mountOperationQueue = Promise.resolve();
7657
7780
  currentRuntime;
7658
7781
  currentLifetime;
@@ -7801,7 +7924,15 @@ var Sandbox = class Sandbox extends Container {
7801
7924
  return this.createSandboxClient();
7802
7925
  }
7803
7926
  constructor(ctx, env$1) {
7927
+ if (ctx.exports?.ContainerProxy) {
7928
+ const registry = new.target;
7929
+ registry.outboundHandlers = {
7930
+ ...registry.outboundHandlers,
7931
+ ...SDK_OUTBOUND_HANDLERS
7932
+ };
7933
+ }
7804
7934
  super(ctx, env$1);
7935
+ this.restoredMountRoutes = readRestoredMountRoutes(this);
7805
7936
  const envObj = env$1;
7806
7937
  ["SANDBOX_LOG_LEVEL", "SANDBOX_LOG_FORMAT"].forEach((key) => {
7807
7938
  if (envObj?.[key]) this.envVars[key] = String(envObj[key]);
@@ -8179,23 +8310,18 @@ var Sandbox = class Sandbox extends Container {
8179
8310
  const prefix = options.prefix;
8180
8311
  let mountOutcome = "error";
8181
8312
  let mountError;
8182
- let passwordFilePath;
8183
- let additionalHeaderFilePath;
8184
8313
  try {
8185
8314
  validateBucketBindingName(bucket, mountPath);
8186
8315
  this.validateMountPath(mountPath);
8187
8316
  this.validateProtectedS3fsOptions(options.s3fsOptions, "R2 binding");
8317
+ const restoredRoute = this.restoredMountRoutes.r2Buckets[bucket];
8318
+ if (restoredRoute && (restoredRoute.prefix !== prefix || (restoredRoute.readOnly ?? false) !== (options.readOnly ?? false))) throw new InvalidMountConfigError(`R2 binding "${bucket}" is already mounted with a different prefix or readOnly setting. Mount the same binding only once, or use the same settings for additional mounts.`);
8188
8319
  for (const [existingMountPath, mountInfo$1] of this.activeMounts) {
8189
8320
  if (mountInfo$1.mountType === "r2-egress" && mountInfo$1.bucket === bucket && mountInfo$1.prefix !== prefix) throw new InvalidMountConfigError(`R2 binding "${bucket}" is already mounted at ${existingMountPath} with a different prefix. Mount the same binding only once, or use the same prefix for additional mounts.`);
8190
8321
  if (mountInfo$1.mountType === "r2-egress" && mountInfo$1.bucket === bucket && mountInfo$1.readOnly !== (options.readOnly ?? false)) throw new InvalidMountConfigError(`R2 binding "${bucket}" is already mounted at ${existingMountPath} with a different readOnly setting. Mount the same binding only once, or use the same readOnly value for additional mounts.`);
8191
8322
  }
8192
- passwordFilePath = this.generatePasswordFilePath();
8193
- additionalHeaderFilePath = this.generateS3FSAdditionalHeaderFilePath();
8194
- await this.createPasswordFile(passwordFilePath, bucket, {
8195
- accessKeyId: "x",
8196
- secretAccessKey: "x"
8197
- });
8198
- await this.createDisableExpectHeaderFile(additionalHeaderFilePath);
8323
+ const passwordFilePath = this.generatePasswordFilePath();
8324
+ const additionalHeaderFilePath = this.generateS3FSAdditionalHeaderFilePath();
8199
8325
  const mountInfo = {
8200
8326
  mountId: crypto.randomUUID(),
8201
8327
  mountType: "r2-egress",
@@ -8208,48 +8334,47 @@ var Sandbox = class Sandbox extends Container {
8208
8334
  readOnly: options.readOnly ?? false
8209
8335
  };
8210
8336
  this.activeMounts.set(mountPath, mountInfo);
8211
- await this.configureR2EgressOutbound(this.getR2EgressParams());
8212
- await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
8213
- const s3fsSource = bucket;
8214
- const optionsStr = shellEscape(serializeS3fsOptions({
8215
- passwd_file: passwordFilePath,
8216
- ...R2_DEFAULT_S3FS_OPTIONS,
8217
- ...parseS3fsOptions(resolveS3fsOptions("r2", options.s3fsOptions)),
8218
- use_path_request_style: true,
8219
- url: "http://r2.internal",
8220
- ahbe_conf: additionalHeaderFilePath,
8221
- ...options.readOnly ? { ro: true } : {}
8222
- }));
8223
- const mountCmd = `s3fs ${shellEscape(s3fsSource)} ${shellEscape(mountPath)} -o ${optionsStr}`;
8224
- this.logger.debug("r2-egress: running s3fs", { mountCmd });
8225
- const result = await this.execInternal(mountCmd);
8226
- this.logger.debug("r2-egress: s3fs exited", {
8227
- exitCode: result.exitCode,
8228
- stdout: result.stdout,
8229
- stderr: result.stderr
8230
- });
8231
- if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
8232
- const mountpointCheck = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && echo 'FUSE_MOUNTED' || echo 'NOT_FUSE_MOUNTED'`);
8233
- this.logger.debug("r2-egress: mountpoint check", {
8234
- stdout: mountpointCheck.stdout.trim(),
8235
- exitCode: mountpointCheck.exitCode
8236
- });
8237
- if (mountpointCheck.stdout.trim() !== "FUSE_MOUNTED") throw new S3FSMountError(`s3fs exited 0 but mount was not established at ${mountPath}`);
8337
+ try {
8338
+ await this.createPasswordFile(passwordFilePath, bucket, {
8339
+ accessKeyId: "x",
8340
+ secretAccessKey: "x"
8341
+ });
8342
+ await this.createDisableExpectHeaderFile(additionalHeaderFilePath);
8343
+ await this.configureR2EgressOutbound(this.getR2EgressParams());
8344
+ await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
8345
+ const s3fsSource = bucket;
8346
+ const optionsStr = shellEscape(serializeS3fsOptions({
8347
+ passwd_file: passwordFilePath,
8348
+ ...R2_DEFAULT_S3FS_OPTIONS,
8349
+ ...parseS3fsOptions(resolveS3fsOptions("r2", options.s3fsOptions)),
8350
+ use_path_request_style: true,
8351
+ url: "http://r2.internal",
8352
+ ahbe_conf: additionalHeaderFilePath,
8353
+ ...options.readOnly ? { ro: true } : {}
8354
+ }));
8355
+ const mountCmd = `s3fs ${shellEscape(s3fsSource)} ${shellEscape(mountPath)} -o ${optionsStr}`;
8356
+ this.logger.debug("r2-egress: running s3fs", { mountCmd });
8357
+ const result = await this.execInternal(mountCmd);
8358
+ this.logger.debug("r2-egress: s3fs exited", {
8359
+ exitCode: result.exitCode,
8360
+ stdout: result.stdout,
8361
+ stderr: result.stderr
8362
+ });
8363
+ if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
8364
+ const mountpointCheck = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && echo 'FUSE_MOUNTED' || echo 'NOT_FUSE_MOUNTED'`);
8365
+ this.logger.debug("r2-egress: mountpoint check", {
8366
+ stdout: mountpointCheck.stdout.trim(),
8367
+ exitCode: mountpointCheck.exitCode
8368
+ });
8369
+ if (mountpointCheck.stdout.trim() !== "FUSE_MOUNTED") throw new S3FSMountError(`s3fs exited 0 but mount was not established at ${mountPath}`);
8370
+ } catch (error) {
8371
+ await this.releaseFailedR2EgressMount(mountInfo);
8372
+ throw error;
8373
+ }
8238
8374
  mountInfo.mounted = true;
8239
8375
  mountOutcome = "success";
8240
8376
  } catch (error) {
8241
8377
  mountError = error instanceof Error ? error : new Error(String(error));
8242
- const failedMount = this.activeMounts.get(mountPath);
8243
- this.activeMounts.delete(mountPath);
8244
- if (failedMount?.mountType === "r2-egress") {
8245
- await this.deletePasswordFile(failedMount.passwordFilePath).catch(() => {});
8246
- if (failedMount.additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(failedMount.additionalHeaderFilePath).catch(() => {});
8247
- } else {
8248
- if (passwordFilePath) await this.deletePasswordFile(passwordFilePath).catch(() => {});
8249
- if (additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(additionalHeaderFilePath).catch(() => {});
8250
- }
8251
- const remainingParams = this.getR2EgressParams();
8252
- await this.configureR2EgressOutbound(remainingParams).catch(() => {});
8253
8378
  throw error;
8254
8379
  } finally {
8255
8380
  logCanonicalEvent(this.logger, {
@@ -8265,6 +8390,16 @@ var Sandbox = class Sandbox extends Container {
8265
8390
  }
8266
8391
  }
8267
8392
  /**
8393
+ * Undo a failed R2 binding mount: drop its reservation and support files,
8394
+ * then rebuild the `r2.internal` routes from the remaining mounts.
8395
+ */
8396
+ async releaseFailedR2EgressMount(mountInfo) {
8397
+ this.activeMounts.delete(mountInfo.mountPath);
8398
+ await this.deletePasswordFile(mountInfo.passwordFilePath);
8399
+ if (mountInfo.additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(mountInfo.additionalHeaderFilePath);
8400
+ await this.configureR2EgressOutbound(this.getR2EgressParams()).catch(() => {});
8401
+ }
8402
+ /**
8268
8403
  * Production mount: S3FS-FUSE inside the container
8269
8404
  */
8270
8405
  async mountBucketFuse(bucket, mountPath, options) {
@@ -8272,10 +8407,7 @@ var Sandbox = class Sandbox extends Container {
8272
8407
  const prefix = options.prefix;
8273
8408
  let mountOutcome = "error";
8274
8409
  let mountError;
8275
- let passwordFilePath;
8276
- let additionalHeaderFilePath;
8277
8410
  let provider = null;
8278
- let dirExisted = true;
8279
8411
  try {
8280
8412
  this.validateMountOptions(bucket, mountPath, {
8281
8413
  ...options,
@@ -8297,8 +8429,8 @@ var Sandbox = class Sandbox extends Container {
8297
8429
  });
8298
8430
  const credentialProxyEnabled = options.credentialProxy === true;
8299
8431
  if (credentialProxyEnabled) this.validateProtectedS3fsOptions(options.s3fsOptions, "credential proxy", ["ahbe_conf", "use_path_request_style"]);
8300
- passwordFilePath = this.generatePasswordFilePath();
8301
- if (credentialProxyEnabled) additionalHeaderFilePath = this.generateS3FSAdditionalHeaderFilePath();
8432
+ const passwordFilePath = this.generatePasswordFilePath();
8433
+ const additionalHeaderFilePath = credentialProxyEnabled ? this.generateS3FSAdditionalHeaderFilePath() : void 0;
8302
8434
  const mountId = crypto.randomUUID();
8303
8435
  const mountInfo = {
8304
8436
  mountId,
@@ -8321,55 +8453,37 @@ var Sandbox = class Sandbox extends Container {
8321
8453
  } } : {}
8322
8454
  };
8323
8455
  this.activeMounts.set(mountPath, mountInfo);
8324
- await this.createPasswordFile(passwordFilePath, bucket, credentialProxyEnabled ? {
8325
- accessKeyId: "x",
8326
- secretAccessKey: "x"
8327
- } : credentials);
8328
- if (credentialProxyEnabled) {
8329
- if (additionalHeaderFilePath) await this.createDisableExpectHeaderFile(additionalHeaderFilePath);
8330
- await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams());
8456
+ let dirExisted = true;
8457
+ try {
8458
+ await this.createPasswordFile(passwordFilePath, bucket, credentialProxyEnabled ? {
8459
+ accessKeyId: "x",
8460
+ secretAccessKey: "x"
8461
+ } : credentials);
8462
+ if (credentialProxyEnabled) {
8463
+ if (additionalHeaderFilePath) await this.createDisableExpectHeaderFile(additionalHeaderFilePath);
8464
+ await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams());
8465
+ }
8466
+ dirExisted = (await this.execInternal(`test -d ${shellEscape(mountPath)}`)).exitCode === 0;
8467
+ await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
8468
+ const effectiveOptions = credentialProxyEnabled ? {
8469
+ ...options,
8470
+ endpoint: `http://${S3_CREDENTIAL_PROXY_HOST}/${mountId}`,
8471
+ s3fsOptions: [
8472
+ ...provider === "r2" ? R2_DEFAULT_S3FS_OPTION_ENTRIES : [],
8473
+ ...options.s3fsOptions ?? [],
8474
+ ...additionalHeaderFilePath ? [`ahbe_conf=${additionalHeaderFilePath}`] : [],
8475
+ "use_path_request_style"
8476
+ ]
8477
+ } : options;
8478
+ await this.executeS3FSMount(s3fsSource, mountPath, effectiveOptions, provider, passwordFilePath);
8479
+ } catch (error) {
8480
+ await this.releaseFailedFuseMount(mountInfo, !dirExisted);
8481
+ throw error;
8331
8482
  }
8332
- dirExisted = (await this.execInternal(`test -d ${shellEscape(mountPath)}`)).exitCode === 0;
8333
- await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
8334
- const effectiveOptions = credentialProxyEnabled ? {
8335
- ...options,
8336
- endpoint: `http://${S3_CREDENTIAL_PROXY_HOST}/${mountId}`,
8337
- s3fsOptions: [
8338
- ...provider === "r2" ? R2_DEFAULT_S3FS_OPTION_ENTRIES : [],
8339
- ...options.s3fsOptions ?? [],
8340
- ...additionalHeaderFilePath ? [`ahbe_conf=${additionalHeaderFilePath}`] : [],
8341
- "use_path_request_style"
8342
- ]
8343
- } : options;
8344
- await this.executeS3FSMount(s3fsSource, mountPath, effectiveOptions, provider, passwordFilePath);
8345
8483
  mountInfo.mounted = true;
8346
8484
  mountOutcome = "success";
8347
8485
  } catch (error) {
8348
8486
  mountError = error instanceof Error ? error : new Error(String(error));
8349
- try {
8350
- await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && fusermount -u ${shellEscape(mountPath)}`);
8351
- } catch {}
8352
- if (passwordFilePath) await this.deletePasswordFile(passwordFilePath);
8353
- if (additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(additionalHeaderFilePath);
8354
- if (!dirExisted) try {
8355
- await this.execInternal(`rmdir ${shellEscape(mountPath)} 2>/dev/null`);
8356
- } catch {}
8357
- const failedMount = this.activeMounts.get(mountPath);
8358
- if (failedMount?.mountType === "fuse" && failedMount.credentialProxy) try {
8359
- await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams({ excludeMountId: failedMount.mountId }));
8360
- this.activeMounts.delete(mountPath);
8361
- evictSigV4ClientCacheEntry(failedMount.mountId);
8362
- evictDirectoryMarkerCacheForMount(failedMount.mountId);
8363
- } catch (cleanupError) {
8364
- this.logger.warn("credential proxy cleanup failed", {
8365
- mountPath,
8366
- error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
8367
- });
8368
- this.activeMounts.delete(mountPath);
8369
- evictSigV4ClientCacheEntry(failedMount.mountId);
8370
- evictDirectoryMarkerCacheForMount(failedMount.mountId);
8371
- }
8372
- else this.activeMounts.delete(mountPath);
8373
8487
  throw error;
8374
8488
  } finally {
8375
8489
  logCanonicalEvent(this.logger, {
@@ -8385,6 +8499,35 @@ var Sandbox = class Sandbox extends Container {
8385
8499
  }
8386
8500
  }
8387
8501
  /**
8502
+ * Undo a failed FUSE mount: unmount anything s3fs established, then drop
8503
+ * its support files, the directory if this mount created it, and its
8504
+ * reservation.
8505
+ */
8506
+ async releaseFailedFuseMount(mountInfo, removeMountDirectory) {
8507
+ const { mountPath } = mountInfo;
8508
+ try {
8509
+ await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && fusermount -u ${shellEscape(mountPath)}`);
8510
+ } catch {}
8511
+ await this.deletePasswordFile(mountInfo.passwordFilePath);
8512
+ if (mountInfo.additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(mountInfo.additionalHeaderFilePath);
8513
+ if (removeMountDirectory) try {
8514
+ await this.execInternal(`rmdir ${shellEscape(mountPath)} 2>/dev/null`);
8515
+ } catch {}
8516
+ if (mountInfo.credentialProxy) {
8517
+ try {
8518
+ await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams({ excludeMountId: mountInfo.mountId }));
8519
+ } catch (cleanupError) {
8520
+ this.logger.warn("credential proxy cleanup failed", {
8521
+ mountPath,
8522
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
8523
+ });
8524
+ }
8525
+ evictSigV4ClientCacheEntry(mountInfo.mountId);
8526
+ evictDirectoryMarkerCacheForMount(mountInfo.mountId);
8527
+ }
8528
+ this.activeMounts.delete(mountPath);
8529
+ }
8530
+ /**
8388
8531
  * Manually unmount a bucket filesystem
8389
8532
  *
8390
8533
  * @param mountPath - Absolute path where the bucket is mounted
@@ -8524,11 +8667,9 @@ var Sandbox = class Sandbox extends Container {
8524
8667
  }
8525
8668
  /**
8526
8669
  * Create s3fs ahbe_conf file that suppresses the Expect: 100-continue header.
8527
- * Restricted to 0600 so s3fs will accept it (same requirement as passwd files).
8528
8670
  */
8529
8671
  async createDisableExpectHeaderFile(headerFilePath) {
8530
- await this.client.files.writeFile(headerFilePath, S3FS_DISABLE_EXPECT_HEADER_CONFIG, DISABLE_SESSION_TOKEN);
8531
- await this.execInternal(`chmod 0600 ${shellEscape(headerFilePath)}`);
8672
+ await this.writeOwnerOnlyFile(headerFilePath, S3FS_DISABLE_EXPECT_HEADER_CONFIG);
8532
8673
  }
8533
8674
  /**
8534
8675
  * Create password file with s3fs credentials
@@ -8536,8 +8677,16 @@ var Sandbox = class Sandbox extends Container {
8536
8677
  */
8537
8678
  async createPasswordFile(passwordFilePath, bucket, credentials) {
8538
8679
  const content = `${bucket}:${credentials.accessKeyId}:${credentials.secretAccessKey}`;
8539
- await this.client.files.writeFile(passwordFilePath, content, DISABLE_SESSION_TOKEN);
8540
- await this.execInternal(`chmod 0600 ${shellEscape(passwordFilePath)}`);
8680
+ await this.writeOwnerOnlyFile(passwordFilePath, content);
8681
+ }
8682
+ /**
8683
+ * s3fs refuses support files that other users can read. Create them with
8684
+ * mode 0600 so the password file is never readable while it is being
8685
+ * written. The chmod covers container images that ignore `ownerOnly`.
8686
+ */
8687
+ async writeOwnerOnlyFile(path$1, content) {
8688
+ await this.client.files.writeFile(path$1, content, DISABLE_SESSION_TOKEN, { ownerOnly: true });
8689
+ await this.execInternal(`chmod 0600 ${shellEscape(path$1)}`);
8541
8690
  }
8542
8691
  /**
8543
8692
  * Delete password file
@@ -8705,6 +8854,7 @@ var Sandbox = class Sandbox extends Container {
8705
8854
  async onStart() {
8706
8855
  this.logger.debug("Sandbox started");
8707
8856
  await this.currentRuntime.markStarted();
8857
+ await this.removeStaleMountRoutes();
8708
8858
  this.checkVersionCompatibility().catch((error) => {
8709
8859
  this.logger.error("Version compatibility check failed", error instanceof Error ? error : new Error(String(error)));
8710
8860
  });
@@ -8786,6 +8936,7 @@ var Sandbox = class Sandbox extends Container {
8786
8936
  this.containerGeneration++;
8787
8937
  this.defaultSession = null;
8788
8938
  this.defaultSessionInit = null;
8939
+ const disconnectClient = this.transport !== "rpc" || this.client.isWebSocketConnected();
8789
8940
  await this.currentRuntime.clear();
8790
8941
  await this.clearActivePreviewPorts();
8791
8942
  try {
@@ -8793,7 +8944,8 @@ var Sandbox = class Sandbox extends Container {
8793
8944
  } catch (error) {
8794
8945
  this.logger.error("Failed to reconcile tunnel storage after container stop", error instanceof Error ? error : new Error(String(error)));
8795
8946
  }
8796
- this.client.disconnect(this.buildDisconnectCause("runtime_replaced", "The sandbox container stopped while the operation was pending.", params));
8947
+ if (disconnectClient) this.client.disconnect(this.buildDisconnectCause("runtime_replaced", "The sandbox container stopped while the operation was pending.", params));
8948
+ await this.releaseRestoredMountRoutes();
8797
8949
  let hadR2EgressMount = false;
8798
8950
  let hadCredentialProxyMount = false;
8799
8951
  for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
@@ -8941,6 +9093,13 @@ var Sandbox = class Sandbox extends Container {
8941
9093
  /**
8942
9094
  * Explicit container-start hook for the RPC transport.
8943
9095
  *
9096
+ * The control connection calls this hook every time it connects, including
9097
+ * when it reconnects after an idle disconnect. A running, healthy container
9098
+ * is left alone, as in {@link containerFetch}: the base class runs
9099
+ * `onStart()` on every `startAndWaitForPorts()`, and `onStart()` treats the
9100
+ * container as new, recording a new runtime identity and dropping quick
9101
+ * tunnels from storage.
9102
+ *
8944
9103
  * Runs `startAndWaitForPorts` under a short instance-get budget
8945
9104
  * ({@link RPC_START_INSTANCE_GET_TIMEOUT_MS}) so a single attempt fails fast
8946
9105
  * under capacity pressure — the base class throws the classifiable
@@ -8960,6 +9119,7 @@ var Sandbox = class Sandbox extends Container {
8960
9119
  */
8961
9120
  async startContainerForRPC() {
8962
9121
  try {
9122
+ if ((await this.getState()).status === "healthy" && this.ctx.container?.running !== false) return;
8963
9123
  await this.startAndWaitForPorts({
8964
9124
  ports: 3e3,
8965
9125
  cancellationOptions: {
@@ -9175,6 +9335,14 @@ var Sandbox = class Sandbox extends Container {
9175
9335
  if (!await this.currentRuntime.isActive(runtime)) return this.stalePreviewURLResponse();
9176
9336
  return new Response("Container suddenly disconnected, try again", { status: 500 });
9177
9337
  }
9338
+ if (result.status === "failed") {
9339
+ if (!await this.currentRuntime.isActive(runtime)) return this.stalePreviewURLResponse();
9340
+ this.logger.error("Preview request to container port failed", result.error instanceof Error ? result.error : new Error(String(result.error)), {
9341
+ port,
9342
+ method: request.method
9343
+ });
9344
+ return new Response(`Could not reach port ${port} in the container`, { status: 502 });
9345
+ }
9178
9346
  return result.response;
9179
9347
  }
9180
9348
  buildPreviewProxyRequest(request, port, sandboxId) {
@@ -9186,10 +9354,6 @@ var Sandbox = class Sandbox extends Container {
9186
9354
  headers.set("X-Forwarded-Host", url.hostname);
9187
9355
  headers.set("X-Forwarded-Proto", url.protocol.replace(":", ""));
9188
9356
  headers.set("X-Sandbox-Name", this.sandboxName ?? sandboxId);
9189
- if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") return new Request(request, {
9190
- headers,
9191
- redirect: "manual"
9192
- });
9193
9357
  return new Request(proxyUrl, {
9194
9358
  method: request.method,
9195
9359
  headers,
@@ -10174,6 +10338,7 @@ var Sandbox = class Sandbox extends Container {
10174
10338
  sandboxId: this.ctx.id.toString(),
10175
10339
  currentRuntime: this.currentRuntime,
10176
10340
  currentLifetime: this.currentLifetime,
10341
+ withPortLock: this.tunnelPortLock,
10177
10342
  getNamedTunnelConfig: async () => {
10178
10343
  const envObj = this.env;
10179
10344
  const token = getEnvString(envObj, "CLOUDFLARE_API_TOKEN");
@@ -11620,11 +11785,47 @@ var Sandbox = class Sandbox extends Container {
11620
11785
  });
11621
11786
  }
11622
11787
  }
11623
- async configureR2EgressOutbound(params) {
11788
+ /**
11789
+ * Drop the routes restored for the mounts of a container that has stopped.
11790
+ */
11791
+ async releaseRestoredMountRoutes() {
11792
+ const { r2Buckets, credentialProxyMounts } = this.restoredMountRoutes;
11793
+ this.restoredMountRoutes = noRestoredMountRoutes();
11794
+ for (const mountId of Object.keys(credentialProxyMounts)) {
11795
+ evictSigV4ClientCacheEntry(mountId);
11796
+ evictDirectoryMarkerCacheForMount(mountId);
11797
+ }
11798
+ if (Object.keys(r2Buckets).length > 0) this.staleMountRoutes.r2 = true;
11799
+ if (Object.keys(credentialProxyMounts).length > 0) this.staleMountRoutes.credentialProxy = true;
11800
+ await this.removeStaleMountRoutes();
11801
+ }
11802
+ /**
11803
+ * Rewrite the routes that still carry a stopped container's restored
11804
+ * mounts so they cover only the mounts in activeMounts. A failed update
11805
+ * stays marked and is retried on the next container start or stop.
11806
+ */
11807
+ async removeStaleMountRoutes() {
11808
+ if (this.staleMountRoutes.r2) try {
11809
+ await this.configureR2EgressOutbound(this.getR2EgressParams());
11810
+ this.staleMountRoutes.r2 = false;
11811
+ } catch (error) {
11812
+ this.logger.warn("Failed to remove stale R2 mount routes", { error: error instanceof Error ? error.message : String(error) });
11813
+ }
11814
+ if (this.staleMountRoutes.credentialProxy) try {
11815
+ await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams());
11816
+ this.staleMountRoutes.credentialProxy = false;
11817
+ } catch (error) {
11818
+ this.logger.warn("Failed to remove stale credential proxy mount routes", { error: error instanceof Error ? error.message : String(error) });
11819
+ }
11820
+ }
11821
+ async configureR2EgressOutbound(activeParams) {
11822
+ const params = { buckets: {
11823
+ ...this.restoredMountRoutes.r2Buckets,
11824
+ ...activeParams.buckets
11825
+ } };
11624
11826
  const ctx = this.ctx;
11625
11827
  if (!ctx.container?.interceptOutboundHttp) throw new InvalidMountConfigError("R2 binding mounts require container outbound interception support");
11626
11828
  if (!ctx.exports?.ContainerProxy) throw new InvalidMountConfigError("R2 binding mounts require exporting ContainerProxy from the Worker entrypoint");
11627
- this.constructor.outboundHandlers = { r2EgressMount: r2EgressHandler };
11628
11829
  if (Object.keys(params.buckets).length > 0) await this.setOutboundByHost("r2.internal", "r2EgressMount", params);
11629
11830
  else await this.removeOutboundByHost("r2.internal");
11630
11831
  this.logger.debug("r2 egress: registering host interception", {
@@ -11644,12 +11845,15 @@ var Sandbox = class Sandbox extends Container {
11644
11845
  if (!isFetcher(fetcher)) throw new InvalidMountConfigError("R2 binding mounts require ContainerProxy to return a valid Fetcher");
11645
11846
  await ctx.container.interceptOutboundHttp("r2.internal", fetcher);
11646
11847
  }
11647
- async configureS3CredentialProxyOutbound(params) {
11848
+ async configureS3CredentialProxyOutbound(activeParams) {
11849
+ const params = { mounts: {
11850
+ ...this.restoredMountRoutes.credentialProxyMounts,
11851
+ ...activeParams.mounts
11852
+ } };
11648
11853
  const ctx = this.ctx;
11649
11854
  if (!ctx.container?.interceptOutboundHttp) throw new InvalidMountConfigError("Credential proxy bucket mounts require container outbound interception support");
11650
11855
  if (!ctx.exports?.ContainerProxy) throw new InvalidMountConfigError("Credential proxy bucket mounts require exporting ContainerProxy from the Worker entrypoint");
11651
11856
  const hosts = [S3_CREDENTIAL_PROXY_HOST, S3_CREDENTIAL_PROXY_DIAGNOSTIC_HOST];
11652
- this.constructor.outboundHandlers = { s3CredentialProxyMount: s3CredentialProxyHandler };
11653
11857
  if (Object.keys(params.mounts).length > 0) for (const host of hosts) await this.setOutboundByHost(host, "s3CredentialProxyMount", params);
11654
11858
  else for (const host of hosts) await this.removeOutboundByHost(host);
11655
11859
  const hostOverrides = {};
@@ -11682,4 +11886,4 @@ var Sandbox = class Sandbox extends Container {
11682
11886
 
11683
11887
  //#endregion
11684
11888
  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 };
11685
- //# sourceMappingURL=sandbox-DKbJiquI.js.map
11889
+ //# sourceMappingURL=sandbox-BGDk-c1Y.js.map