@cloudflare/sandbox 0.12.10 → 0.12.12

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-B_eXrP83.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
  }
@@ -1878,11 +1880,13 @@ var FileClient = class extends BaseHttpClient {
1878
1880
  * Returns a ReadableStream of SSE events containing metadata, chunks, and completion
1879
1881
  * @param path - File path to stream
1880
1882
  * @param sessionId - The session ID for this operation
1883
+ * @param options - Optional stream encoding; base64 preserves arbitrary bytes
1881
1884
  */
1882
- async readFileStream(path$1, sessionId) {
1885
+ async readFileStream(path$1, sessionId, options) {
1883
1886
  const data = {
1884
1887
  path: path$1,
1885
- sessionId
1888
+ sessionId,
1889
+ encoding: options?.encoding
1886
1890
  };
1887
1891
  return await this.doStreamFetch("/api/read/stream", data);
1888
1892
  }
@@ -2600,6 +2604,19 @@ function isDurableObjectCodeUpdateReset(error) {
2600
2604
  return false;
2601
2605
  }
2602
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
+ /**
2603
2620
  * Whether an error represents a transient platform lifecycle/storage failure,
2604
2621
  * not an application-level sandbox failure.
2605
2622
  */
@@ -4343,18 +4360,49 @@ async function* parseSSE(stream) {
4343
4360
  * ```
4344
4361
  */
4345
4362
  async function* streamFile(stream) {
4346
- let metadata = null;
4347
- for await (const event of parseSSE(stream)) switch (event.type) {
4363
+ const { chunks } = await openFileStream(stream);
4364
+ return yield* chunks;
4365
+ }
4366
+ /**
4367
+ * Read the metadata event at the head of a file stream and return it with
4368
+ * the decoded chunks that follow. Callers that need the file size before
4369
+ * consuming the body, such as sizing a FixedLengthStream, use this instead of
4370
+ * streamFile(). Call cancel() to release the stream without reading the rest.
4371
+ */
4372
+ async function openFileStream(stream) {
4373
+ const events = parseSSE(stream);
4374
+ const cancel = async () => {
4375
+ await events.return(void 0);
4376
+ };
4377
+ try {
4378
+ const first = await events.next();
4379
+ if (first.done) throw new Error("Stream ended unexpectedly");
4380
+ const metadata = readMetadataEvent(first.value);
4381
+ return {
4382
+ metadata,
4383
+ chunks: decodeChunks(events, metadata),
4384
+ cancel
4385
+ };
4386
+ } catch (error) {
4387
+ await cancel();
4388
+ throw error;
4389
+ }
4390
+ }
4391
+ function readMetadataEvent(event) {
4392
+ switch (event.type) {
4393
+ case "metadata": return toFileMetadata(event);
4394
+ case "chunk": throw new Error("Received chunk before metadata");
4395
+ case "complete": throw new Error("Stream completed without metadata");
4396
+ case "error": throw new Error(`File streaming error: ${event.error}`);
4397
+ }
4398
+ }
4399
+ async function* decodeChunks(events, initialMetadata) {
4400
+ let metadata = initialMetadata;
4401
+ for await (const event of events) switch (event.type) {
4348
4402
  case "metadata":
4349
- metadata = {
4350
- mimeType: event.mimeType,
4351
- size: event.size,
4352
- isBinary: event.isBinary,
4353
- encoding: event.encoding
4354
- };
4403
+ metadata = toFileMetadata(event);
4355
4404
  break;
4356
4405
  case "chunk":
4357
- if (!metadata) throw new Error("Received chunk before metadata");
4358
4406
  if (metadata.isBinary && metadata.encoding === "base64") {
4359
4407
  const binaryString = atob(event.data);
4360
4408
  const bytes = new Uint8Array(binaryString.length);
@@ -4362,13 +4410,19 @@ async function* streamFile(stream) {
4362
4410
  yield bytes;
4363
4411
  } else yield event.data;
4364
4412
  break;
4365
- case "complete":
4366
- if (!metadata) throw new Error("Stream completed without metadata");
4367
- return metadata;
4413
+ case "complete": return metadata;
4368
4414
  case "error": throw new Error(`File streaming error: ${event.error}`);
4369
4415
  }
4370
4416
  throw new Error("Stream ended unexpectedly");
4371
4417
  }
4418
+ function toFileMetadata(event) {
4419
+ return {
4420
+ mimeType: event.mimeType,
4421
+ size: event.size,
4422
+ isBinary: event.isBinary,
4423
+ encoding: event.encoding
4424
+ };
4425
+ }
4372
4426
  /**
4373
4427
  * Collect an entire file into memory from a stream
4374
4428
  *
@@ -4871,6 +4925,10 @@ const DEFAULT_POLL_INTERVAL_MS = 1e3;
4871
4925
  const DEFAULT_ECHO_SUPPRESS_TTL_MS = 2e3;
4872
4926
  const MAX_BACKOFF_MS = 3e4;
4873
4927
  const SYNC_CONCURRENCY = 5;
4928
+ const STREAM_TO_CONTAINER_THRESHOLD_BYTES = 4 * 1024 * 1024;
4929
+ const UPLOAD_DEBOUNCE_MS = 1500;
4930
+ /** Watch rejections that a retry with the same request cannot fix. */
4931
+ const PERMANENT_WATCH_ERRORS = new Set([ErrorCode.PERMISSION_DENIED, ErrorCode.VALIDATION_FAILED]);
4874
4932
  /**
4875
4933
  * Manages bidirectional sync between an R2 binding and a container directory.
4876
4934
  *
@@ -4888,7 +4946,9 @@ var LocalMountSyncManager = class {
4888
4946
  pollIntervalMs;
4889
4947
  echoSuppressTtlMs;
4890
4948
  snapshot = /* @__PURE__ */ new Map();
4891
- echoSuppressSet = /* @__PURE__ */ new Set();
4949
+ echoSuppressTimers = /* @__PURE__ */ new Map();
4950
+ uploadTimers = /* @__PURE__ */ new Map();
4951
+ containerToR2Queue = Promise.resolve();
4892
4952
  pollTimer = null;
4893
4953
  watchReconnectTimer = null;
4894
4954
  watchAbortController = null;
@@ -4941,8 +5001,11 @@ var LocalMountSyncManager = class {
4941
5001
  this.watchAbortController.abort();
4942
5002
  this.watchAbortController = null;
4943
5003
  }
5004
+ for (const timer of this.uploadTimers.values()) clearTimeout(timer);
5005
+ this.uploadTimers.clear();
5006
+ for (const timer of this.echoSuppressTimers.values()) clearTimeout(timer);
5007
+ this.echoSuppressTimers.clear();
4944
5008
  this.snapshot.clear();
4945
- this.echoSuppressSet.clear();
4946
5009
  this.logger.info("Local mount sync stopped", { mountPath: this.mountPath });
4947
5010
  }
4948
5011
  async fullSyncR2ToContainer() {
@@ -5000,6 +5063,7 @@ var LocalMountSyncManager = class {
5000
5063
  await this.ensureParentDir(containerPath);
5001
5064
  this.suppressEcho(containerPath);
5002
5065
  await this.transferR2ObjectToContainer(key, containerPath);
5066
+ this.suppressEcho(containerPath);
5003
5067
  this.logger.debug("R2 -> Container: synced object", {
5004
5068
  key,
5005
5069
  action
@@ -5041,6 +5105,15 @@ var LocalMountSyncManager = class {
5041
5105
  async transferR2ObjectToContainer(key, containerPath) {
5042
5106
  const obj = await this.bucket.get(key);
5043
5107
  if (!obj) return;
5108
+ if (obj.size > STREAM_TO_CONTAINER_THRESHOLD_BYTES && this.client.getTransportMode() === "rpc") {
5109
+ try {
5110
+ await this.client.files.writeFileStream(containerPath, obj.body, this.sessionId);
5111
+ } catch (error) {
5112
+ await obj.body.cancel().catch(() => {});
5113
+ throw error;
5114
+ }
5115
+ return;
5116
+ }
5044
5117
  const arrayBuffer = await obj.arrayBuffer();
5045
5118
  const base64 = uint8ArrayToBase64(new Uint8Array(arrayBuffer));
5046
5119
  await this.client.files.writeFile(containerPath, base64, this.sessionId, { encoding: "base64" });
@@ -5060,8 +5133,13 @@ var LocalMountSyncManager = class {
5060
5133
  this.scheduleWatchReconnect();
5061
5134
  }).catch((error) => {
5062
5135
  if (!this.running) return;
5136
+ const err = error instanceof Error ? error : new Error(String(error));
5137
+ if (PERMANENT_WATCH_ERRORS.has(Reflect.get(err, "code"))) {
5138
+ this.logger.error("Container rejected the mount watch; changes made in the container will not sync to R2. Update the container image to match the SDK version.", err, { mountPath: this.mountPath });
5139
+ return;
5140
+ }
5063
5141
  this.consecutiveWatchFailures++;
5064
- this.logger.error("Container watch loop failed", error instanceof Error ? error : new Error(String(error)));
5142
+ this.logger.error("Container watch loop failed", err);
5065
5143
  this.scheduleWatchReconnect();
5066
5144
  });
5067
5145
  }
@@ -5091,54 +5169,108 @@ var LocalMountSyncManager = class {
5091
5169
  if (event.type !== "event") continue;
5092
5170
  if (event.isDirectory) continue;
5093
5171
  const containerPath = event.path;
5094
- if (this.echoSuppressSet.has(containerPath)) continue;
5172
+ if (this.echoSuppressTimers.has(containerPath)) continue;
5095
5173
  const r2Key = this.containerPathToR2Key(containerPath);
5096
5174
  if (!r2Key) continue;
5097
- try {
5098
- switch (event.eventType) {
5099
- case "create":
5100
- case "modify":
5101
- case "move_to":
5102
- await this.uploadFileToR2(containerPath, r2Key);
5103
- this.logger.debug("Container -> R2: synced file", {
5104
- path: containerPath,
5105
- key: r2Key,
5106
- action: event.eventType
5107
- });
5108
- break;
5109
- case "delete":
5110
- case "move_from":
5175
+ switch (event.eventType) {
5176
+ case "create":
5177
+ case "modify":
5178
+ case "move_to":
5179
+ this.scheduleUpload(containerPath, r2Key);
5180
+ break;
5181
+ case "delete":
5182
+ case "move_from":
5183
+ this.cancelScheduledUpload(containerPath);
5184
+ this.enqueueContainerToR2(containerPath, async () => {
5111
5185
  await this.bucket.delete(r2Key);
5112
5186
  this.snapshot.delete(r2Key);
5113
5187
  this.logger.debug("Container -> R2: deleted object", {
5114
5188
  path: containerPath,
5115
5189
  key: r2Key
5116
5190
  });
5117
- break;
5118
- }
5191
+ });
5192
+ break;
5193
+ }
5194
+ }
5195
+ }
5196
+ /**
5197
+ * Upload a changed file once its burst of watch events settles. Each new
5198
+ * event for the path restarts the wait.
5199
+ */
5200
+ scheduleUpload(containerPath, r2Key) {
5201
+ this.cancelScheduledUpload(containerPath);
5202
+ const timer = setTimeout(() => {
5203
+ this.uploadTimers.delete(containerPath);
5204
+ this.enqueueContainerToR2(containerPath, async () => {
5205
+ await this.uploadFileToR2(containerPath, r2Key);
5206
+ this.logger.debug("Container -> R2: synced file", {
5207
+ path: containerPath,
5208
+ key: r2Key
5209
+ });
5210
+ });
5211
+ }, UPLOAD_DEBOUNCE_MS);
5212
+ this.uploadTimers.set(containerPath, timer);
5213
+ }
5214
+ cancelScheduledUpload(containerPath) {
5215
+ const timer = this.uploadTimers.get(containerPath);
5216
+ if (timer !== void 0) {
5217
+ clearTimeout(timer);
5218
+ this.uploadTimers.delete(containerPath);
5219
+ }
5220
+ }
5221
+ enqueueContainerToR2(containerPath, operation) {
5222
+ this.containerToR2Queue = this.containerToR2Queue.then(async () => {
5223
+ if (!this.running) return;
5224
+ try {
5225
+ await operation();
5119
5226
  } catch (error) {
5120
5227
  this.logger.error(`Container -> R2 sync failed for ${containerPath}`, error instanceof Error ? error : new Error(String(error)));
5121
5228
  }
5122
- }
5229
+ });
5123
5230
  }
5124
5231
  /**
5125
- * Read a container file and upload it to R2, then update the local
5126
- * snapshot so the next poll cycle doesn't echo the write back.
5232
+ * Stream a container file into R2, then update the local snapshot so the
5233
+ * next poll cycle doesn't echo the write back.
5127
5234
  */
5128
5235
  async uploadFileToR2(containerPath, r2Key) {
5129
- const bytes = base64ToUint8Array((await this.client.files.readFile(containerPath, this.sessionId, { encoding: "base64" })).content);
5130
- await this.bucket.put(r2Key, bytes);
5236
+ const file = await openFileStream(await this.client.files.readFileStream(containerPath, this.sessionId, { encoding: "base64" }));
5237
+ if (file.metadata.isBinary && file.metadata.encoding === "base64") await this.putFileStream(r2Key, file);
5238
+ else {
5239
+ await file.cancel();
5240
+ const result = await this.client.files.readFile(containerPath, this.sessionId, { encoding: "base64" });
5241
+ await this.bucket.put(r2Key, base64ToUint8Array(result.content));
5242
+ }
5131
5243
  const head = await this.bucket.head(r2Key);
5132
5244
  if (head) this.snapshot.set(r2Key, {
5133
5245
  etag: head.etag,
5134
5246
  size: head.size
5135
5247
  });
5136
5248
  }
5249
+ async putFileStream(r2Key, file) {
5250
+ const body = new ReadableStream({
5251
+ async pull(controller) {
5252
+ const next = await file.chunks.next();
5253
+ if (next.done) controller.close();
5254
+ else if (typeof next.value === "string") controller.error(/* @__PURE__ */ new Error("Expected binary file chunks"));
5255
+ else controller.enqueue(next.value);
5256
+ },
5257
+ cancel: () => file.cancel()
5258
+ });
5259
+ const sized = new FixedLengthStream(file.metadata.size);
5260
+ const pipeAbort = new AbortController();
5261
+ body.pipeTo(sized.writable, { signal: pipeAbort.signal }).catch(() => {});
5262
+ try {
5263
+ await this.bucket.put(r2Key, sized.readable);
5264
+ } catch (error) {
5265
+ pipeAbort.abort(error);
5266
+ throw error;
5267
+ }
5268
+ }
5137
5269
  suppressEcho(containerPath) {
5138
- this.echoSuppressSet.add(containerPath);
5139
- setTimeout(() => {
5140
- this.echoSuppressSet.delete(containerPath);
5141
- }, this.echoSuppressTtlMs);
5270
+ clearTimeout(this.echoSuppressTimers.get(containerPath));
5271
+ this.echoSuppressTimers.set(containerPath, setTimeout(() => {
5272
+ this.echoSuppressTimers.delete(containerPath);
5273
+ }, this.echoSuppressTtlMs));
5142
5274
  }
5143
5275
  r2KeyToContainerPath(key) {
5144
5276
  let relativePath = key;
@@ -5187,8 +5319,12 @@ async function forwardPreviewRequest(tcpPort, request, lifecycle) {
5187
5319
  };
5188
5320
  } catch (error) {
5189
5321
  settleForward();
5322
+ if (isDurableObjectReset(error)) throw error;
5190
5323
  if (error instanceof Error && error.message.includes("Network connection lost.")) return { status: "network-lost" };
5191
- throw error;
5324
+ return {
5325
+ status: "failed",
5326
+ error
5327
+ };
5192
5328
  }
5193
5329
  }
5194
5330
  function bridgePreviewWebSocket(response, lifecycle, settleForward) {
@@ -6655,6 +6791,23 @@ function isTunnelAlreadyRunningError(error) {
6655
6791
  return hasErrorCode(error, "TUNNEL_ALREADY_RUNNING");
6656
6792
  }
6657
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
+ /**
6658
6811
  * Concrete `TunnelsHandler` implementation.
6659
6812
  *
6660
6813
  * Extends `RpcTarget` for forward compatibility with direct Workers RPC
@@ -6768,6 +6921,7 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
6768
6921
  const interrupted = translateTunnelInterruption(error, "provisioning", "unknown");
6769
6922
  if (!interrupted || options?.name || !interrupted.context.retryable) throw error;
6770
6923
  if (recoveryAttempts >= TUNNEL_GET_MAX_RECOVERY_ATTEMPTS) {
6924
+ await this.#stopAbandonedQuickRun(port, recovery.quickRun);
6771
6925
  await this.#clearQuickTunnelStorage(port);
6772
6926
  throw createTunnelInterruptedError({
6773
6927
  reason: "recovery_exhausted",
@@ -6779,10 +6933,50 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
6779
6933
  });
6780
6934
  }
6781
6935
  recoveryAttempts++;
6782
- 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
+ }
6783
6940
  await this.#clearQuickTunnelStorage(port);
6784
6941
  }
6785
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
+ }
6786
6980
  async #clearQuickTunnelStorage(port) {
6787
6981
  await this.#host.storage.transaction(async (txn) => {
6788
6982
  const map = await readMap(txn);
@@ -6863,7 +7057,7 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
6863
7057
  let ensureResult;
6864
7058
  let runtime = runtimeBeforeAdmission;
6865
7059
  try {
6866
- ensureResult = await this.#host.client.tunnels.ensureTunnelRun({
7060
+ ensureResult = await this.#ensureQuickTunnelRun({
6867
7061
  tunnelId,
6868
7062
  runId,
6869
7063
  mode: "quick",
@@ -7092,13 +7286,33 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
7092
7286
  return Object.values(map);
7093
7287
  }
7094
7288
  };
7095
- 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() {
7096
7307
  const portLocks = /* @__PURE__ */ new Map();
7097
- const withPortLock = (port, fn) => {
7308
+ return (port, fn) => {
7098
7309
  const next = (portLocks.get(port) ?? Promise.resolve()).then(fn, fn);
7099
7310
  portLocks.set(port, next.catch(() => void 0));
7100
7311
  return next;
7101
7312
  };
7313
+ }
7314
+ function createTunnelsHandler(host) {
7315
+ const withPortLock = host.withPortLock ?? createPortLock();
7102
7316
  const tunnels = new TunnelsRpcTarget(host, withPortLock);
7103
7317
  const handleTunnelExit = async (id, port, exitCode, runId) => {
7104
7318
  const startTime = Date.now();
@@ -7185,7 +7399,7 @@ function createTunnelsHandler(host) {
7185
7399
  * This file is auto-updated by .github/changeset-version.ts during releases
7186
7400
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
7187
7401
  */
7188
- const SDK_VERSION = "0.12.10";
7402
+ const SDK_VERSION = "0.12.12";
7189
7403
 
7190
7404
  //#endregion
7191
7405
  //#region src/sandbox.ts
@@ -7194,12 +7408,32 @@ const ACTIVE_PREVIEW_PORTS_STORAGE_KEY = "activePreviewPorts";
7194
7408
  const CONTAINER_PROXY_CLASS_NAME = "ContainerProxy";
7195
7409
  const S3_CREDENTIAL_PROXY_HOST = "s3-credential-proxy.internal";
7196
7410
  const S3_CREDENTIAL_PROXY_DIAGNOSTIC_HOST = "s3-credential-proxy.sandbox.test";
7197
- var ContainerProxyOutboundTarget = class extends Container {};
7198
- Object.defineProperty(ContainerProxyOutboundTarget, "name", { value: CONTAINER_PROXY_CLASS_NAME });
7199
- ContainerProxyOutboundTarget.outboundHandlers = {
7411
+ const SDK_OUTBOUND_HANDLERS = {
7200
7412
  r2EgressMount: r2EgressHandler,
7201
7413
  s3CredentialProxyMount: s3CredentialProxyHandler
7202
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
+ }
7203
7437
  /**
7204
7438
  * SDK-level ContainerProxy that directly dispatches SDK-internal mount hosts
7205
7439
  * (r2.internal, s3-credential-proxy.internal) without relying on
@@ -7527,6 +7761,7 @@ var Sandbox = class Sandbox extends Container {
7527
7761
  tunnelsHandler = null;
7528
7762
  tunnelExitHandler = null;
7529
7763
  destroyAllTunnels = null;
7764
+ tunnelPortLock = createPortLock();
7530
7765
  controlCallback;
7531
7766
  normalizeId = false;
7532
7767
  defaultSession = null;
@@ -7536,6 +7771,11 @@ var Sandbox = class Sandbox extends Container {
7536
7771
  logger;
7537
7772
  keepAliveEnabled = false;
7538
7773
  activeMounts = /* @__PURE__ */ new Map();
7774
+ restoredMountRoutes = noRestoredMountRoutes();
7775
+ staleMountRoutes = {
7776
+ r2: false,
7777
+ credentialProxy: false
7778
+ };
7539
7779
  mountOperationQueue = Promise.resolve();
7540
7780
  currentRuntime;
7541
7781
  currentLifetime;
@@ -7684,7 +7924,15 @@ var Sandbox = class Sandbox extends Container {
7684
7924
  return this.createSandboxClient();
7685
7925
  }
7686
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
+ }
7687
7934
  super(ctx, env$1);
7935
+ this.restoredMountRoutes = readRestoredMountRoutes(this);
7688
7936
  const envObj = env$1;
7689
7937
  ["SANDBOX_LOG_LEVEL", "SANDBOX_LOG_FORMAT"].forEach((key) => {
7690
7938
  if (envObj?.[key]) this.envVars[key] = String(envObj[key]);
@@ -8062,23 +8310,18 @@ var Sandbox = class Sandbox extends Container {
8062
8310
  const prefix = options.prefix;
8063
8311
  let mountOutcome = "error";
8064
8312
  let mountError;
8065
- let passwordFilePath;
8066
- let additionalHeaderFilePath;
8067
8313
  try {
8068
8314
  validateBucketBindingName(bucket, mountPath);
8069
8315
  this.validateMountPath(mountPath);
8070
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.`);
8071
8319
  for (const [existingMountPath, mountInfo$1] of this.activeMounts) {
8072
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.`);
8073
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.`);
8074
8322
  }
8075
- passwordFilePath = this.generatePasswordFilePath();
8076
- additionalHeaderFilePath = this.generateS3FSAdditionalHeaderFilePath();
8077
- await this.createPasswordFile(passwordFilePath, bucket, {
8078
- accessKeyId: "x",
8079
- secretAccessKey: "x"
8080
- });
8081
- await this.createDisableExpectHeaderFile(additionalHeaderFilePath);
8323
+ const passwordFilePath = this.generatePasswordFilePath();
8324
+ const additionalHeaderFilePath = this.generateS3FSAdditionalHeaderFilePath();
8082
8325
  const mountInfo = {
8083
8326
  mountId: crypto.randomUUID(),
8084
8327
  mountType: "r2-egress",
@@ -8091,48 +8334,47 @@ var Sandbox = class Sandbox extends Container {
8091
8334
  readOnly: options.readOnly ?? false
8092
8335
  };
8093
8336
  this.activeMounts.set(mountPath, mountInfo);
8094
- await this.configureR2EgressOutbound(this.getR2EgressParams());
8095
- await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
8096
- const s3fsSource = bucket;
8097
- const optionsStr = shellEscape(serializeS3fsOptions({
8098
- passwd_file: passwordFilePath,
8099
- ...R2_DEFAULT_S3FS_OPTIONS,
8100
- ...parseS3fsOptions(resolveS3fsOptions("r2", options.s3fsOptions)),
8101
- use_path_request_style: true,
8102
- url: "http://r2.internal",
8103
- ahbe_conf: additionalHeaderFilePath,
8104
- ...options.readOnly ? { ro: true } : {}
8105
- }));
8106
- const mountCmd = `s3fs ${shellEscape(s3fsSource)} ${shellEscape(mountPath)} -o ${optionsStr}`;
8107
- this.logger.debug("r2-egress: running s3fs", { mountCmd });
8108
- const result = await this.execInternal(mountCmd);
8109
- this.logger.debug("r2-egress: s3fs exited", {
8110
- exitCode: result.exitCode,
8111
- stdout: result.stdout,
8112
- stderr: result.stderr
8113
- });
8114
- if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
8115
- const mountpointCheck = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && echo 'FUSE_MOUNTED' || echo 'NOT_FUSE_MOUNTED'`);
8116
- this.logger.debug("r2-egress: mountpoint check", {
8117
- stdout: mountpointCheck.stdout.trim(),
8118
- exitCode: mountpointCheck.exitCode
8119
- });
8120
- 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
+ }
8121
8374
  mountInfo.mounted = true;
8122
8375
  mountOutcome = "success";
8123
8376
  } catch (error) {
8124
8377
  mountError = error instanceof Error ? error : new Error(String(error));
8125
- const failedMount = this.activeMounts.get(mountPath);
8126
- this.activeMounts.delete(mountPath);
8127
- if (failedMount?.mountType === "r2-egress") {
8128
- await this.deletePasswordFile(failedMount.passwordFilePath).catch(() => {});
8129
- if (failedMount.additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(failedMount.additionalHeaderFilePath).catch(() => {});
8130
- } else {
8131
- if (passwordFilePath) await this.deletePasswordFile(passwordFilePath).catch(() => {});
8132
- if (additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(additionalHeaderFilePath).catch(() => {});
8133
- }
8134
- const remainingParams = this.getR2EgressParams();
8135
- await this.configureR2EgressOutbound(remainingParams).catch(() => {});
8136
8378
  throw error;
8137
8379
  } finally {
8138
8380
  logCanonicalEvent(this.logger, {
@@ -8148,6 +8390,16 @@ var Sandbox = class Sandbox extends Container {
8148
8390
  }
8149
8391
  }
8150
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
+ /**
8151
8403
  * Production mount: S3FS-FUSE inside the container
8152
8404
  */
8153
8405
  async mountBucketFuse(bucket, mountPath, options) {
@@ -8155,10 +8407,7 @@ var Sandbox = class Sandbox extends Container {
8155
8407
  const prefix = options.prefix;
8156
8408
  let mountOutcome = "error";
8157
8409
  let mountError;
8158
- let passwordFilePath;
8159
- let additionalHeaderFilePath;
8160
8410
  let provider = null;
8161
- let dirExisted = true;
8162
8411
  try {
8163
8412
  this.validateMountOptions(bucket, mountPath, {
8164
8413
  ...options,
@@ -8180,8 +8429,8 @@ var Sandbox = class Sandbox extends Container {
8180
8429
  });
8181
8430
  const credentialProxyEnabled = options.credentialProxy === true;
8182
8431
  if (credentialProxyEnabled) this.validateProtectedS3fsOptions(options.s3fsOptions, "credential proxy", ["ahbe_conf", "use_path_request_style"]);
8183
- passwordFilePath = this.generatePasswordFilePath();
8184
- if (credentialProxyEnabled) additionalHeaderFilePath = this.generateS3FSAdditionalHeaderFilePath();
8432
+ const passwordFilePath = this.generatePasswordFilePath();
8433
+ const additionalHeaderFilePath = credentialProxyEnabled ? this.generateS3FSAdditionalHeaderFilePath() : void 0;
8185
8434
  const mountId = crypto.randomUUID();
8186
8435
  const mountInfo = {
8187
8436
  mountId,
@@ -8204,55 +8453,37 @@ var Sandbox = class Sandbox extends Container {
8204
8453
  } } : {}
8205
8454
  };
8206
8455
  this.activeMounts.set(mountPath, mountInfo);
8207
- await this.createPasswordFile(passwordFilePath, bucket, credentialProxyEnabled ? {
8208
- accessKeyId: "x",
8209
- secretAccessKey: "x"
8210
- } : credentials);
8211
- if (credentialProxyEnabled) {
8212
- if (additionalHeaderFilePath) await this.createDisableExpectHeaderFile(additionalHeaderFilePath);
8213
- 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;
8214
8482
  }
8215
- dirExisted = (await this.execInternal(`test -d ${shellEscape(mountPath)}`)).exitCode === 0;
8216
- await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
8217
- const effectiveOptions = credentialProxyEnabled ? {
8218
- ...options,
8219
- endpoint: `http://${S3_CREDENTIAL_PROXY_HOST}/${mountId}`,
8220
- s3fsOptions: [
8221
- ...provider === "r2" ? R2_DEFAULT_S3FS_OPTION_ENTRIES : [],
8222
- ...options.s3fsOptions ?? [],
8223
- ...additionalHeaderFilePath ? [`ahbe_conf=${additionalHeaderFilePath}`] : [],
8224
- "use_path_request_style"
8225
- ]
8226
- } : options;
8227
- await this.executeS3FSMount(s3fsSource, mountPath, effectiveOptions, provider, passwordFilePath);
8228
8483
  mountInfo.mounted = true;
8229
8484
  mountOutcome = "success";
8230
8485
  } catch (error) {
8231
8486
  mountError = error instanceof Error ? error : new Error(String(error));
8232
- try {
8233
- await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && fusermount -u ${shellEscape(mountPath)}`);
8234
- } catch {}
8235
- if (passwordFilePath) await this.deletePasswordFile(passwordFilePath);
8236
- if (additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(additionalHeaderFilePath);
8237
- if (!dirExisted) try {
8238
- await this.execInternal(`rmdir ${shellEscape(mountPath)} 2>/dev/null`);
8239
- } catch {}
8240
- const failedMount = this.activeMounts.get(mountPath);
8241
- if (failedMount?.mountType === "fuse" && failedMount.credentialProxy) try {
8242
- await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams({ excludeMountId: failedMount.mountId }));
8243
- this.activeMounts.delete(mountPath);
8244
- evictSigV4ClientCacheEntry(failedMount.mountId);
8245
- evictDirectoryMarkerCacheForMount(failedMount.mountId);
8246
- } catch (cleanupError) {
8247
- this.logger.warn("credential proxy cleanup failed", {
8248
- mountPath,
8249
- error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
8250
- });
8251
- this.activeMounts.delete(mountPath);
8252
- evictSigV4ClientCacheEntry(failedMount.mountId);
8253
- evictDirectoryMarkerCacheForMount(failedMount.mountId);
8254
- }
8255
- else this.activeMounts.delete(mountPath);
8256
8487
  throw error;
8257
8488
  } finally {
8258
8489
  logCanonicalEvent(this.logger, {
@@ -8268,6 +8499,35 @@ var Sandbox = class Sandbox extends Container {
8268
8499
  }
8269
8500
  }
8270
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
+ /**
8271
8531
  * Manually unmount a bucket filesystem
8272
8532
  *
8273
8533
  * @param mountPath - Absolute path where the bucket is mounted
@@ -8407,11 +8667,9 @@ var Sandbox = class Sandbox extends Container {
8407
8667
  }
8408
8668
  /**
8409
8669
  * Create s3fs ahbe_conf file that suppresses the Expect: 100-continue header.
8410
- * Restricted to 0600 so s3fs will accept it (same requirement as passwd files).
8411
8670
  */
8412
8671
  async createDisableExpectHeaderFile(headerFilePath) {
8413
- await this.client.files.writeFile(headerFilePath, S3FS_DISABLE_EXPECT_HEADER_CONFIG, DISABLE_SESSION_TOKEN);
8414
- await this.execInternal(`chmod 0600 ${shellEscape(headerFilePath)}`);
8672
+ await this.writeOwnerOnlyFile(headerFilePath, S3FS_DISABLE_EXPECT_HEADER_CONFIG);
8415
8673
  }
8416
8674
  /**
8417
8675
  * Create password file with s3fs credentials
@@ -8419,8 +8677,16 @@ var Sandbox = class Sandbox extends Container {
8419
8677
  */
8420
8678
  async createPasswordFile(passwordFilePath, bucket, credentials) {
8421
8679
  const content = `${bucket}:${credentials.accessKeyId}:${credentials.secretAccessKey}`;
8422
- await this.client.files.writeFile(passwordFilePath, content, DISABLE_SESSION_TOKEN);
8423
- 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)}`);
8424
8690
  }
8425
8691
  /**
8426
8692
  * Delete password file
@@ -8588,6 +8854,7 @@ var Sandbox = class Sandbox extends Container {
8588
8854
  async onStart() {
8589
8855
  this.logger.debug("Sandbox started");
8590
8856
  await this.currentRuntime.markStarted();
8857
+ await this.removeStaleMountRoutes();
8591
8858
  this.checkVersionCompatibility().catch((error) => {
8592
8859
  this.logger.error("Version compatibility check failed", error instanceof Error ? error : new Error(String(error)));
8593
8860
  });
@@ -8669,6 +8936,7 @@ var Sandbox = class Sandbox extends Container {
8669
8936
  this.containerGeneration++;
8670
8937
  this.defaultSession = null;
8671
8938
  this.defaultSessionInit = null;
8939
+ const disconnectClient = this.transport !== "rpc" || this.client.isWebSocketConnected();
8672
8940
  await this.currentRuntime.clear();
8673
8941
  await this.clearActivePreviewPorts();
8674
8942
  try {
@@ -8676,7 +8944,8 @@ var Sandbox = class Sandbox extends Container {
8676
8944
  } catch (error) {
8677
8945
  this.logger.error("Failed to reconcile tunnel storage after container stop", error instanceof Error ? error : new Error(String(error)));
8678
8946
  }
8679
- 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();
8680
8949
  let hadR2EgressMount = false;
8681
8950
  let hadCredentialProxyMount = false;
8682
8951
  for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
@@ -8824,6 +9093,13 @@ var Sandbox = class Sandbox extends Container {
8824
9093
  /**
8825
9094
  * Explicit container-start hook for the RPC transport.
8826
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
+ *
8827
9103
  * Runs `startAndWaitForPorts` under a short instance-get budget
8828
9104
  * ({@link RPC_START_INSTANCE_GET_TIMEOUT_MS}) so a single attempt fails fast
8829
9105
  * under capacity pressure — the base class throws the classifiable
@@ -8843,6 +9119,7 @@ var Sandbox = class Sandbox extends Container {
8843
9119
  */
8844
9120
  async startContainerForRPC() {
8845
9121
  try {
9122
+ if ((await this.getState()).status === "healthy" && this.ctx.container?.running !== false) return;
8846
9123
  await this.startAndWaitForPorts({
8847
9124
  ports: 3e3,
8848
9125
  cancellationOptions: {
@@ -9058,6 +9335,14 @@ var Sandbox = class Sandbox extends Container {
9058
9335
  if (!await this.currentRuntime.isActive(runtime)) return this.stalePreviewURLResponse();
9059
9336
  return new Response("Container suddenly disconnected, try again", { status: 500 });
9060
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
+ }
9061
9346
  return result.response;
9062
9347
  }
9063
9348
  buildPreviewProxyRequest(request, port, sandboxId) {
@@ -9069,10 +9354,6 @@ var Sandbox = class Sandbox extends Container {
9069
9354
  headers.set("X-Forwarded-Host", url.hostname);
9070
9355
  headers.set("X-Forwarded-Proto", url.protocol.replace(":", ""));
9071
9356
  headers.set("X-Sandbox-Name", this.sandboxName ?? sandboxId);
9072
- if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") return new Request(request, {
9073
- headers,
9074
- redirect: "manual"
9075
- });
9076
9357
  return new Request(proxyUrl, {
9077
9358
  method: request.method,
9078
9359
  headers,
@@ -9323,7 +9604,6 @@ var Sandbox = class Sandbox extends Container {
9323
9604
  logCanonicalEvent(this.logger, {
9324
9605
  event: "sandbox.exec",
9325
9606
  outcome: execError ? "error" : "success",
9326
- command,
9327
9607
  exitCode: execOutcome?.exitCode,
9328
9608
  durationMs: Date.now() - startTime,
9329
9609
  sessionId: this.getPublicExecutionSessionId(sessionId),
@@ -10058,6 +10338,7 @@ var Sandbox = class Sandbox extends Container {
10058
10338
  sandboxId: this.ctx.id.toString(),
10059
10339
  currentRuntime: this.currentRuntime,
10060
10340
  currentLifetime: this.currentLifetime,
10341
+ withPortLock: this.tunnelPortLock,
10061
10342
  getNamedTunnelConfig: async () => {
10062
10343
  const envObj = this.env;
10063
10344
  const token = getEnvString(envObj, "CLOUDFLARE_API_TOKEN");
@@ -10839,9 +11120,9 @@ var Sandbox = class Sandbox extends Container {
10839
11120
  ...startLines,
10840
11121
  ...waitLines,
10841
11122
  `FAILED=$(( ${exitVars.join(" + ")} ))`,
10842
- `if [ "$FAILED" -ne 0 ]; then rm -f ${shellEscape(tmpPath)}; exit 1; fi`
11123
+ `if [ "$FAILED" -ne 0 ]; then echo "download part exit codes: ${exitVars.join(" ")}" >&2; rm -f ${shellEscape(tmpPath)}; exit 1; fi`
10843
11124
  ].join("; ");
10844
- const result = await this.execWithSession(script, backupSession, {
11125
+ const result = await this.execWithSession(`( ${script} )`, backupSession, {
10845
11126
  timeout: 181e4,
10846
11127
  origin: "internal"
10847
11128
  });
@@ -11504,11 +11785,47 @@ var Sandbox = class Sandbox extends Container {
11504
11785
  });
11505
11786
  }
11506
11787
  }
11507
- 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
+ } };
11508
11826
  const ctx = this.ctx;
11509
11827
  if (!ctx.container?.interceptOutboundHttp) throw new InvalidMountConfigError("R2 binding mounts require container outbound interception support");
11510
11828
  if (!ctx.exports?.ContainerProxy) throw new InvalidMountConfigError("R2 binding mounts require exporting ContainerProxy from the Worker entrypoint");
11511
- this.constructor.outboundHandlers = { r2EgressMount: r2EgressHandler };
11512
11829
  if (Object.keys(params.buckets).length > 0) await this.setOutboundByHost("r2.internal", "r2EgressMount", params);
11513
11830
  else await this.removeOutboundByHost("r2.internal");
11514
11831
  this.logger.debug("r2 egress: registering host interception", {
@@ -11528,12 +11845,15 @@ var Sandbox = class Sandbox extends Container {
11528
11845
  if (!isFetcher(fetcher)) throw new InvalidMountConfigError("R2 binding mounts require ContainerProxy to return a valid Fetcher");
11529
11846
  await ctx.container.interceptOutboundHttp("r2.internal", fetcher);
11530
11847
  }
11531
- async configureS3CredentialProxyOutbound(params) {
11848
+ async configureS3CredentialProxyOutbound(activeParams) {
11849
+ const params = { mounts: {
11850
+ ...this.restoredMountRoutes.credentialProxyMounts,
11851
+ ...activeParams.mounts
11852
+ } };
11532
11853
  const ctx = this.ctx;
11533
11854
  if (!ctx.container?.interceptOutboundHttp) throw new InvalidMountConfigError("Credential proxy bucket mounts require container outbound interception support");
11534
11855
  if (!ctx.exports?.ContainerProxy) throw new InvalidMountConfigError("Credential proxy bucket mounts require exporting ContainerProxy from the Worker entrypoint");
11535
11856
  const hosts = [S3_CREDENTIAL_PROXY_HOST, S3_CREDENTIAL_PROXY_DIAGNOSTIC_HOST];
11536
- this.constructor.outboundHandlers = { s3CredentialProxyMount: s3CredentialProxyHandler };
11537
11857
  if (Object.keys(params.mounts).length > 0) for (const host of hosts) await this.setOutboundByHost(host, "s3CredentialProxyMount", params);
11538
11858
  else for (const host of hosts) await this.removeOutboundByHost(host);
11539
11859
  const hostOverrides = {};
@@ -11566,4 +11886,4 @@ var Sandbox = class Sandbox extends Container {
11566
11886
 
11567
11887
  //#endregion
11568
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 };
11569
- //# sourceMappingURL=sandbox-Co2NYKhX.js.map
11889
+ //# sourceMappingURL=sandbox-BYPanejK.js.map