Skip to content

fix(sql-fs): cap the file size a sandbox exec can read or produce - #200

Closed
Hazzng wants to merge 18 commits into
fix/192-fence-all-mutationsfrom
fix/168-exec-file-size-ceiling
Closed

Hazzng wants to merge 18 commits into
fix/192-fence-all-mutationsfrom
fix/168-exec-file-size-ceiling

Conversation

@Hazzng

@Hazzng Hazzng commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Closes #168. Stacks on PR #197.

Completes #168. Its other concrete parts — bulk-write caps, the missing writeBodyLimit on POST /writeFiles, eventLoopLagSnapshot() wired to /readyz, and p999Ms + event_loop_stall — landed in PR #193.

The decision

The repo owner chose ceiling now, worker thread later. The worker-thread migration is tracked as #198, scoped against the existing python3/js-exec worker-bridge plan (which addresses a different limit and does not help here).

What it does

MAX_EXEC_FILE_BYTES, default 8 MiB, enforced inside SqlFs and scoped to bash.exec by an AsyncLocalStorage context. Over-cap reads and writes are refused as EFBIG → 413.

limit
inside bash.exec MAX_EXEC_FILE_BYTES (8 MiB)
HTTP / MCP file routes MAX_FILE_WRITE_BYTES (50 MiB), unchanged

The asymmetry is deliberate: moving bytes over HTTP costs a buffer copy; rebuilding them through a shell pipeline costs seconds of GC. A file too large for a script to read is still retrievable whole via GET .../files/*path.

Why 8 MiB

sed s///g blocks the event loop 706 ms at 8 MiB and 2238 ms at 16 MiB; tr 5563 ms at 49 MiB. Past ~2 s it stops being a latency problem: commandTimeout: 2000 means the stall times out in-flight Redis commands belonging to other tenants (observed as rw_lock_writer_release_error against an unrelated sandbox). The sed threshold for that is ~14 MiB. 8 MiB sits a factor of two under it rather than at it, because per-byte cost varies by utility and the ceiling must hold for the worst.

The error message is part of the deliverable

The caller is an agent that has to recover without a human, so it states the limit is deliberate and retrying is pointless, gives both numbers, names the concrete smaller calls (head -c, split -b, sed -n '1,20000p'; PATCH .../files/*path for writes), and names the env var for an operator reading the same line in a log. A test asserts those parts are present so a refactor cannot degrade it to a bare code.

Performance

Enforcement is O(1) on the passing path — a store lookup and an integer compare before any blob is fetched. No extra database round trip, no allocation. An over-cap read never fetches the blob.

Verification

  • Gate: pnpm typecheck clean, pnpm lint:fix clean, pnpm test:unit 1408 passed / 4 skipped (base 1371 + 37 new), nothing pre-existing broken.
  • Tests fail without the fix — reverting sql-fs.ts fails 6+ of the 16 (rejects readFile of an over-cap file with EFBIG, does not fetch the blob for an over-cap read, rejects an append whose RESULT crosses the cap, counting the existing bytes); restored, 16/16.

Not verified

The implementing agent was interrupted before it reported, so the bystander-latency measurement it was asked for (probing a different sandbox and owner every 5 ms during a large sed) was not completed, and concurrency.mjs was not re-run on this branch by it. The orchestrator verified the gate and the revert independently. Benchmarking of the full stack against main is being done separately and will be reported on the stack.


Summary by cubic

Closes #168. Sandbox exec previously had no file-size ceiling; it now rejects reads and writes above 8 MiB (MAX_EXEC_FILE_BYTES) with EFBIG mapped to 413, preventing large shell transformations from blocking other tenants' Redis commands. Scripts that process larger files will fail, while HTTP and MCP file routes keep their existing limits.

Issue #168 safeguards

  • The cap uses cached path sizes before fetching blobs, so over-limit reads avoid extra database work and allocation.
  • /writeFiles now enforces streaming body and bulk-size limits, and /readyz exposes event-loop lag with a stall signal.
  • EFBIG explains why retrying will not help and suggests smaller commands such as head -c and split -b.
  • Moving bash.exec off the main thread remains the longer-term fix.

Stacked reliability fixes

  • Durable sandbox epochs fence stale writers, including writers racing with deletion or sandbox ID reuse.
  • Migrations now use a transaction-scoped advisory lock, and Redis control/data traffic uses separate connections with bounded cache backfills.
  • Exec disconnects abort running scripts, read routes use shared locks, and the default lock-acquire timeout is 75 seconds.
  • Client errors now redact driver codes and include a retryable durability flag; driver faults and poisoned Redis version keys recover without killing or wedging the replica.
  • Deployments should use Redis maxmemory-policy allkeys-lru; startup warns when the policy is unsafe.

Written for commit a465a65. Summary will update on new commits.

Review in cubic

* feat: add durable sandbox epoch migration

* feat: fence postgres sandbox epochs

* feat(sql-fs): fence script scopes with sandbox epoch

* test: add postgres fencing regressions

* fix: advance fencing epoch on composite writes

* fix(sql-fs): address PR #161 review comments for epoch fencing (F2-L2 #131)

- Accept tx-local advances with s.version > expected; extract epochParam helper
- Throw ENOENT on missing sandbox; fallback to tombstone epoch on delete
- Pin epoch inside locked tx (close TOCTOU); refresh tx-local epoch per composite
- Publish lastKnownEpoch only after COMMIT; throw ESTALE with 409 mapping
- Document sandbox_epochs RLS exemption; split fencing tests; harden integration setup

* fix(sql-fs): close fencing gaps from follow-up review (F2-L2 #131)

- Baseline lastKnownEpoch on ready/reload so scopes fence fresh cache state
- Take writer lock before fenced CTE snapshot in all composites
- Harden integration tests (race readiness, explicit ctx.skip on superuser)
- Tighten fencing unit assertions to the deleting statement

* chore: add changeset for epoch fencing (F2-L2 #131)

---------

Co-authored-by: trek-ai[bot] <302790941+trek-ai[bot]@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Sep 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: e32d0a68-6985-4bab-9773-8c67390d29df

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/api/lib/env.ts">

<violation number="1" location="src/api/lib/env.ts:95">
P2: When `MAX_EXEC_FILE_BYTES` is set to a positive fraction below 1, such as `0.5`, this call produces a zero-byte cap because `positiveIntEnv` floors after checking `n > 0`. `SqlFs` then rejects every nonempty script read and write; floor before validating positivity and fall back when the result is below 1.</violation>

<violation number="2" location="src/api/lib/env.ts:95">
P2: Raising `MAX_EXEC_FILE_BYTES` above the 8 MiB default silently reintroduces the #168 cross-tenant failure this cap exists to prevent: past ~2 s of synchronous string building, in-flight Redis commands of *other* tenants time out (commandTimeout: 2000). The docstring tells operators "Raise it only where the replica is not shared, or is not backed by Redis", but nothing enforces or even flags that — and the sibling constant in this same file, `MAX_FILE_WRITE_BYTES` (lines 88-96...), sets the precedent of emitting `console.warn` when an override steps past the safe default. Add the same warning so an override on a shared/Redis-backed replica is visible at boot instead of only in a docstring.</violation>
</file>

<file name="src/sql-fs/errors.ts">

<violation number="1" location="src/sql-fs/errors.ts:144">
P2: When the file exceeds the exec limit, the suggested shell commands cannot process it in slices because just-bash reads the whole file before those utilities can truncate or split it. Recommend only the HTTP GET route here, or provide a genuinely range-aware exec operation before advertising shell slicing.</violation>
</file>

<file name="src/sql-fs/sql-fs.ts">

<violation number="1" location="src/sql-fs/sql-fs.ts:1114">
P1: When an exec passes a large string to `writeFile` or `appendFile`, `TextEncoder.encode` allocates the entire over-limit byte buffer before this assertion. Reject an obviously oversized string before encoding, then retain the exact byte-length check to prevent the allocation and event-loop stall this cap is intended to avoid.</violation>
</file>

<file name="src/api/tests/unit/exec-file-limit.test.ts">

<violation number="1" location="src/api/tests/unit/exec-file-limit.test.ts:199">
P3: Hardcode the ceiling number instead of reusing the imported constant. The remediation-message assertion pins "8388608 bytes" as a string literal while every other assertion in this file derives from `MAX_EXEC_FILE_BYTES`; if the default in `src/api/lib/env.ts` changes, this assertion fails even though the code is correct, and under a `MAX_EXEC_FILE_BYTES` env override the big file is created at `MAX_EXEC_FILE_BYTES + 1` and trips the cap, but this assertion still demands the default number. Interpolate the constant so the wording check tracks the real limit.</violation>
</file>

<file name=".changeset/fix-168-exec-file-size-ceiling.md">

<violation number="1" location=".changeset/fix-168-exec-file-size-ceiling.md:11">
P3: The claim that a file too large for a script is "still retrievable whole with ... MCP `file_read`" is over-broad and will mislead the agent that the rest of the changeset is written for. `tools.ts` gates `file_read` at `MAX_MCP_READ_FILE_BYTES` (default 16 MiB) and returns `file_too_large` above it (`src/api/mcp/tools.ts:222,309`), so a script that hits EFBIG on a 20 MiB file cannot recover via MCP `file_read`. Only `GET .../files/*path` (no size gate in `src/api/routes/files.ts`) is unconditionally available. Qualify the sentence with the 16 MiB MCP read cap.</violation>
</file>

<file name="src/api/exec-context.ts">

<violation number="1" location="src/api/exec-context.ts:25">
P2: When a script both trips the cap and is later aborted by timeout, the recorded `exceeded` error wins over the abort outcome. `bash.exec` resolves with exit 124 on a plain timeout, so session-manager's `runExec` reaches the `ctx.exceeded !== undefined` branch (session-manager.ts:1793-1796) and throws EFBIG while rolling the script-tx back — even though the file-cap trip is not what stopped the work. A script that previously committed several writes then reads an over-cap file and hits the runtime budget loses all of it and is told the cap was the failure. This diverges from the documented plain-timeout-commits policy (audit L7), and the client cannot learn both facts. Consider recording the abort outcome as well (or checking `bash.exec`'s resolved exit/timeout before the `exceeded` marker) so the surfaced error reflects the terminal cause, or documenting this precedence in the `exceeded` doc block.</violation>
</file>

<file name="CLAUDE.md">

<violation number="1" location="CLAUDE.md:237">
P3: In the new `MAX_EXEC_FILE_BYTES` row, the phrase "and past the 2 s Redis `commandTimeout` that stall times out *other* tenants' in-flight commands" reads as if the stall's subject got elided — "that stall" has no clear antecedent in the sentence (the earlier clause names only `sed s///g`). README's parallel sentence is unambiguous: "a stall past the 2 s Redis `commandTimeout` times out **other** tenants' in-flight commands." Use the same subject-first phrasing here.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/sql-fs/sql-fs.ts
const { name, parentEntry } = this.#requireParentDir(path);

const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
this.#assertExecFileSize(path, bytes.byteLength, "write");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When an exec passes a large string to writeFile or appendFile, TextEncoder.encode allocates the entire over-limit byte buffer before this assertion. Reject an obviously oversized string before encoding, then retain the exact byte-length check to prevent the allocation and event-loop stall this cap is intended to avoid.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/sql-fs/sql-fs.ts, line 1114:

<comment>When an exec passes a large string to `writeFile` or `appendFile`, `TextEncoder.encode` allocates the entire over-limit byte buffer before this assertion. Reject an obviously oversized string before encoding, then retain the exact byte-length check to prevent the allocation and event-loop stall this cap is intended to avoid.</comment>

<file context>
@@ -1085,6 +1111,7 @@ export class SqlFs<Tx = unknown> implements ICoherentFs, IReadOnlyScopeFs {
 		const { name, parentEntry } = this.#requireParentDir(path);
 
 		const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
+		this.#assertExecFileSize(path, bytes.byteLength, "write");
 		const sha256 = new Uint8Array(createHash("sha256").update(bytes).digest());
 		const mtime = new Date();
</file context>

Comment thread src/api/lib/env.ts
* cap at all — held until `bash.exec` moves off the main thread. Raise it only where the replica
* is not shared, or is not backed by Redis.
*/
export const MAX_EXEC_FILE_BYTES = positiveIntEnv(process.env.MAX_EXEC_FILE_BYTES, 8 * 1024 * 1024);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When MAX_EXEC_FILE_BYTES is set to a positive fraction below 1, such as 0.5, this call produces a zero-byte cap because positiveIntEnv floors after checking n > 0. SqlFs then rejects every nonempty script read and write; floor before validating positivity and fall back when the result is below 1.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api/lib/env.ts, line 95:

<comment>When `MAX_EXEC_FILE_BYTES` is set to a positive fraction below 1, such as `0.5`, this call produces a zero-byte cap because `positiveIntEnv` floors after checking `n > 0`. `SqlFs` then rejects every nonempty script read and write; floor before validating positivity and fall back when the result is below 1.</comment>

<file context>
@@ -73,3 +73,23 @@ export const MAX_BULK_WRITE_BYTES = positiveIntEnv(process.env.MAX_BULK_WRITE_BY
+ * cap at all — held until `bash.exec` moves off the main thread. Raise it only where the replica
+ * is not shared, or is not backed by Redis.
+ */
+export const MAX_EXEC_FILE_BYTES = positiveIntEnv(process.env.MAX_EXEC_FILE_BYTES, 8 * 1024 * 1024);
</file context>

Comment thread src/sql-fs/errors.ts
op === "read" ? `'${path}' is ${attemptedBytes} bytes` : `writing '${path}' would produce ${attemptedBytes} bytes`;
const remedy =
op === "read"
? "process a slice at a time (`head -c`, `tail -c`, `split -b`, `sed -n '1,20000p'`), or pull the whole file out over HTTP with `GET .../files/{path}` (MCP `file_read`), which this limit does not apply to"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the file exceeds the exec limit, the suggested shell commands cannot process it in slices because just-bash reads the whole file before those utilities can truncate or split it. Recommend only the HTTP GET route here, or provide a genuinely range-aware exec operation before advertising shell slicing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/sql-fs/errors.ts, line 144:

<comment>When the file exceeds the exec limit, the suggested shell commands cannot process it in slices because just-bash reads the whole file before those utilities can truncate or split it. Recommend only the HTTP GET route here, or provide a genuinely range-aware exec operation before advertising shell slicing.</comment>

<file context>
@@ -116,6 +116,44 @@ export function createEdriverfault(cause: Error): Error {
+		op === "read" ? `'${path}' is ${attemptedBytes} bytes` : `writing '${path}' would produce ${attemptedBytes} bytes`;
+	const remedy =
+		op === "read"
+			? "process a slice at a time (`head -c`, `tail -c`, `split -b`, `sed -n '1,20000p'`), or pull the whole file out over HTTP with `GET .../files/{path}` (MCP `file_read`), which this limit does not apply to"
+			: "write several smaller files (`split -b`), send large content in over HTTP with `PUT .../files/{path}` (MCP `file_write`), or change part of a file with `PATCH .../files/{path}` (MCP `file_edit`) instead of rewriting it whole";
+	return makeFsError(
</file context>
Suggested change
? "process a slice at a time (`head -c`, `tail -c`, `split -b`, `sed -n '1,20000p'`), or pull the whole file out over HTTP with `GET .../files/{path}` (MCP `file_read`), which this limit does not apply to"
+\t\t\t? "pull the whole file out over HTTP with `GET .../files/{path}` (MCP `file_read`), which this limit does not apply to"

Comment thread src/api/exec-context.ts
/** Largest file this script may read whole or produce. See MAX_EXEC_FILE_BYTES. */
readonly maxFileBytes: number;
/**
* The first EFBIG this script tripped, recorded as well as thrown.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a script both trips the cap and is later aborted by timeout, the recorded exceeded error wins over the abort outcome. bash.exec resolves with exit 124 on a plain timeout, so session-manager's runExec reaches the ctx.exceeded !== undefined branch (session-manager.ts:1793-1796) and throws EFBIG while rolling the script-tx back — even though the file-cap trip is not what stopped the work. A script that previously committed several writes then reads an over-cap file and hits the runtime budget loses all of it and is told the cap was the failure. This diverges from the documented plain-timeout-commits policy (audit L7), and the client cannot learn both facts. Consider recording the abort outcome as well (or checking bash.exec's resolved exit/timeout before the exceeded marker) so the surfaced error reflects the terminal cause, or documenting this precedence in the exceeded doc block.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api/exec-context.ts, line 25:

<comment>When a script both trips the cap and is later aborted by timeout, the recorded `exceeded` error wins over the abort outcome. `bash.exec` resolves with exit 124 on a plain timeout, so session-manager's `runExec` reaches the `ctx.exceeded !== undefined` branch (session-manager.ts:1793-1796) and throws EFBIG while rolling the script-tx back — even though the file-cap trip is not what stopped the work. A script that previously committed several writes then reads an over-cap file and hits the runtime budget loses all of it and is told the cap was the failure. This diverges from the documented plain-timeout-commits policy (audit L7), and the client cannot learn both facts. Consider recording the abort outcome as well (or checking `bash.exec`'s resolved exit/timeout before the `exceeded` marker) so the surfaced error reflects the terminal cause, or documenting this precedence in the `exceeded` doc block.</comment>

<file context>
@@ -0,0 +1,43 @@
+	/** Largest file this script may read whole or produce. See MAX_EXEC_FILE_BYTES. */
+	readonly maxFileBytes: number;
+	/**
+	 * The first EFBIG this script tripped, recorded as well as thrown.
+	 *
+	 * Throwing alone is not enough on the read side: just-bash's text utilities catch every
</file context>

Comment thread src/api/lib/env.ts
* cap at all — held until `bash.exec` moves off the main thread. Raise it only where the replica
* is not shared, or is not backed by Redis.
*/
export const MAX_EXEC_FILE_BYTES = positiveIntEnv(process.env.MAX_EXEC_FILE_BYTES, 8 * 1024 * 1024);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Raising MAX_EXEC_FILE_BYTES above the 8 MiB default silently reintroduces the #168 cross-tenant failure this cap exists to prevent: past ~2 s of synchronous string building, in-flight Redis commands of other tenants time out (commandTimeout: 2000). The docstring tells operators "Raise it only where the replica is not shared, or is not backed by Redis", but nothing enforces or even flags that — and the sibling constant in this same file, MAX_FILE_WRITE_BYTES (lines 88-96...), sets the precedent of emitting console.warn when an override steps past the safe default. Add the same warning so an override on a shared/Redis-backed replica is visible at boot instead of only in a docstring.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api/lib/env.ts, line 95:

<comment>Raising `MAX_EXEC_FILE_BYTES` above the 8 MiB default silently reintroduces the #168 cross-tenant failure this cap exists to prevent: past ~2 s of synchronous string building, in-flight Redis commands of *other* tenants time out (commandTimeout: 2000). The docstring tells operators "Raise it only where the replica is not shared, or is not backed by Redis", but nothing enforces or even flags that — and the sibling constant in this same file, `MAX_FILE_WRITE_BYTES` (lines 88-96...), sets the precedent of emitting `console.warn` when an override steps past the safe default. Add the same warning so an override on a shared/Redis-backed replica is visible at boot instead of only in a docstring.</comment>

<file context>
@@ -73,3 +73,23 @@ export const MAX_BULK_WRITE_BYTES = positiveIntEnv(process.env.MAX_BULK_WRITE_BY
+ * cap at all — held until `bash.exec` moves off the main thread. Raise it only where the replica
+ * is not shared, or is not backed by Redis.
+ */
+export const MAX_EXEC_FILE_BYTES = positiveIntEnv(process.env.MAX_EXEC_FILE_BYTES, 8 * 1024 * 1024);
</file context>
Suggested change
export const MAX_EXEC_FILE_BYTES = positiveIntEnv(process.env.MAX_EXEC_FILE_BYTES, 8 * 1024 * 1024);
export const MAX_EXEC_FILE_BYTES = positiveIntEnv(process.env.MAX_EXEC_FILE_BYTES, 8 * 1024 * 1024);
// Same rationale as the MAX_FILE_WRITE_BYTES warning above: an override is sometimes legitimate
// (dedicated replica, no shared Redis), but must not be silent — past the 8 MiB default the #168
// stall crosses the 2 s Redis commandTimeout and breaks other tenants' in-flight commands, not
// just this request's latency.
if (MAX_EXEC_FILE_BYTES > 8 * 1024 * 1024) {
console.warn(
JSON.stringify({
event: "exec_cap_above_default",
maxExecFileBytes: MAX_EXEC_FILE_BYTES,
warning:
"with the exec ceiling raised, a large script read/write can stall the shared event loop past the 2 s Redis commandTimeout (cross-tenant, #168); only safe where the replica is not shared and is not backed by Redis",
}),
);
}

.then(() => undefined)
.catch((e: Error) => e);

expect(err?.message).toContain("the per-file exec limit is 8388608 bytes");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Hardcode the ceiling number instead of reusing the imported constant. The remediation-message assertion pins "8388608 bytes" as a string literal while every other assertion in this file derives from MAX_EXEC_FILE_BYTES; if the default in src/api/lib/env.ts changes, this assertion fails even though the code is correct, and under a MAX_EXEC_FILE_BYTES env override the big file is created at MAX_EXEC_FILE_BYTES + 1 and trips the cap, but this assertion still demands the default number. Interpolate the constant so the wording check tracks the real limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api/tests/unit/exec-file-limit.test.ts, line 199:

<comment>Hardcode the ceiling number instead of reusing the imported constant. The remediation-message assertion pins "8388608 bytes" as a string literal while every other assertion in this file derives from `MAX_EXEC_FILE_BYTES`; if the default in `src/api/lib/env.ts` changes, this assertion fails even though the code is correct, and under a `MAX_EXEC_FILE_BYTES` env override the big file is created at `MAX_EXEC_FILE_BYTES + 1` and trips the cap, but this assertion still demands the default number. Interpolate the constant so the wording check tracks the real limit.</comment>

<file context>
@@ -0,0 +1,225 @@
+			.then(() => undefined)
+			.catch((e: Error) => e);
+
+		expect(err?.message).toContain("the per-file exec limit is 8388608 bytes");
+		expect(err?.message).toContain("MAX_EXEC_FILE_BYTES");
+		expect(err?.message).not.toContain("No such file or directory");
</file context>
Suggested change
expect(err?.message).toContain("the per-file exec limit is 8388608 bytes");
expect(err?.message).toContain(`the per-file exec limit is ${MAX_EXEC_FILE_BYTES} bytes`);


Past roughly 2 s the stall stops being a latency problem and becomes a correctness problem for unrelated tenants: `src/redis/client.ts` sets `commandTimeout: 2000`, so a stall beyond it times out in-flight Redis commands belonging to other sandboxes — observed in the harness as `rw_lock_writer_release_error … Command timed out` against a sandbox that had no part in the exec. The `sed` threshold for that is ~14 MiB. 8 MiB is chosen to sit a factor of two under it rather than at it, because the per-byte cost varies by utility (`tr` is worse than `sed`, `wc` is better) and the ceiling has to hold for the worst one.

The ceiling applies **only inside `bash.exec`** — it is scoped by an `AsyncLocalStorage` context, so the HTTP and MCP file routes keep their own, wider caps (`MAX_FILE_WRITE_BYTES`, 50 MiB). A file too large for a script to read is still retrievable whole with `GET /v1/sandboxes/:id/files/*path` or MCP `file_read`. That asymmetry is the point: moving bytes over HTTP costs one buffer copy, rebuilding them through a shell pipeline costs seconds of GC.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The claim that a file too large for a script is "still retrievable whole with ... MCP file_read" is over-broad and will mislead the agent that the rest of the changeset is written for. tools.ts gates file_read at MAX_MCP_READ_FILE_BYTES (default 16 MiB) and returns file_too_large above it (src/api/mcp/tools.ts:222,309), so a script that hits EFBIG on a 20 MiB file cannot recover via MCP file_read. Only GET .../files/*path (no size gate in src/api/routes/files.ts) is unconditionally available. Qualify the sentence with the 16 MiB MCP read cap.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .changeset/fix-168-exec-file-size-ceiling.md, line 11:

<comment>The claim that a file too large for a script is "still retrievable whole with ... MCP `file_read`" is over-broad and will mislead the agent that the rest of the changeset is written for. `tools.ts` gates `file_read` at `MAX_MCP_READ_FILE_BYTES` (default 16 MiB) and returns `file_too_large` above it (`src/api/mcp/tools.ts:222,309`), so a script that hits EFBIG on a 20 MiB file cannot recover via MCP `file_read`. Only `GET .../files/*path` (no size gate in `src/api/routes/files.ts`) is unconditionally available. Qualify the sentence with the 16 MiB MCP read cap.</comment>

<file context>
@@ -0,0 +1,17 @@
+
+Past roughly 2 s the stall stops being a latency problem and becomes a correctness problem for unrelated tenants: `src/redis/client.ts` sets `commandTimeout: 2000`, so a stall beyond it times out in-flight Redis commands belonging to other sandboxes — observed in the harness as `rw_lock_writer_release_error … Command timed out` against a sandbox that had no part in the exec. The `sed` threshold for that is ~14 MiB. 8 MiB is chosen to sit a factor of two under it rather than at it, because the per-byte cost varies by utility (`tr` is worse than `sed`, `wc` is better) and the ceiling has to hold for the worst one.
+
+The ceiling applies **only inside `bash.exec`** — it is scoped by an `AsyncLocalStorage` context, so the HTTP and MCP file routes keep their own, wider caps (`MAX_FILE_WRITE_BYTES`, 50 MiB). A file too large for a script to read is still retrievable whole with `GET /v1/sandboxes/:id/files/*path` or MCP `file_read`. That asymmetry is the point: moving bytes over HTTP costs one buffer copy, rebuilding them through a shell pipeline costs seconds of GC.
+
+Enforcement is O(1) on the passing path — a store lookup and an integer compare before any blob is fetched, with no extra database round trip and no allocation. An over-cap read is refused without reading the blob at all.
</file context>
Suggested change
The ceiling applies **only inside `bash.exec`** — it is scoped by an `AsyncLocalStorage` context, so the HTTP and MCP file routes keep their own, wider caps (`MAX_FILE_WRITE_BYTES`, 50 MiB). A file too large for a script to read is still retrievable whole with `GET /v1/sandboxes/:id/files/*path` or MCP `file_read`. That asymmetry is the point: moving bytes over HTTP costs one buffer copy, rebuilding them through a shell pipeline costs seconds of GC.
The ceiling applies **only inside `bash.exec`** — it is scoped by an `AsyncLocalStorage` context, so the HTTP and MCP file routes keep their own caps (`MAX_FILE_WRITE_BYTES`, 50 MiB, for writes; `MAX_MCP_READ_FILE_BYTES`, 16 MiB, for MCP `file_read`; `GET .../files/*path` is uncapped). A file too large for a script to read is still retrievable whole with `GET /v1/sandboxes/:id/files/*path`, and with MCP `file_read` up to `MAX_MCP_READ_FILE_BYTES` (16 MiB). That asymmetry is the point: moving bytes over HTTP costs one buffer copy, rebuilding them through a shell pipeline costs seconds of GC.

Comment thread CLAUDE.md
| `REDIS_PATH_SNAPSHOT_TTL_MS` | No (default: 3600000) | TTL for path snapshot entries (ms, default 1h). |
| `MAX_FILE_WRITE_BYTES` | No (default: contentCache cap, 50 MiB) | Largest single file body any write surface accepts (PUT, PATCH, MCP `file_write`). Raising it past the contentCache cap costs ~4x the memory per file for the whole `SESSION_IDLE_MS`. |
| `MAX_BULK_WRITE_BYTES` | No (default: `MAX_FILE_WRITE_BYTES`) | Largest total decoded byte count one `POST /writeFiles` batch may carry. Defaults to the per-file cap so the bulk route is not a wider door than the single write it batches (#168 — it used to default to 128 MiB against a 50 MiB per-file cap). The request body is separately capped at twice this, counted off the stream before the JSON parse, to leave headroom for JSON string escaping. |
| `MAX_EXEC_FILE_BYTES` | No (default: 8 MiB) | Largest file a sandbox `exec` script may read whole, or produce with one write. Bash text utilities rebuild strings synchronously on the main thread — `sed s///g` blocks the event loop 706 ms at 8 MiB and 2238 ms at 16 MiB, and past the 2 s Redis `commandTimeout` that stall times out *other* tenants' in-flight commands. Trips as `EFBIG` → 413, never retryable. Applies only to calls made from inside `bash.exec`; the HTTP/MCP file routes keep their own caps, so a file above this can still be fetched with `GET .../files/{path}`. Stopgap until `bash.exec` moves off the main thread (#168). |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: In the new MAX_EXEC_FILE_BYTES row, the phrase "and past the 2 s Redis commandTimeout that stall times out other tenants' in-flight commands" reads as if the stall's subject got elided — "that stall" has no clear antecedent in the sentence (the earlier clause names only sed s///g). README's parallel sentence is unambiguous: "a stall past the 2 s Redis commandTimeout times out other tenants' in-flight commands." Use the same subject-first phrasing here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLAUDE.md, line 237:

<comment>In the new `MAX_EXEC_FILE_BYTES` row, the phrase "and past the 2 s Redis `commandTimeout` that stall times out *other* tenants' in-flight commands" reads as if the stall's subject got elided — "that stall" has no clear antecedent in the sentence (the earlier clause names only `sed s///g`). README's parallel sentence is unambiguous: "a stall past the 2 s Redis `commandTimeout` times out **other** tenants' in-flight commands." Use the same subject-first phrasing here.</comment>

<file context>
@@ -234,6 +234,7 @@ const TABLE = Object.assign(Object.create(null) as Record<string, string>, {
 | `REDIS_PATH_SNAPSHOT_TTL_MS` | No (default: 3600000) | TTL for path snapshot entries (ms, default 1h). |
 | `MAX_FILE_WRITE_BYTES` | No (default: contentCache cap, 50 MiB) | Largest single file body any write surface accepts (PUT, PATCH, MCP `file_write`). Raising it past the contentCache cap costs ~4x the memory per file for the whole `SESSION_IDLE_MS`. |
 | `MAX_BULK_WRITE_BYTES` | No (default: `MAX_FILE_WRITE_BYTES`) | Largest total decoded byte count one `POST /writeFiles` batch may carry. Defaults to the per-file cap so the bulk route is not a wider door than the single write it batches (#168 — it used to default to 128 MiB against a 50 MiB per-file cap). The request body is separately capped at twice this, counted off the stream before the JSON parse, to leave headroom for JSON string escaping. |
+| `MAX_EXEC_FILE_BYTES` | No (default: 8 MiB) | Largest file a sandbox `exec` script may read whole, or produce with one write. Bash text utilities rebuild strings synchronously on the main thread — `sed s///g` blocks the event loop 706 ms at 8 MiB and 2238 ms at 16 MiB, and past the 2 s Redis `commandTimeout` that stall times out *other* tenants' in-flight commands. Trips as `EFBIG` → 413, never retryable. Applies only to calls made from inside `bash.exec`; the HTTP/MCP file routes keep their own caps, so a file above this can still be fetched with `GET .../files/{path}`. Stopgap until `bash.exec` moves off the main thread (#168). |
 | `MAX_BULK_WRITE_FILES` | No (default: 1000) | Max number of entries in one `POST /writeFiles` batch. |
 | `EVENT_LOOP_MONITOR_INTERVAL_MS` | No (default: 10000) | Sampling interval (ms) for the F8 event-loop lag monitor. Each window logs `event:"event_loop_lag"` (`p50Ms`/`p99Ms`/`p999Ms`/`maxMs`/`meanMs`) then resets the histogram. Purely observational; pairs with per-heartbeat `event:"heartbeat_gap"` warn/critical events that flag a stall eating into a Redis lease (see DEVELOPER.md "Lock observability"). The live histogram is also on `GET /readyz` as `eventLoop`. |
</file context>
Suggested change
| `MAX_EXEC_FILE_BYTES` | No (default: 8 MiB) | Largest file a sandbox `exec` script may read whole, or produce with one write. Bash text utilities rebuild strings synchronously on the main thread — `sed s///g` blocks the event loop 706 ms at 8 MiB and 2238 ms at 16 MiB, and past the 2 s Redis `commandTimeout` that stall times out *other* tenants' in-flight commands. Trips as `EFBIG` → 413, never retryable. Applies only to calls made from inside `bash.exec`; the HTTP/MCP file routes keep their own caps, so a file above this can still be fetched with `GET .../files/{path}`. Stopgap until `bash.exec` moves off the main thread (#168). |
| `MAX_EXEC_FILE_BYTES` | No (default: 8 MiB) | Largest file a sandbox `exec` script may read whole, or produce with one write. Bash text utilities rebuild strings synchronously on the main thread — `sed s///g` blocks the event loop 706 ms at 8 MiB and 2238 ms at 16 MiB, and a stall past the 2 s Redis `commandTimeout` times out *other* tenants' in-flight commands. Trips as `EFBIG` → 413, never retryable. Applies only to calls made from inside `bash.exec`; the HTTP/MCP file routes keep their own caps, so a file above this can still be fetched with `GET .../files/{path}`. Stopgap until `bash.exec` moves off the main thread (#168). |

@Hazzng
Hazzng added this pull request to stack #199 September 19, 2026 01:01
@Hazzng

Hazzng commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Hazzng and others added 10 commits September 19, 2026 10:33
…y lock

Wrap the whole migration run in one transaction and take
pg_advisory_xact_lock as its first statement, replacing the session-scoped
pg_advisory_lock that a transaction pooler silently defeats. Measured on the
Neon pooler: a second booter acquired the old lock in 267-335ms while it was
held; it now waits 1855-1906ms for the holder to commit.

US-165: drop DATABASE_DIRECT_URL from the deployment, correct its doc row.
No server code ever read it; the runner no longer needs a direct connection.
drizzle-kit still reads it for pnpm db:generate, so the variable stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion-class SQLSTATEs to 503

`app.onError` and the exec SSE error frame allowlisted the error message but
emitted the raw `code`, leaking driver codes (ECONNRESET, CONNECTION_CLOSED)
and Postgres SQLSTATEs to clients. Add `clientSafeErrorCode` next to
`clientSafeErrorMessage` so the pair cannot be used asymmetrically, and map
connection-class SQLSTATEs (08xxx, 53300, 53400, 57P03) to a retryable 503
reported as EUNAVAILABLE.

Closes #174

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wire c.req.raw.signal into the /exec-sync abort controller, mirroring
/exec-sync-batch (already-aborted pre-check plus a { once: true }
listener), so an abandoned request stops holding the sandbox exec lock
for the rest of its timeout. Semantics stay abort-only — work the script
already committed stays committed, as on /exec, /exec-sync-batch and the
timeout path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ceeds the lease

Move the REDIS_EXEC_LOCK_* env parsing into loadExecLockOptions(), lower the
acquire-timeout default from 300s to lease + ~15s reap margin, and fail startup
when the acquire window is not strictly greater than both the writer lease and
the reader lease — a crashed holder is only reaped when its lease expires, so a
shorter window turns recovery into a 503.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both read handlers went through withOwnedSessionOrRehydrate, so two
concurrent GETs on one sandbox serialized as if each were a write. Switch
the two read call sites to withOwnedSessionRead — the same path MCP
file_read already uses for identical work.

This does NOT let a GET overtake an in-flight writer: shared still excludes
exclusive. It removes reader-reader serialization only (C=12 burst wall
time 131ms -> 6ms on the load-test harness).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Data plane (blob cache, path snapshot) moves to its own ioredis connection so
multi-MiB SETs can no longer head-of-line block the control plane's locks and
version counter. RedisBlobCache.set gains an in-flight count/byte cap and drops
rather than queues. The circuit breaker is keyed by role, and every transition
now logs redis_circuit_open / redis_circuit_closed with the role — the original
outage produced zero log lines.

Closes #167

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add a `retryable` discriminator to every error body, give the poisoned
(rolled-back) publish its own `ECOHERENCE_UNAPPLIED` code, correct the
ECOHERENCE message that told clients to retry an already-committed write,
and defer — rather than surface — a stranded INCR failure on a turn that
mutated nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Root-anchored "comparison/**" missed the copy inside an in-repo git
worktree, sweeping a duplicate src/ suite and deliberately-excluded
comparison fixtures into every pnpm test:unit run. Also exclude
.claude/** outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four test apps hand-rolled the leaky pre-#174 onError, so they no
longer matched production and could not fail on a code-leak
regression. Route them through one testErrorHandler built on
clientSafeErrorCode, and guard both the shape and the absence of
hand-rolled copies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Default MAX_BULK_WRITE_BYTES to MAX_FILE_WRITE_BYTES instead of 128 MiB,
add the streaming body cap POST /writeFiles never had, expose the lag
histogram on /readyz, and surface a lone stall via p99.9 and a new
event_loop_stall line thresholded at the Redis commandTimeout.

Refs #168 — the structural exec cap / worker-thread fix stays open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a93f5f970f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/sql-fs/sql-fs.ts
if (entry.kind === INODE_KIND.DIRECTORY) throw createEisdir(path);
// Before the cache lookup on purpose: a warm cache makes the DB round trip free
// but not the megabytes of string work the caller is about to do with the bytes.
this.#assertExecFileSize(path, entry.size, "read");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound aggregate shell data before processing

When a script pipes two legal 8 MiB files into sed, or expands one legal file into a much larger result, every read passes this per-entry check but just-bash then synchronously builds and processes a 16+ MiB string. For redirected output the new write check runs only after that allocation, while stdout has no write check at all. Since the commit's own measurements show 16 MiB of sed work exceeding Redis's 2-second timeout, the cross-tenant failure remains trivially reachable; enforce a byte budget at the pipeline or command-output boundary rather than only at individual filesystem calls.

Useful? React with 👍 / 👎.

Comment thread src/sql-fs/errors.ts
Comment on lines +143 to +145
op === "read"
? "process a slice at a time (`head -c`, `tail -c`, `split -b`, `sed -n '1,20000p'`), or pull the whole file out over HTTP with `GET .../files/{path}` (MCP `file_read`), which this limit does not apply to"
: "write several smaller files (`split -b`), send large content in over HTTP with `PUT .../files/{path}` (MCP `file_write`), or change part of a file with `PATCH .../files/{path}` (MCP `file_edit`) instead of rewriting it whole";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove unusable shell slicing remedies

For a file above the limit, head, tail, split, and sed still read through the same IFileSystem whole-file methods, and #readBytes rejects solely from the full inode size before fetching any content. Consequently every suggested in-sandbox slicing command trips the same EFBIG instead of recovering, leaving only the HTTP/MCP advice usable; either provide a ranged-read path or stop directing callers to commands that cannot pass this check.

Useful? React with 👍 / 👎.

Hazzng and others added 7 commits September 19, 2026 10:35
A backend reaped mid-transaction leaves the driver flushing a buffered write
to a nulled socket from a bare setImmediate, which is a fatal uncaught
exception that kills the replica and every other in-flight request on it.
Recognise exactly that stack frame, log it, and fail the DB awaits it stranded
with EDRIVERFAULT instead of letting them hang; everything else keeps Node's
default crash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main's epoch fence (#161) calls getSandboxEpoch on every script-scope
transaction, so a fake dialect without it throws before the test reaches
the fault path it is asserting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…writes

Distinguish a structurally invalid version key (WRONGTYPE, "ERR value is not
an integer") from a transport failure. A structurally invalid key is repaired
in place with an epoch-stamped SET so the write publishes normally; every
other INCR failure keeps the #186 deferral. The F7 destroy tombstone is exempt
so a repair cannot resurrect a destroyed sandbox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Document maxmemory-policy allkeys-lru as a deployment requirement alongside
REDIS_URL / REDIS_DATA_URL in CLAUDE.md and README.md, and check it at boot
with CONFIG GET maxmemory-policy. A non-allkeys policy logs
redis_eviction_policy_unsafe at critical severity. The check is never awaited,
never fails startup, and recognises a provider that forbids CONFIG GET as its
own outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sandbox `exec` had no file-size bound of any kind: just-bash's text
utilities rebuild strings synchronously on the main thread, so `sed
s///g` blocked the event loop 706 ms at 8 MiB and 2238 ms at 16 MiB
with 62.8% of the CPU in GC. Past the 2 s Redis `commandTimeout` that
stall times out in-flight commands belonging to other tenants, which
makes it a cross-tenant correctness bug rather than a latency blip.

Enforce an 8 MiB ceiling (`MAX_EXEC_FILE_BYTES`) inside SqlFs, scoped
through an AsyncLocalStorage set once per `bash.exec` so the HTTP and
MCP file routes keep their own, looser caps. The size comes from the
pathCache, so an over-cap call costs neither a round trip nor the
allocation it exists to prevent.

bash swallows an FS read rejection into a phantom "No such file or
directory", so the error is recorded on the exec context as well as
thrown and re-raised after the script returns — rolling the script-tx
back first, the same shape `readOnlyContext` already uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main's epoch fence (#161) calls getSandboxEpoch on every script-scope
transaction, so a fake dialect without it throws before the test reaches
the ceiling it is asserting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Hazzng
Hazzng force-pushed the fix/168-exec-file-size-ceiling branch from a93f5f9 to a465a65 Compare September 19, 2026 01:09
@Hazzng

Hazzng commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Superseded by a re-opened PR with the correct base. Its original base fix/192-fence-all-mutations is being reworked against main's fence, so this now stacks directly on #196. Same commits, rebased onto the new main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant