@cloudflare/sandbox 0.12.4 → 0.12.6

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.
@@ -49,6 +49,8 @@ interface BridgeEnv {
49
49
  SANDBOX_API_KEY?: string;
50
50
  WARM_POOL_TARGET?: string;
51
51
  WARM_POOL_REFRESH_INTERVAL?: string;
52
+ WARM_POOL_MAX_INSTANCES?: string;
53
+ WARM_POOL_SCALE_BATCH_SIZE?: string;
52
54
  [key: string]: unknown;
53
55
  }
54
56
  //#endregion
@@ -75,6 +77,20 @@ interface WarmPoolConfig {
75
77
  warmTarget?: number;
76
78
  /** How often to check and replenish warm containers (ms). @default 10000 */
77
79
  refreshInterval?: number;
80
+ /**
81
+ * Operator-declared max_instances ceiling, mirroring
82
+ * `containers[].max_instances` in wrangler.jsonc. Seeds the pool's capacity
83
+ * math so it never blindly attempts doomed starts. 0/undefined = auto-learn
84
+ * the ceiling reactively from platform errors (legacy behaviour).
85
+ * @default 0
86
+ */
87
+ maxInstances?: number;
88
+ /**
89
+ * Number of containers to start in parallel per scale-up batch. Higher
90
+ * values fill the pool faster but risk the platform throttling a cold-start
91
+ * stampede. Clamped to [1, MAX_BATCH_SIZE]. @default 5
92
+ */
93
+ scaleBatchSize?: number;
78
94
  }
79
95
  interface PoolStats {
80
96
  /** Number of warm (unassigned) containers ready for use */
@@ -109,6 +125,13 @@ declare class WarmPool extends DurableObject<WarmPoolEnv> {
109
125
  private knownMaxInstances;
110
126
  private capacityExhausted;
111
127
  private initialized;
128
+ /** Guards against overlapping eager refills triggered by concurrent pops. */
129
+ private refillInFlight;
130
+ /**
131
+ * The currently in-flight eager refill, or null. Exposed for tests to await
132
+ * the otherwise fire-and-forget refill deterministically.
133
+ */
134
+ private refillPromise;
112
135
  /**
113
136
  * Get a container UUID for the given sandbox ID.
114
137
  * - If this ID already has an assigned container that's still running, return it.
@@ -139,6 +162,13 @@ declare class WarmPool extends DurableObject<WarmPoolEnv> {
139
162
  * Does not affect containers that are assigned to sandbox IDs.
140
163
  */
141
164
  shutdownPrewarmed(): Promise<void>;
165
+ /**
166
+ * Kick a non-blocking refill toward warmTarget. Debounced via refillInFlight
167
+ * so a burst of concurrent pops triggers at most one adjustPool sweep at a
168
+ * time; capacity is respected because adjustPool clamps to remainingCapacity.
169
+ * Errors are swallowed so a failed refill never rejects the triggering call.
170
+ */
171
+ private requestRefill;
142
172
  alarm(): Promise<void>;
143
173
  private init;
144
174
  private persist;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/bridge/types.ts","../../src/bridge/helpers.ts","../../src/bridge/warm-pool.ts","../../src/bridge/index.ts"],"sourcesContent":[],"mappings":";;;;;;;AA6DgB,UAvCC,YAAA,CAuCD;EAEP;;;AAaT;;;;ICJgB,OAAA,CAAA,EAAU,MAAA;IAyBV;;;;AC7EhB;AAOA;AAWC;AA6CD;EAA4C,cAAA,CAAA,EAAA,MAAA;EA2BH;;;;;EAiFf,WAAA,CAAA,EAAA,MAAA;;;;;;;;UFzIT,cAAA;EGgCD,KAAA,EAAA,OAAM,EH9BT,OG8BS,EAAA,GAAA,EAAA,GAAA,EAAA,GAAA,EH5Bb,gBG4Ba,CAAA,EH3BjB,QG2BiB,GH3BN,OG2BM,CH3BE,QG2BF,CAAA;EACZ,SAAA,EAAA,UAAA,EH1BM,mBG0BN,EAAA,GAAA,EAAA,GAAA,EAAA,GAAA,EHxBD,gBGwBC,CAAA,EAAA,IAAA,GHvBE,OGuBF,CAAA,IAAA,CAAA;EACC,CAAA,GAAA,EAAA,MAAA,CAAA,EAAA,OAAA;;;;;;UHZM,SAAA;;;;;;;;;;;AAAjB;;;;ACJA;AAyBgB,iBAzBA,UAAA,CAyBoB,GAAA,EAAA,MAAA,CAAA,EAAA,MAAA;;;;AC7EpC;AAOA;AAmDU,iBDmBM,oBAAA,CClBL,QAAsB,EAAA,MAAA,CAAA,EAAA,MAAA,GAAA,IAAA;;;AFHhB,UExDA,cAAA,CFwDS;;;;ECJV,eAAU,CAAA,EAAA,MAAA;AAyB1B;UCtEiB,SAAA;;;EAPA;EAOA,QAAA,EAAA,MAAS;EAmDhB;EAKG,KAAA,EAAA,MAAS;EAAsB;EA2BH,MAAA,EA3E/B,QA2E+B,CA3EtB,cA2EsB,CAAA;EA6CG;EAYE,YAAA,EAAA,MAAA,GAAA,IAAA;;;;;;;UAzFpC,WAAA,CAKoB;EAAa,OAAA,EAJhC,sBAIgC;;;cAA9B,QAAA,SAAiB,cAAc;ECG5B,QAAA,MAAM;EACZ;EACC,QAAA,cAAA;EACQ;EAAhB,QAAA,WAAA;EAAe;;;;;;;;;;;mCDqBuB;;;;;;sCA6CG;;;;wCAYE;;;;cAS1B,QAAQ;;;;;oBAeF,iBAAiB;;;;;uBAUd;WAyBZ;;;;;;;;;;;;;;;;;;;;;;;;AAvMjB;AAWC;AA6CD;;;;;;;;;;AA+IiB,iBC5ID,MAAA,CD4IC,MAAA,EC3IP,cD2IO,EAAA,MAAA,CAAA,EC1IN,YD0IM,CAAA,ECzId,eDyIc,CCzIE,SDyIF,CAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/bridge/types.ts","../../src/bridge/helpers.ts","../../src/bridge/warm-pool.ts","../../src/bridge/index.ts"],"sourcesContent":[],"mappings":";;;;;;;AA6DgB,UAvCC,YAAA,CAuCD;EAEP;;;AAaT;;;;ICJgB,OAAA,CAAA,EAAU,MAAA;IAyBV;;;;AC7EhB;AAqBA;AAWC;AAoDD;EAA4C,cAAA,CAAA,EAAA,MAAA;EAoCH;;;;;EAqFf,WAAA,CAAA,EAAA,MAAA;;;;;;;;UF3KT,cAAA;EGgCD,KAAA,EAAA,OAAM,EH9BT,OG8BS,EAAA,GAAA,EAAA,GAAA,EAAA,GAAA,EH5Bb,gBG4Ba,CAAA,EH3BjB,QG2BiB,GH3BN,OG2BM,CH3BE,QG2BF,CAAA;EACZ,SAAA,EAAA,UAAA,EH1BM,mBG0BN,EAAA,GAAA,EAAA,GAAA,EAAA,GAAA,EHxBD,gBGwBC,CAAA,EAAA,IAAA,GHvBE,OGuBF,CAAA,IAAA,CAAA;EACC,CAAA,GAAA,EAAA,MAAA,CAAA,EAAA,OAAA;;;;;;UHZM,SAAA;;;;;;;;;;;;;AAAjB;;;;ACJA;AAyBgB,iBAzBA,UAAA,CAyBoB,GAAA,EAAA,MAAA,CAAA,EAAA,MAAA;;;;AC7EpC;AAqBA;AA0DU,iBDFM,oBAAA,CCGL,QAAsB,EAAA,MAAA,CAAA,EAAA,MAAA,GAAA,IAAA;;;AFxBhB,UExDA,cAAA,CFwDS;;;;ECJV,eAAU,CAAA,EAAA,MAAA;EAyBV;;;;AC7EhB;AAqBA;AAWC;EAoDY,YAAS,CAAA,EAAA,MAAA;EAAsB;;;;;EA0GxB,cAAA,CAAA,EAAA,MAAA;;AAeuB,UAxL1B,SAAA,CAwL0B;EA+Bd;EAyDZ,IAAA,EAAA,MAAA;EAjNa;EAAa,QAAA,EAAA,MAAA;;;;EClB3B,MAAA,EDrCN,QCqCY,CDrCH,cCqCG,CAAA;EACZ;EACC,YAAA,EAAA,MAAA,GAAA,IAAA;;;;;;;UDWD,WAAA;WACC;;;cAIE,QAAA,SAAiB,cAAc;;;;;;;;;;;;;;;;;;;;;;;;mCAoCH;;;;;;sCAiDG;;;;wCAYE;;;;cAS1B,QAAQ;;;;;oBAeF,iBAAiB;;;;;uBA+Bd;;;;;;;;WAyDZ;;;;;;;;;;;;;;;;;;;;;;;;AAhRjB;AAWC;AAoDD;;;;;;;;;;AAiNiB,iBCnOD,MAAA,CDmOC,MAAA,EClOP,cDkOO,EAAA,MAAA,CAAA,ECjON,YDiOM,CAAA,EChOd,eDgOc,CChOE,SDgOF,CAAA"}
@@ -1,25 +1,35 @@
1
1
  import "../dist-B_eXrP83.js";
2
2
  import "../errors-CpkKPbWC.js";
3
- import { S as validateTunnelName, r as getSandbox, w as streamFile, x as validatePort, y as SandboxSecurityError } from "../sandbox-CyqG4jca.js";
4
- import { DurableObject, env } from "cloudflare:workers";
3
+ import { S as validateTunnelName, r as getSandbox, w as streamFile, x as validatePort, y as SandboxSecurityError } from "../sandbox-FBmZ9mj1.js";
4
+ import { DurableObject, env, tracing } from "cloudflare:workers";
5
5
  import { Hono } from "hono";
6
6
 
7
7
  //#region src/bridge/pool.ts
8
8
  /**
9
+ * Parse warm-pool configuration from bridge env vars.
10
+ *
11
+ * `WARM_POOL_MAX_INSTANCES` should match `containers[].max_instances` in
12
+ * wrangler.jsonc. 0/unset means auto-learn the ceiling reactively.
13
+ */
14
+ function parsePoolConfig(env$1) {
15
+ return {
16
+ warmTarget: Number.parseInt(env$1.WARM_POOL_TARGET || "0", 10) || 0,
17
+ refreshInterval: Number.parseInt(env$1.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4,
18
+ maxInstances: Number.parseInt(env$1.WARM_POOL_MAX_INSTANCES || "0", 10) || 0,
19
+ scaleBatchSize: Number.parseInt(env$1.WARM_POOL_SCALE_BATCH_SIZE || "5", 10) || 5
20
+ };
21
+ }
22
+ /**
9
23
  * Prime the warm pool — pushes current configuration to the WarmPool
10
24
  * Durable Object so it starts its alarm loop.
11
25
  *
12
26
  * Called by the scheduled() handler and by POST /pool/prime.
13
27
  */
14
28
  async function primePool(env$1, warmPoolBinding) {
15
- const warmTarget = Number.parseInt(env$1.WARM_POOL_TARGET || "0", 10) || 0;
16
- const refreshInterval = Number.parseInt(env$1.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
29
+ const config = parsePoolConfig(env$1);
17
30
  const ns = env$1[warmPoolBinding];
18
31
  const poolId = ns.idFromName("global-pool");
19
- await ns.get(poolId).configure({
20
- warmTarget,
21
- refreshInterval
22
- });
32
+ await ns.get(poolId).configure(config);
23
33
  }
24
34
 
25
35
  //#endregion
@@ -1761,6 +1771,51 @@ function esc(s) {
1761
1771
  return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1762
1772
  }
1763
1773
 
1774
+ //#endregion
1775
+ //#region src/bridge/tracing.ts
1776
+ /**
1777
+ * Custom span instrumentation for the bridge API.
1778
+ *
1779
+ * Wraps each Hono route handler in a Cloudflare custom span
1780
+ * (`tracing.enterSpan`) named `bridge.<operation>`, seeding common attributes
1781
+ * (sandbox ID, container UUID, HTTP method/route, response status) and exposing
1782
+ * the active span so handlers can attach operation-specific metadata.
1783
+ *
1784
+ * See https://developers.cloudflare.com/workers/observability/traces/custom-spans/
1785
+ */
1786
+ /** True when the runtime exposes the custom-span API. */
1787
+ function tracingAvailable() {
1788
+ return typeof tracing === "object" && tracing !== null && typeof tracing.enterSpan === "function";
1789
+ }
1790
+ /**
1791
+ * Wrap a route handler in a `bridge.<name>` span. Seeds common attributes and
1792
+ * stashes the span on the context for `annotate()`. When tracing is
1793
+ * unavailable the handler runs unchanged.
1794
+ */
1795
+ function traced(name, handler) {
1796
+ if (!tracingAvailable()) return handler;
1797
+ return (c) => tracing.enterSpan(`bridge.${name}`, async (span) => {
1798
+ span.setAttribute("bridge.operation", name);
1799
+ span.setAttribute("http.request.method", c.req.method);
1800
+ span.setAttribute("http.route", c.req.routePath);
1801
+ const sandboxId = c.req.param("id");
1802
+ if (sandboxId) span.setAttribute("sandbox.id", sandboxId);
1803
+ const containerUUID = c.get("containerUUID");
1804
+ if (containerUUID) span.setAttribute("sandbox.container_uuid", containerUUID);
1805
+ c.set("span", span);
1806
+ const response = await handler(c);
1807
+ span.setAttribute("http.response.status_code", response.status);
1808
+ return response;
1809
+ });
1810
+ }
1811
+ /**
1812
+ * Attach an attribute to the active bridge span, if any. No-op when tracing is
1813
+ * disabled or the request is not sampled.
1814
+ */
1815
+ function annotate(c, key, value) {
1816
+ c.get("span")?.setAttribute(key, value);
1817
+ }
1818
+
1764
1819
  //#endregion
1765
1820
  //#region src/bridge/routes.ts
1766
1821
  /** Typed wrapper around the SDK's getSandbox() that returns a BridgeSandbox. */
@@ -1859,7 +1914,7 @@ function createBridgeApp(config) {
1859
1914
  }
1860
1915
  return next();
1861
1916
  });
1862
- app.post(`${apiPrefix}/sandbox`, async (c) => {
1917
+ app.post(`${apiPrefix}/sandbox`, traced("create_sandbox", async (c) => {
1863
1918
  const token = c.env.SANDBOX_API_KEY;
1864
1919
  if (token) {
1865
1920
  const authHeader = c.req.header("Authorization") ?? "";
@@ -1868,20 +1923,16 @@ function createBridgeApp(config) {
1868
1923
  const bytes = new Uint8Array(16);
1869
1924
  crypto.getRandomValues(bytes);
1870
1925
  const id = base32Encode(bytes);
1926
+ annotate(c, "sandbox.id", id);
1871
1927
  return c.json({ id });
1872
- });
1928
+ }));
1873
1929
  app.use(`${apiPrefix}/sandbox/:id/*`, async (c, next) => {
1874
1930
  const sandboxId = c.req.param("id");
1875
- const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
1876
- const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
1877
1931
  const poolNs = getWarmPoolNs(c.env);
1878
1932
  const poolId = poolNs.idFromName("global-pool");
1879
1933
  const poolStub = poolNs.get(poolId);
1880
1934
  try {
1881
- await poolStub.configure({
1882
- warmTarget,
1883
- refreshInterval
1884
- });
1935
+ await poolStub.configure(parsePoolConfig(c.env));
1885
1936
  const containerUUID = await poolStub.getContainer(sandboxId);
1886
1937
  c.set("containerUUID", containerUUID);
1887
1938
  } catch (err) {
@@ -1906,7 +1957,7 @@ function createBridgeApp(config) {
1906
1957
  }
1907
1958
  return next();
1908
1959
  });
1909
- app.post(`${apiPrefix}/sandbox/:id/exec`, async (c) => {
1960
+ app.post(`${apiPrefix}/sandbox/:id/exec`, traced("exec", async (c) => {
1910
1961
  let body;
1911
1962
  try {
1912
1963
  body = await c.req.json();
@@ -1914,21 +1965,28 @@ function createBridgeApp(config) {
1914
1965
  return errorJson("Invalid JSON body", "invalid_request", 400);
1915
1966
  }
1916
1967
  if (!Array.isArray(body.argv) || body.argv.length === 0) return errorJson("argv must be a non-empty array", "invalid_request", 400);
1968
+ annotate(c, "exec.argv_count", body.argv.length);
1969
+ if (body.argv[0]) annotate(c, "exec.command", body.argv[0]);
1917
1970
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), c.get("containerUUID"));
1918
1971
  const rawSessionId = c.req.header("Session-Id");
1919
1972
  let executor = sandbox;
1920
1973
  if (rawSessionId) {
1921
1974
  const sessionId = validateSessionId(rawSessionId);
1922
1975
  if (!sessionId) return errorJson("Invalid session ID format", "invalid_request", 400);
1976
+ annotate(c, "session.id", sessionId);
1923
1977
  executor = await sandbox.getSession(sessionId);
1924
1978
  }
1925
1979
  const command = body.argv.map(shellQuote).join(" ");
1926
1980
  const opts = {};
1927
- if (typeof body.timeout_ms === "number") opts.timeout = body.timeout_ms;
1981
+ if (typeof body.timeout_ms === "number") {
1982
+ opts.timeout = body.timeout_ms;
1983
+ annotate(c, "exec.timeout_ms", body.timeout_ms);
1984
+ }
1928
1985
  if (typeof body.cwd === "string") {
1929
1986
  const resolvedCwd = resolveWorkspacePath(body.cwd);
1930
1987
  if (!resolvedCwd) return errorJson("cwd must resolve to a location within /workspace", "invalid_request", 403);
1931
1988
  opts.cwd = resolvedCwd;
1989
+ annotate(c, "exec.cwd", resolvedCwd);
1932
1990
  }
1933
1991
  const { readable, writable } = new TransformStream();
1934
1992
  const writer = writable.getWriter();
@@ -1978,8 +2036,8 @@ function createBridgeApp(config) {
1978
2036
  "Cache-Control": "no-cache"
1979
2037
  }
1980
2038
  });
1981
- });
1982
- app.get(`${apiPrefix}/sandbox/:id/file/*`, async (c) => {
2039
+ }));
2040
+ app.get(`${apiPrefix}/sandbox/:id/file/*`, traced("read_file", async (c) => {
1983
2041
  const sandboxId = c.req.param("id");
1984
2042
  const fullPath = c.req.path;
1985
2043
  const marker = `${apiPrefix}/sandbox/${sandboxId}/file/`;
@@ -1987,12 +2045,14 @@ function createBridgeApp(config) {
1987
2045
  if (!relativePath) return errorJson("file path must not be empty", "invalid_request", 400);
1988
2046
  const resolvedPath = resolveWorkspacePath(`/${relativePath}`);
1989
2047
  if (!resolvedPath) return errorJson("path must resolve to a location within /workspace", "invalid_request", 403);
2048
+ annotate(c, "file.path", resolvedPath);
1990
2049
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), c.get("containerUUID"));
1991
2050
  const rawSessionId = c.req.header("Session-Id");
1992
2051
  let executor = sandbox;
1993
2052
  if (rawSessionId) {
1994
2053
  const sessionId = validateSessionId(rawSessionId);
1995
2054
  if (!sessionId) return errorJson("Invalid session ID format", "invalid_request", 400);
2055
+ annotate(c, "session.id", sessionId);
1996
2056
  executor = await sandbox.getSession(sessionId);
1997
2057
  }
1998
2058
  try {
@@ -2005,8 +2065,8 @@ function createBridgeApp(config) {
2005
2065
  if (err.code === "FILE_NOT_FOUND") return errorJson(`File not found: ${resolvedPath}`, "workspace_read_not_found", 404);
2006
2066
  return errorJson(`read failed: ${err instanceof Error ? err.message : String(err)}`, "exec_transport_error", 502);
2007
2067
  }
2008
- });
2009
- app.put(`${apiPrefix}/sandbox/:id/file/*`, async (c) => {
2068
+ }));
2069
+ app.put(`${apiPrefix}/sandbox/:id/file/*`, traced("write_file", async (c) => {
2010
2070
  const sandboxId = c.req.param("id");
2011
2071
  const fullPath = c.req.path;
2012
2072
  const marker = `${apiPrefix}/sandbox/${sandboxId}/file/`;
@@ -2014,16 +2074,19 @@ function createBridgeApp(config) {
2014
2074
  if (!relativePath) return errorJson("file path must not be empty", "invalid_request", 400);
2015
2075
  const resolvedPath = resolveWorkspacePath(`/${relativePath}`);
2016
2076
  if (!resolvedPath) return errorJson("path must resolve to a location within /workspace", "invalid_request", 403);
2077
+ annotate(c, "file.path", resolvedPath);
2017
2078
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), c.get("containerUUID"));
2018
2079
  const rawSessionId = c.req.header("Session-Id");
2019
2080
  let executor = sandbox;
2020
2081
  if (rawSessionId) {
2021
2082
  const sessionId = validateSessionId(rawSessionId);
2022
2083
  if (!sessionId) return errorJson("Invalid session ID format", "invalid_request", 400);
2084
+ annotate(c, "session.id", sessionId);
2023
2085
  executor = await sandbox.getSession(sessionId);
2024
2086
  }
2025
2087
  try {
2026
2088
  const buffer = await c.req.arrayBuffer();
2089
+ annotate(c, "file.size_bytes", buffer.byteLength);
2027
2090
  const MAX_WRITE_BYTES = 32 * 1024 * 1024;
2028
2091
  if (buffer.byteLength > MAX_WRITE_BYTES) return errorJson(`payload too large: ${buffer.byteLength} bytes exceeds the ${MAX_WRITE_BYTES}-byte limit`, "payload_too_large", 413);
2029
2092
  const bytes = new Uint8Array(buffer);
@@ -2035,12 +2098,14 @@ function createBridgeApp(config) {
2035
2098
  } catch (err) {
2036
2099
  return errorJson(`write failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_write_error", 502);
2037
2100
  }
2038
- });
2039
- app.post(`${apiPrefix}/sandbox/:id/tunnel/:port`, async (c) => {
2101
+ }));
2102
+ app.post(`${apiPrefix}/sandbox/:id/tunnel/:port`, traced("tunnel_create", async (c) => {
2040
2103
  const port = Number(c.req.param("port"));
2041
2104
  if (!validatePort(port)) return errorJson("Invalid port", "invalid_request", 400);
2105
+ annotate(c, "tunnel.port", port);
2042
2106
  const options = parseTunnelOptions(await c.req.text());
2043
2107
  if (options instanceof Response) return options;
2108
+ if (options?.name) annotate(c, "tunnel.name", options.name);
2044
2109
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), c.get("containerUUID"));
2045
2110
  try {
2046
2111
  const tunnel = await sandbox.tunnels.get(port, options);
@@ -2048,10 +2113,11 @@ function createBridgeApp(config) {
2048
2113
  } catch (err) {
2049
2114
  return errorJson(`tunnel failed: ${err instanceof Error ? err.message : String(err)}`, "tunnel_error", 502);
2050
2115
  }
2051
- });
2052
- app.delete(`${apiPrefix}/sandbox/:id/tunnel/:port`, async (c) => {
2116
+ }));
2117
+ app.delete(`${apiPrefix}/sandbox/:id/tunnel/:port`, traced("tunnel_destroy", async (c) => {
2053
2118
  const port = Number(c.req.param("port"));
2054
2119
  if (!validatePort(port)) return errorJson("Invalid port", "invalid_request", 400);
2120
+ annotate(c, "tunnel.port", port);
2055
2121
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), c.get("containerUUID"));
2056
2122
  try {
2057
2123
  await sandbox.tunnels.destroy(port);
@@ -2059,17 +2125,19 @@ function createBridgeApp(config) {
2059
2125
  } catch (err) {
2060
2126
  return errorJson(`tunnel failed: ${err instanceof Error ? err.message : String(err)}`, "tunnel_error", 502);
2061
2127
  }
2062
- });
2063
- app.get(`${apiPrefix}/sandbox/:id/running`, async (c) => {
2128
+ }));
2129
+ app.get(`${apiPrefix}/sandbox/:id/running`, traced("running", async (c) => {
2064
2130
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), c.get("containerUUID"));
2065
2131
  try {
2066
2132
  await sandbox.exec("true");
2133
+ annotate(c, "sandbox.running", true);
2067
2134
  return c.json({ running: true });
2068
2135
  } catch {
2136
+ annotate(c, "sandbox.running", false);
2069
2137
  return c.json({ running: false });
2070
2138
  }
2071
- });
2072
- app.get(`${apiPrefix}/sandbox/:id/pty`, async (c) => {
2139
+ }));
2140
+ app.get(`${apiPrefix}/sandbox/:id/pty`, traced("pty", async (c) => {
2073
2141
  const upgrade = c.req.header("Upgrade");
2074
2142
  if (!upgrade || upgrade.toLowerCase() !== "websocket") return errorJson("WebSocket upgrade required", "invalid_request", 400);
2075
2143
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), c.get("containerUUID"));
@@ -2080,26 +2148,33 @@ function createBridgeApp(config) {
2080
2148
  const cols = colsParam ? Number(colsParam) : 80;
2081
2149
  const rows = rowsParam ? Number(rowsParam) : 24;
2082
2150
  if (Number.isNaN(cols) || Number.isNaN(rows)) return errorJson("cols and rows must be valid numbers", "invalid_request", 400);
2151
+ annotate(c, "pty.cols", cols);
2152
+ annotate(c, "pty.rows", rows);
2083
2153
  const opts = {
2084
2154
  cols,
2085
2155
  rows
2086
2156
  };
2087
- if (shell) opts.shell = shell;
2157
+ if (shell) {
2158
+ opts.shell = shell;
2159
+ annotate(c, "pty.shell", shell);
2160
+ }
2088
2161
  try {
2089
2162
  if (sessionId) {
2090
2163
  const validatedId = validateSessionId(sessionId);
2091
2164
  if (!validatedId) return errorJson("Invalid session ID format", "invalid_request", 400);
2165
+ annotate(c, "session.id", validatedId);
2092
2166
  return await (await sandbox.getSession(validatedId)).terminal(c.req.raw, opts);
2093
2167
  }
2094
2168
  return await sandbox.terminal(c.req.raw, opts);
2095
2169
  } catch (err) {
2096
2170
  return errorJson(`terminal failed: ${err instanceof Error ? err.message : String(err)}`, "exec_transport_error", 502);
2097
2171
  }
2098
- });
2099
- app.post(`${apiPrefix}/sandbox/:id/persist`, async (c) => {
2172
+ }));
2173
+ app.post(`${apiPrefix}/sandbox/:id/persist`, traced("persist", async (c) => {
2100
2174
  const root = "/workspace";
2101
2175
  const excludesParam = c.req.query("excludes") ?? "";
2102
2176
  const excludes = excludesParam ? excludesParam.split(",").filter((s) => s.length > 0) : [];
2177
+ annotate(c, "persist.exclude_count", excludes.length);
2103
2178
  for (const ex of excludes) if (ex.includes("..")) return errorJson("exclude paths must not contain \"..\"", "invalid_request", 400);
2104
2179
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), c.get("containerUUID"));
2105
2180
  const tmpPath = `/tmp/sandbox-persist-${Date.now()}.tar`;
@@ -2117,8 +2192,8 @@ function createBridgeApp(config) {
2117
2192
  } catch (err) {
2118
2193
  return errorJson(`persist failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_read_error", 502);
2119
2194
  }
2120
- });
2121
- app.post(`${apiPrefix}/sandbox/:id/hydrate`, async (c) => {
2195
+ }));
2196
+ app.post(`${apiPrefix}/sandbox/:id/hydrate`, traced("hydrate", async (c) => {
2122
2197
  const root = "/workspace";
2123
2198
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), c.get("containerUUID"));
2124
2199
  let tarBytes;
@@ -2128,6 +2203,7 @@ function createBridgeApp(config) {
2128
2203
  } catch {
2129
2204
  return errorJson("Could not read request body", "invalid_request", 400);
2130
2205
  }
2206
+ annotate(c, "hydrate.size_bytes", tarBytes.byteLength);
2131
2207
  if (tarBytes.byteLength === 0) return errorJson("Empty tar payload", "invalid_request", 400);
2132
2208
  const MAX_HYDRATE_BYTES = 32 * 1024 * 1024;
2133
2209
  if (tarBytes.byteLength > MAX_HYDRATE_BYTES) return errorJson(`tar payload too large: ${tarBytes.byteLength} bytes exceeds the ${MAX_HYDRATE_BYTES}-byte limit`, "invalid_request", 400);
@@ -2144,8 +2220,8 @@ function createBridgeApp(config) {
2144
2220
  } catch (err) {
2145
2221
  return errorJson(`hydrate failed: ${err instanceof Error ? err.message : String(err)}`, "workspace_archive_write_error", 502);
2146
2222
  }
2147
- });
2148
- app.post(`${apiPrefix}/sandbox/:id/mount`, async (c) => {
2223
+ }));
2224
+ app.post(`${apiPrefix}/sandbox/:id/mount`, traced("mount", async (c) => {
2149
2225
  let body;
2150
2226
  try {
2151
2227
  body = await c.req.json();
@@ -2160,6 +2236,8 @@ function createBridgeApp(config) {
2160
2236
  if (optionsError) return optionsError;
2161
2237
  const bucketName = resolveMountBucketName(body);
2162
2238
  if (bucketName instanceof Response) return bucketName;
2239
+ annotate(c, "mount.bucket", bucketName);
2240
+ annotate(c, "mount.path", body.mountPath);
2163
2241
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), c.get("containerUUID"));
2164
2242
  const sdkOptions = toSDKMountOptions(body.options);
2165
2243
  try {
@@ -2168,8 +2246,8 @@ function createBridgeApp(config) {
2168
2246
  } catch (err) {
2169
2247
  return errorJson(`mount failed: ${err instanceof Error ? err.message : String(err)}`, "mount_error", 502);
2170
2248
  }
2171
- });
2172
- app.post(`${apiPrefix}/sandbox/:id/unmount`, async (c) => {
2249
+ }));
2250
+ app.post(`${apiPrefix}/sandbox/:id/unmount`, traced("unmount", async (c) => {
2173
2251
  let body;
2174
2252
  try {
2175
2253
  body = await c.req.json();
@@ -2180,6 +2258,7 @@ function createBridgeApp(config) {
2180
2258
  if (!body.mountPath.startsWith("/")) return errorJson("mountPath must be an absolute path (start with /)", "invalid_request", 400);
2181
2259
  const normalizedPath = new URL(body.mountPath, "file:///").pathname;
2182
2260
  if (normalizedPath === "/") return errorJson("mountPath must not resolve to / (filesystem root)", "invalid_request", 400);
2261
+ annotate(c, "mount.path", normalizedPath);
2183
2262
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), c.get("containerUUID"));
2184
2263
  try {
2185
2264
  await sandbox.unmountBucket(normalizedPath);
@@ -2191,31 +2270,35 @@ function createBridgeApp(config) {
2191
2270
  } catch (err) {
2192
2271
  return errorJson(`unmount failed: ${err instanceof Error ? err.message : String(err)}`, "unmount_error", 502);
2193
2272
  }
2194
- });
2195
- app.post(`${apiPrefix}/sandbox/:id/session`, async (c) => {
2273
+ }));
2274
+ app.post(`${apiPrefix}/sandbox/:id/session`, traced("session_create", async (c) => {
2196
2275
  const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
2197
2276
  let body = {};
2198
2277
  try {
2199
2278
  body = await c.req.json();
2200
2279
  } catch {}
2280
+ if (body.cwd) annotate(c, "session.cwd", body.cwd);
2201
2281
  try {
2202
2282
  const session = await sandbox.createSession(body);
2283
+ annotate(c, "session.id", session.id);
2203
2284
  return c.json({ id: session.id });
2204
2285
  } catch (err) {
2205
2286
  return errorJson(`session create failed: ${err instanceof Error ? err.message : String(err)}`, "session_error", 502);
2206
2287
  }
2207
- });
2208
- app.delete(`${apiPrefix}/sandbox/:id/session/:sid`, async (c) => {
2288
+ }));
2289
+ app.delete(`${apiPrefix}/sandbox/:id/session/:sid`, traced("session_delete", async (c) => {
2209
2290
  const sandbox = getSandbox$1(getSandboxNs(c.env), c.get("containerUUID"));
2210
2291
  const sid = c.req.param("sid");
2292
+ if (!sid) return errorJson("session ID must not be empty", "invalid_request", 400);
2293
+ annotate(c, "session.id", sid);
2211
2294
  try {
2212
2295
  const result = await sandbox.deleteSession(sid);
2213
2296
  return c.json(result);
2214
2297
  } catch (err) {
2215
2298
  return errorJson(`session delete failed: ${err instanceof Error ? err.message : String(err)}`, "session_error", 502);
2216
2299
  }
2217
- });
2218
- app.delete(`${apiPrefix}/sandbox/:id`, async (c) => {
2300
+ }));
2301
+ app.delete(`${apiPrefix}/sandbox/:id`, traced("destroy_sandbox", async (c) => {
2219
2302
  const containerUUID = c.get("containerUUID");
2220
2303
  const sandbox = getSandboxWithoutDefaultSession(getSandboxNs(c.env), containerUUID);
2221
2304
  try {
@@ -2227,7 +2310,7 @@ function createBridgeApp(config) {
2227
2310
  await poolNs.get(poolId).reportStopped(containerUUID);
2228
2311
  } catch {}
2229
2312
  return new Response(null, { status: 204 });
2230
- });
2313
+ }));
2231
2314
  app.get(config.healthPath, (c) => {
2232
2315
  const errors = [];
2233
2316
  if (!c.env[sandboxBinding]) errors.push(`Missing required Durable Object binding "${sandboxBinding}". Ensure your wrangler.jsonc has a binding named "${sandboxBinding}".`);
@@ -2246,40 +2329,34 @@ function createBridgeApp(config) {
2246
2329
  }
2247
2330
  return next();
2248
2331
  });
2249
- app.get(`${apiPrefix}/pool/stats`, async (c) => {
2250
- const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
2251
- const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
2332
+ app.get(`${apiPrefix}/pool/stats`, traced("pool_stats", async (c) => {
2252
2333
  const poolNs = getWarmPoolNs(c.env);
2253
2334
  const poolId = poolNs.idFromName("global-pool");
2254
2335
  const poolStub = poolNs.get(poolId);
2255
2336
  try {
2256
- await poolStub.configure({
2257
- warmTarget,
2258
- refreshInterval
2259
- });
2337
+ await poolStub.configure(parsePoolConfig(c.env));
2260
2338
  } catch {}
2261
2339
  const stats = await poolStub.getStats();
2340
+ annotate(c, "pool.warm", stats.warm);
2341
+ annotate(c, "pool.assigned", stats.assigned);
2342
+ annotate(c, "pool.total", stats.total);
2343
+ if (stats.maxInstances !== null) annotate(c, "pool.max_instances", stats.maxInstances);
2262
2344
  return c.json(stats);
2263
- });
2264
- app.post(`${apiPrefix}/pool/shutdown-prewarmed`, async (c) => {
2265
- const warmTarget = Number.parseInt(c.env.WARM_POOL_TARGET || "0", 10) || 0;
2266
- const refreshInterval = Number.parseInt(c.env.WARM_POOL_REFRESH_INTERVAL || "10000", 10) || 1e4;
2345
+ }));
2346
+ app.post(`${apiPrefix}/pool/shutdown-prewarmed`, traced("pool_shutdown_prewarmed", async (c) => {
2267
2347
  const poolNs = getWarmPoolNs(c.env);
2268
2348
  const poolId = poolNs.idFromName("global-pool");
2269
2349
  const poolStub = poolNs.get(poolId);
2270
2350
  try {
2271
- await poolStub.configure({
2272
- warmTarget,
2273
- refreshInterval
2274
- });
2351
+ await poolStub.configure(parsePoolConfig(c.env));
2275
2352
  } catch {}
2276
2353
  await poolStub.shutdownPrewarmed();
2277
2354
  return c.json({ ok: true });
2278
- });
2279
- app.post(`${apiPrefix}/pool/prime`, async (c) => {
2355
+ }));
2356
+ app.post(`${apiPrefix}/pool/prime`, traced("pool_prime", async (c) => {
2280
2357
  await primePool(c.env, warmPoolBinding);
2281
2358
  return c.json({ ok: true });
2282
- });
2359
+ }));
2283
2360
  const openapiAuth = async (c, next) => {
2284
2361
  const token = c.env.SANDBOX_API_KEY;
2285
2362
  if (token) {
@@ -2312,9 +2389,14 @@ function createBridgeApp(config) {
2312
2389
  * Configuration is pushed in via `configure()` on every request (idempotent)
2313
2390
  * so changes to wrangler vars take effect without manual intervention.
2314
2391
  */
2392
+ const DEFAULT_BATCH_SIZE = 5;
2393
+ /** Upper bound on parallel starts per batch to avoid a cold-start stampede. */
2394
+ const MAX_BATCH_SIZE = 20;
2315
2395
  const DEFAULT_CONFIG = {
2316
2396
  warmTarget: 0,
2317
- refreshInterval: 1e4
2397
+ refreshInterval: 1e4,
2398
+ maxInstances: 0,
2399
+ scaleBatchSize: DEFAULT_BATCH_SIZE
2318
2400
  };
2319
2401
  var WarmPool = class extends DurableObject {
2320
2402
  config = { ...DEFAULT_CONFIG };
@@ -2328,6 +2410,13 @@ var WarmPool = class extends DurableObject {
2328
2410
  knownMaxInstances = null;
2329
2411
  capacityExhausted = false;
2330
2412
  initialized = false;
2413
+ /** Guards against overlapping eager refills triggered by concurrent pops. */
2414
+ refillInFlight = false;
2415
+ /**
2416
+ * The currently in-flight eager refill, or null. Exposed for tests to await
2417
+ * the otherwise fire-and-forget refill deterministically.
2418
+ */
2419
+ refillPromise = null;
2331
2420
  /**
2332
2421
  * Get a container UUID for the given sandbox ID.
2333
2422
  * - If this ID already has an assigned container that's still running, return it.
@@ -2346,6 +2435,7 @@ var WarmPool = class extends DurableObject {
2346
2435
  this.warmContainers.delete(containerUUID$1);
2347
2436
  this.assignments.set(sandboxId, containerUUID$1);
2348
2437
  await this.persist();
2438
+ this.requestRefill();
2349
2439
  return containerUUID$1;
2350
2440
  }
2351
2441
  if (this.remainingCapacity() <= 0) this.throwCapacityError();
@@ -2353,6 +2443,7 @@ var WarmPool = class extends DurableObject {
2353
2443
  if (containerUUID) {
2354
2444
  this.assignments.set(sandboxId, containerUUID);
2355
2445
  await this.persist();
2446
+ this.requestRefill();
2356
2447
  return containerUUID;
2357
2448
  }
2358
2449
  if (this.capacityExhausted) this.throwCapacityError();
@@ -2400,6 +2491,11 @@ var WarmPool = class extends DurableObject {
2400
2491
  ...DEFAULT_CONFIG,
2401
2492
  ...config
2402
2493
  };
2494
+ this.config.scaleBatchSize = Math.min(MAX_BATCH_SIZE, Math.max(1, this.config.scaleBatchSize || DEFAULT_BATCH_SIZE));
2495
+ if (this.config.maxInstances > 0) {
2496
+ this.knownMaxInstances = this.knownMaxInstances === null ? this.config.maxInstances : Math.min(this.knownMaxInstances, this.config.maxInstances);
2497
+ await this.ctx.storage.put("knownMaxInstances", this.knownMaxInstances);
2498
+ }
2403
2499
  await this.ctx.storage.put("config", this.config);
2404
2500
  }
2405
2501
  /**
@@ -2421,6 +2517,32 @@ var WarmPool = class extends DurableObject {
2421
2517
  }
2422
2518
  await this.persist();
2423
2519
  }
2520
+ /**
2521
+ * Kick a non-blocking refill toward warmTarget. Debounced via refillInFlight
2522
+ * so a burst of concurrent pops triggers at most one adjustPool sweep at a
2523
+ * time; capacity is respected because adjustPool clamps to remainingCapacity.
2524
+ * Errors are swallowed so a failed refill never rejects the triggering call.
2525
+ */
2526
+ requestRefill() {
2527
+ if (this.refillInFlight) return;
2528
+ if (this.warmContainers.size >= this.config.warmTarget) return;
2529
+ if (this.remainingCapacity() <= 0) return;
2530
+ this.refillInFlight = true;
2531
+ this.refillPromise = (async () => {
2532
+ try {
2533
+ await this.adjustPool();
2534
+ } catch (error) {
2535
+ console.error({
2536
+ message: "Eager refill failed",
2537
+ component: "warm-pool",
2538
+ error
2539
+ });
2540
+ } finally {
2541
+ this.refillInFlight = false;
2542
+ }
2543
+ })();
2544
+ this.ctx.waitUntil(this.refillPromise);
2545
+ }
2424
2546
  async alarm() {
2425
2547
  await this.init();
2426
2548
  this.capacityExhausted = false;
@@ -2569,25 +2691,22 @@ var WarmPool = class extends DurableObject {
2569
2691
  });
2570
2692
  return;
2571
2693
  }
2694
+ const batchSize = this.config.scaleBatchSize;
2572
2695
  console.info({
2573
2696
  message: "Scaling up pool",
2574
2697
  component: "warm-pool",
2575
2698
  starting: toStart,
2576
2699
  needed: diff,
2577
- capacity: this.remainingCapacity()
2700
+ capacity: this.remainingCapacity(),
2701
+ batchSize
2578
2702
  });
2579
- for (let i = 0; i < toStart; i++) {
2580
- if (this.capacityExhausted) {
2581
- console.log({
2582
- message: "Capacity exhausted mid-loop, stopping further starts",
2583
- component: "warm-pool"
2584
- });
2585
- break;
2586
- }
2587
- const uuid = await this.startContainer();
2588
- if (uuid) this.warmContainers.add(uuid);
2703
+ for (let i = 0; i < toStart && !this.capacityExhausted; i += batchSize) {
2704
+ const n = Math.min(batchSize, toStart - i);
2705
+ const results = await Promise.all(Array.from({ length: n }, () => this.startContainer()));
2706
+ for (const uuid of results) if (uuid) this.warmContainers.add(uuid);
2707
+ if (this.capacityExhausted) this.knownMaxInstances = this.warmContainers.size + this.assignments.size;
2708
+ await this.persist();
2589
2709
  }
2590
- await this.persist();
2591
2710
  } else if (diff < 0) {
2592
2711
  const excess = -diff;
2593
2712
  console.info({