Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
There was a problem hiding this comment.
6 issues found across 11 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/event-loop-monitor.ts">
<violation number="1" location="src/api/event-loop-monitor.ts:136">
P2: When a measured maximum is just over an integer threshold, `toMs()` rounds it down to the threshold before this comparison, so no `event_loop_stall` is emitted. Preserve or compare the raw nanosecond maximum before resetting the histogram, and round only the logged payload.</violation>
</file>
<file name="src/api/tests/unit/readyz-event-loop.test.ts">
<violation number="1" location="src/api/tests/unit/readyz-event-loop.test.ts:41">
P3: Test 2 asserts `maxMs > 0` after only 80 ms of idle time at the default 20 ms histogram resolution, but idle event-loop delay readings (poll-phase sleeps) are environment-dependent and can round to 0 ms through `toMs` (`Math.round(ns / 1e6)`). The companion `event-loop-monitor.test.ts` never asserts a nonzero `max` without forcing one via `busyLoopMs()`, so this assertion can flake on quiet hosts. Force a recorded reading by blocking synchronously before the request, matching that pattern.</violation>
</file>
<file name="src/api/tests/unit/files.body-limit.test.ts">
<violation number="1" location="src/api/tests/unit/files.body-limit.test.ts:272">
P3: The overflow tests for /writeFiles don't assert the incoming stream is cancelled, unlike the PATCH suite in this same file ("cancels the incoming stream when the body overflows", which checks `canceled === true`). The middleware aborts the source upload a single reader can't drain, and that cleanup is the point of the #168 fix for chunked/lying bodies, so lock it in for the bulk route: pass `chunked(BULK_BODY_LIMIT * 2, () => { canceled = true; })` and assert `expect(canceled).toBe(true)` alongside the 413.</violation>
</file>
<file name="src/api/tests/unit/event-loop-monitor.test.ts">
<violation number="1" location="src/api/tests/unit/event-loop-monitor.test.ts:174">
P3: This window is wall-clock sensitive: with ~109 readings (500ms warmup at 5ms resolution), p99 lands on the second-highest sample, so the test fails if any single non-injected reading ≥ 50ms occurs during the ~740ms run (a GC pause or scheduler kick on loaded CI). The semantic you want — that p99 dilutes the lone stall — is more robustly expressed relative to p999 (which is already asserted ≥ 100), rather than against an absolute 50ms floor that depends on every idle reading staying tiny.</violation>
</file>
<file name="src/api/lib/env.ts">
<violation number="1" location="src/api/lib/env.ts:63">
P1: When `MAX_BULK_WRITE_BYTES` is unset, `POST /v1/sandboxes` initial-files still enforces a 128 MiB total: `src/api/routes/sandboxes.ts:28` reads the same env var with the old `128 * 1024 * 1024` fallback. The changeset and the README/CLAUDE.md rows this PR adds claim the default is now `MAX_FILE_WRITE_BYTES` and that the two caps "cannot drift", but the create route keeps the exact 2.5x door #168 documents — no per-file cap and no body-limit middleware on that path either. Point `sandboxes.ts` at the env.ts-derived constants (as this route does) so both consumers share one default.</violation>
<violation number="2" location="src/api/lib/env.ts:75">
P2: The 2x wire cap is not just a parse backstop: it can reject legal batches below the decoded cap. The middleware counts raw stream bytes, and `JSON.stringify` expands control characters (U+0000–U+001F other than \b\t\n\f\r) to `\uXXXX` — 6 wire bytes per 1 decoded byte — while JSON keys/braces consume wire bytes that never enter the decoded total. A control-char-heavy batch at roughly one third of `MAX_BULK_WRITE_BYTES` (e.g. ~17 MiB decoded of a 50 MiB cap) exceeds the 100 MiB wire cap and gets `413 Bulk write body exceeds limit (...)` even though its decoded total is legal. This contradicts the documented 'backstop, not a second content cap' design and will mislead callers who sized by decoded bytes.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| * {@link MAX_FILE_WRITE_BYTES} makes the batch cost no more synchronous work than the widest | ||
| * single write the service already accepts, and ties the two together so they cannot drift. | ||
| */ | ||
| export const MAX_BULK_WRITE_BYTES = positiveIntEnv(process.env.MAX_BULK_WRITE_BYTES, MAX_FILE_WRITE_BYTES); |
There was a problem hiding this comment.
P1: When MAX_BULK_WRITE_BYTES is unset, POST /v1/sandboxes initial-files still enforces a 128 MiB total: src/api/routes/sandboxes.ts:28 reads the same env var with the old 128 * 1024 * 1024 fallback. The changeset and the README/CLAUDE.md rows this PR adds claim the default is now MAX_FILE_WRITE_BYTES and that the two caps "cannot drift", but the create route keeps the exact 2.5x door #168 documents — no per-file cap and no body-limit middleware on that path either. Point sandboxes.ts at the env.ts-derived constants (as this route does) so both consumers share one default.
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 63:
<comment>When `MAX_BULK_WRITE_BYTES` is unset, `POST /v1/sandboxes` initial-files still enforces a 128 MiB total: `src/api/routes/sandboxes.ts:28` reads the same env var with the old `128 * 1024 * 1024` fallback. The changeset and the README/CLAUDE.md rows this PR adds claim the default is now `MAX_FILE_WRITE_BYTES` and that the two caps "cannot drift", but the create route keeps the exact 2.5x door #168 documents — no per-file cap and no body-limit middleware on that path either. Point `sandboxes.ts` at the env.ts-derived constants (as this route does) so both consumers share one default.</comment>
<file context>
@@ -50,3 +50,26 @@ if (MAX_FILE_WRITE_BYTES > DEFAULT_CONTENT_CACHE_MAX_BYTES) {
+ * {@link MAX_FILE_WRITE_BYTES} makes the batch cost no more synchronous work than the widest
+ * single write the service already accepts, and ties the two together so they cannot drift.
+ */
+export const MAX_BULK_WRITE_BYTES = positiveIntEnv(process.env.MAX_BULK_WRITE_BYTES, MAX_FILE_WRITE_BYTES);
+
+/**
</file context>
| log(JSON.stringify({ event: "event_loop_lag", ...snapshot, windowMs: sampleIntervalMs })); | ||
| // A lone stall is invisible in a windowed percentile line nobody alerts on, so it gets its | ||
| // own severity-tagged event — the same shape `heartbeat_gap` uses (#168). | ||
| if (snapshot.maxMs > stallThresholdMs) { |
There was a problem hiding this comment.
P2: When a measured maximum is just over an integer threshold, toMs() rounds it down to the threshold before this comparison, so no event_loop_stall is emitted. Preserve or compare the raw nanosecond maximum before resetting the histogram, and round only the logged payload.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api/event-loop-monitor.ts, line 136:
<comment>When a measured maximum is just over an integer threshold, `toMs()` rounds it down to the threshold before this comparison, so no `event_loop_stall` is emitted. Preserve or compare the raw nanosecond maximum before resetting the histogram, and round only the logged payload.</comment>
<file context>
@@ -95,6 +131,20 @@ export function startEventLoopMonitor(opts: EventLoopMonitorOptions = {}): void
log(JSON.stringify({ event: "event_loop_lag", ...snapshot, windowMs: sampleIntervalMs }));
+ // A lone stall is invisible in a windowed percentile line nobody alerts on, so it gets its
+ // own severity-tagged event — the same shape `heartbeat_gap` uses (#168).
+ if (snapshot.maxMs > stallThresholdMs) {
+ logStall(
+ JSON.stringify({
</file context>
| * is a backstop on the parse, not a second content cap — the decoded totals are what the route | ||
| * actually enforces. | ||
| */ | ||
| export const MAX_BULK_WRITE_BODY_BYTES = MAX_BULK_WRITE_BYTES * 2; |
There was a problem hiding this comment.
P2: The 2x wire cap is not just a parse backstop: it can reject legal batches below the decoded cap. The middleware counts raw stream bytes, and JSON.stringify expands control characters (U+0000–U+001F other than \b\t\n\f\r) to \uXXXX — 6 wire bytes per 1 decoded byte — while JSON keys/braces consume wire bytes that never enter the decoded total. A control-char-heavy batch at roughly one third of MAX_BULK_WRITE_BYTES (e.g. ~17 MiB decoded of a 50 MiB cap) exceeds the 100 MiB wire cap and gets 413 Bulk write body exceeds limit (...) even though its decoded total is legal. This contradicts the documented 'backstop, not a second content cap' design and will mislead callers who sized by decoded bytes.
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 75:
<comment>The 2x wire cap is not just a parse backstop: it can reject legal batches below the decoded cap. The middleware counts raw stream bytes, and `JSON.stringify` expands control characters (U+0000–U+001F other than \b\t\n\f\r) to `\uXXXX` — 6 wire bytes per 1 decoded byte — while JSON keys/braces consume wire bytes that never enter the decoded total. A control-char-heavy batch at roughly one third of `MAX_BULK_WRITE_BYTES` (e.g. ~17 MiB decoded of a 50 MiB cap) exceeds the 100 MiB wire cap and gets `413 Bulk write body exceeds limit (...)` even though its decoded total is legal. This contradicts the documented 'backstop, not a second content cap' design and will mislead callers who sized by decoded bytes.</comment>
<file context>
@@ -50,3 +50,26 @@ if (MAX_FILE_WRITE_BYTES > DEFAULT_CONTENT_CACHE_MAX_BYTES) {
+ * is a backstop on the parse, not a second content cap — the decoded totals are what the route
+ * actually enforces.
+ */
+export const MAX_BULK_WRITE_BODY_BYTES = MAX_BULK_WRITE_BYTES * 2;
</file context>
| expect(body.eventLoop.maxMs).toBeGreaterThan(0); | ||
| expect(eventLoopLagSnapshot()?.maxMs).toBeGreaterThanOrEqual(body.eventLoop.maxMs); | ||
| }); | ||
|
|
||
| // Negative guard: the field is absent — not null, not zeroed — when nothing is measuring, so a |
There was a problem hiding this comment.
P3: Test 2 asserts maxMs > 0 after only 80 ms of idle time at the default 20 ms histogram resolution, but idle event-loop delay readings (poll-phase sleeps) are environment-dependent and can round to 0 ms through toMs (Math.round(ns / 1e6)). The companion event-loop-monitor.test.ts never asserts a nonzero max without forcing one via busyLoopMs(), so this assertion can flake on quiet hosts. Force a recorded reading by blocking synchronously before the request, matching that pattern.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api/tests/unit/readyz-event-loop.test.ts, line 41:
<comment>Test 2 asserts `maxMs > 0` after only 80 ms of idle time at the default 20 ms histogram resolution, but idle event-loop delay readings (poll-phase sleeps) are environment-dependent and can round to 0 ms through `toMs` (`Math.round(ns / 1e6)`). The companion `event-loop-monitor.test.ts` never asserts a nonzero `max` without forcing one via `busyLoopMs()`, so this assertion can flake on quiet hosts. Force a recorded reading by blocking synchronously before the request, matching that pattern.</comment>
<file context>
@@ -0,0 +1,53 @@
+
+ const body = (await (await app.request("/readyz")).json()) as { eventLoop: { maxMs: number } };
+
+ expect(body.eventLoop.maxMs).toBeGreaterThan(0);
+ expect(eventLoopLagSnapshot()?.maxMs).toBeGreaterThanOrEqual(body.eventLoop.maxMs);
+ });
</file context>
| expect(body.eventLoop.maxMs).toBeGreaterThan(0); | |
| expect(eventLoopLagSnapshot()?.maxMs).toBeGreaterThanOrEqual(body.eventLoop.maxMs); | |
| }); | |
| // Negative guard: the field is absent — not null, not zeroed — when nothing is measuring, so a | |
| await new Promise((res) => setTimeout(res, 40)); | |
| // Block synchronously so the histogram records a guaranteed-nonzero reading; an idle loop's | |
| // poll sleeps can round to 0 ms at the default 20 ms resolution. | |
| const end = Date.now() + 40; | |
| while (Date.now() < end) {} | |
| await new Promise((res) => setTimeout(res, 40)); | |
| const body = (await (await app.request("/readyz")).json()) as { eventLoop: { maxMs: number } }; | |
| expect(body.eventLoop.maxMs).toBeGreaterThan(0); |
| }); | ||
| }); | ||
|
|
||
| it("rejects an oversized body streamed without a Content-Length", async () => { |
There was a problem hiding this comment.
P3: The overflow tests for /writeFiles don't assert the incoming stream is cancelled, unlike the PATCH suite in this same file ("cancels the incoming stream when the body overflows", which checks canceled === true). The middleware aborts the source upload a single reader can't drain, and that cleanup is the point of the #168 fix for chunked/lying bodies, so lock it in for the bulk route: pass chunked(BULK_BODY_LIMIT * 2, () => { canceled = true; }) and assert expect(canceled).toBe(true) alongside the 413.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api/tests/unit/files.body-limit.test.ts, line 272:
<comment>The overflow tests for /writeFiles don't assert the incoming stream is cancelled, unlike the PATCH suite in this same file ("cancels the incoming stream when the body overflows", which checks `canceled === true`). The middleware aborts the source upload a single reader can't drain, and that cleanup is the point of the #168 fix for chunked/lying bodies, so lock it in for the bulk route: pass `chunked(BULK_BODY_LIMIT * 2, () => { canceled = true; })` and assert `expect(canceled).toBe(true)` alongside the 413.</comment>
<file context>
@@ -230,3 +230,103 @@ describe("PUT raw file body limit", () => {
+ });
+ });
+
+ it("rejects an oversized body streamed without a Content-Length", async () => {
+ const app = await makeApp();
+
</file context>
| await new Promise((res) => setTimeout(res, 40)); | ||
| const snap = eventLoopLagSnapshot(); | ||
| expect(snap).toBeDefined(); | ||
| expect(snap?.p99Ms).toBeLessThan(50); |
There was a problem hiding this comment.
P3: This window is wall-clock sensitive: with ~109 readings (500ms warmup at 5ms resolution), p99 lands on the second-highest sample, so the test fails if any single non-injected reading ≥ 50ms occurs during the ~740ms run (a GC pause or scheduler kick on loaded CI). The semantic you want — that p99 dilutes the lone stall — is more robustly expressed relative to p999 (which is already asserted ≥ 100), rather than against an absolute 50ms floor that depends on every idle reading staying tiny.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api/tests/unit/event-loop-monitor.test.ts, line 174:
<comment>This window is wall-clock sensitive: with ~109 readings (500ms warmup at 5ms resolution), p99 lands on the second-highest sample, so the test fails if any single non-injected reading ≥ 50ms occurs during the ~740ms run (a GC pause or scheduler kick on loaded CI). The semantic you want — that p99 dilutes the lone stall — is more robustly expressed relative to p999 (which is already asserted ≥ 100), rather than against an absolute 50ms floor that depends on every idle reading staying tiny.</comment>
<file context>
@@ -135,12 +152,84 @@ describe("event-loop monitor lifecycle", () => {
+ await new Promise((res) => setTimeout(res, 40));
+ const snap = eventLoopLagSnapshot();
+ expect(snap).toBeDefined();
+ expect(snap?.p99Ms).toBeLessThan(50);
+ expect(snap?.p999Ms).toBeGreaterThanOrEqual(100);
+ } finally {
</file context>
| expect(snap?.p99Ms).toBeLessThan(50); | |
| expect(snap?.p99Ms).toBeLessThan(snap?.p999Ms as number); |
e8f59fb to
d41dfcb
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d41dfcb615
ℹ️ 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".
| * is a backstop on the parse, not a second content cap — the decoded totals are what the route | ||
| * actually enforces. | ||
| */ | ||
| export const MAX_BULK_WRITE_BODY_BYTES = MAX_BULK_WRITE_BYTES * 2; |
There was a problem hiding this comment.
Size the wire cap for worst-case JSON escaping
When file content contains JSON control characters, the wire representation can be much larger than twice the decoded UTF-8 size: for example, each one-byte NUL becomes the six-byte sequence \u0000. With a 1,024-byte decoded cap, a valid 400-byte file of NULs produces a 2,418-byte request and is rejected by the new 2,048-byte middleware limit before the decoded-content checks run; even newline- or quote-only content at exactly the decoded cap exceeds 2× once the JSON envelope is included. The wire ceiling therefore needs to accommodate worst-case escaping and structural overhead, or otherwise avoid rejecting batches that satisfy MAX_BULK_WRITE_BYTES.
Useful? React with 👍 / 👎.
d41dfcb to
6ee43a5
Compare
6ee43a5 to
777e046
Compare
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>
M10: POST /v1/sandboxes re-derived MAX_INITIAL_FILES / MAX_INITIAL_FILE_BYTES from MAX_BULK_WRITE_FILES / MAX_BULK_WRITE_BYTES but with its own 128 MiB default, so with the knobs unset /writeFiles capped a batch at 50 MiB while create still accepted 128 MiB of identical synchronous work. It also used a bare Number(), so a non-numeric override became NaN and removed the cap, and it enforced no per-entry limit at all. All three limits now come from lib/env.ts (MAX_BULK_WRITE_FILES moved there and gained positiveIntEnv), and the route enforces the per-file cap per entry. That is what makes CLAUDE.md's "any write surface" true; README, CLAUDE.md and the changeset now say the caps cover both batch surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
777e046 to
84f200b
Compare
…194) * US-169: survive postgres.js throwing from its own socket-write path 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> * US-169: teach the driver-fault fake dialect getSandboxEpoch 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> * US-169: fail a condemned script scope closed, and race the boot migrations M3: endScriptScope never checked #scriptTxLost. After a driver fault every later fs op throws via #assertScriptTxAlive, but just-bash swallows those into a nonzero exit rather than rejecting, so the exec path still reached endScriptScope and it COMMITTED the part of the script that had landed and reported success — the opposite of the invariant the #scriptTxLost doc states. It now delegates to the existing abort path (reject endPromise -> ROLLBACK -> reload) and rethrows the fault. M4: writeFile/appendFile awaited commitBlob — a root-`sql` statement — before #withBareTx reached the liveness assert, so a write in a condemned scope still put a statement on the wire; it was also unraced, so a fault during the blob write never settled. Assert first, route it through #db, and do the same for loadAllPaths and #refreshKnownEpoch. The sticky test now asserts the commitBlob call count too, which is what its comment already claimed. M5: runMigrations was unraced behind the new crash guard, so a driver fault at boot hung forever with no listen instead of exiting 1. Extracted runStartupMigrations() and raced it. Also fixed the script-tx-lost fake, whose `void fn({})` left the abort path's callback rejection unhandled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-167: record what a physically split data-plane Redis actually buys (#211) Replaces the "not verified: a real two-Redis deployment" line with the measurement. Control plane and data plane on separate instances, pausing each in turn: a data-plane stall costs 0 of 12,584 requests, a control-plane stall costs 94.8% of 26,643. The ~54% on the single-instance harness is a property of sharing one Redis, not a ceiling on the split. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-180: make vitest excludes path-independent (#210) 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> * US-181: share production's error handler with the test apps (#190) 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> * US-168: tighten bulk-write caps and make event-loop stalls visible (#201) * US-168: tighten bulk-write caps and make event-loop stalls visible 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> * US-168: put the create route's initial files under the real bulk caps M10: POST /v1/sandboxes re-derived MAX_INITIAL_FILES / MAX_INITIAL_FILE_BYTES from MAX_BULK_WRITE_FILES / MAX_BULK_WRITE_BYTES but with its own 128 MiB default, so with the knobs unset /writeFiles capped a batch at 50 MiB while create still accepted 128 MiB of identical synchronous work. It also used a bare Number(), so a non-numeric override became NaN and removed the cap, and it enforced no per-entry limit at all. All three limits now come from lib/env.ts (MAX_BULK_WRITE_FILES moved there and gained positiveIntEnv), and the route enforces the per-file cap per entry. That is what makes CLAUDE.md's "any write surface" true; README, CLAUDE.md and the changeset now say the caps cover both batch surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-169: hold the boot race on a referenced grace timer so startup fails loudly --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…writes (#195) * US-187: recover from a poisoned Redis version key instead of wedging 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> * US-167: record what a physically split data-plane Redis actually buys (#211) Replaces the "not verified: a real two-Redis deployment" line with the measurement. Control plane and data plane on separate instances, pausing each in turn: a data-plane stall costs 0 of 12,584 requests, a control-plane stall costs 94.8% of 26,643. The ~54% on the single-instance harness is a property of sharing one Redis, not a ceiling on the split. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-180: make vitest excludes path-independent (#210) 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> * US-181: share production's error handler with the test apps (#190) 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> * US-168: tighten bulk-write caps and make event-loop stalls visible (#201) * US-168: tighten bulk-write caps and make event-loop stalls visible 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> * US-168: put the create route's initial files under the real bulk caps M10: POST /v1/sandboxes re-derived MAX_INITIAL_FILES / MAX_INITIAL_FILE_BYTES from MAX_BULK_WRITE_FILES / MAX_BULK_WRITE_BYTES but with its own 128 MiB default, so with the knobs unset /writeFiles capped a batch at 50 MiB while create still accepted 128 MiB of identical synchronous work. It also used a bare Number(), so a non-numeric override became NaN and removed the cap, and it enforced no per-entry limit at all. All three limits now come from lib/env.ts (MAX_BULK_WRITE_FILES moved there and gained positiveIntEnv), and the route enforces the per-file cap per entry. That is what makes CLAUDE.md's "any write surface" true; README, CLAUDE.md and the changeset now say the caps cover both batch surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-169: survive postgres.js throwing from its own socket-write path (#194) * US-169: survive postgres.js throwing from its own socket-write path 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> * US-169: teach the driver-fault fake dialect getSandboxEpoch 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> * US-169: fail a condemned script scope closed, and race the boot migrations M3: endScriptScope never checked #scriptTxLost. After a driver fault every later fs op throws via #assertScriptTxAlive, but just-bash swallows those into a nonzero exit rather than rejecting, so the exec path still reached endScriptScope and it COMMITTED the part of the script that had landed and reported success — the opposite of the invariant the #scriptTxLost doc states. It now delegates to the existing abort path (reject endPromise -> ROLLBACK -> reload) and rethrows the fault. M4: writeFile/appendFile awaited commitBlob — a root-`sql` statement — before #withBareTx reached the liveness assert, so a write in a condemned scope still put a statement on the wire; it was also unraced, so a fault during the blob write never settled. Assert first, route it through #db, and do the same for loadAllPaths and #refreshKnownEpoch. The sticky test now asserts the commitBlob call count too, which is what its comment already claimed. M5: runMigrations was unraced behind the new crash guard, so a driver fault at boot hung forever with no listen instead of exiting 1. Extracted runStartupMigrations() and raced it. Also fixed the script-tx-lost fake, whose `void fn({})` left the abort path's callback rejection unhandled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-167: record what a physically split data-plane Redis actually buys (#211) Replaces the "not verified: a real two-Redis deployment" line with the measurement. Control plane and data plane on separate instances, pausing each in turn: a data-plane stall costs 0 of 12,584 requests, a control-plane stall costs 94.8% of 26,643. The ~54% on the single-instance harness is a property of sharing one Redis, not a ceiling on the split. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-180: make vitest excludes path-independent (#210) 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> * US-181: share production's error handler with the test apps (#190) 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> * US-168: tighten bulk-write caps and make event-loop stalls visible (#201) * US-168: tighten bulk-write caps and make event-loop stalls visible 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> * US-168: put the create route's initial files under the real bulk caps M10: POST /v1/sandboxes re-derived MAX_INITIAL_FILES / MAX_INITIAL_FILE_BYTES from MAX_BULK_WRITE_FILES / MAX_BULK_WRITE_BYTES but with its own 128 MiB default, so with the knobs unset /writeFiles capped a batch at 50 MiB while create still accepted 128 MiB of identical synchronous work. It also used a bare Number(), so a non-numeric override became NaN and removed the cap, and it enforced no per-entry limit at all. All three limits now come from lib/env.ts (MAX_BULK_WRITE_FILES moved there and gained positiveIntEnv), and the route enforces the per-file cap per entry. That is what makes CLAUDE.md's "any write surface" true; README, CLAUDE.md and the changeset now say the caps cover both batch surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-169: hold the boot race on a referenced grace timer so startup fails loudly --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-187: make poisoned version-key repair atomic via Lua Replace the split GET-then-SET with one EVAL that only swaps a still-poisoned key, never a concurrently healed counter or tombstone. The repair loser reloads from Postgres then INCRs past the winner. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…196) * US-188: require allkeys-lru on the blob-cache Redis and warn at boot 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> * US-188: narrow the safe eviction policies and stop paging control-only Redis M9: `policy.startsWith("allkeys-")` blessed allkeys-random, which samples uniformly and can reap a live exec-lock lease as readily as a cold blob — the very hazard the changeset used to reject volatile-* for. That argument never distinguished the accepted policies from the rejected ones. Safe is now allkeys-lru / allkeys-lfu only, on the argument that does hold (recency and frequency make a lease renewed every 20s and a hot version counter effectively immune), and volatile-* is rejected on its real defect: it evicts only TTL-bearing keys, so an instance that fills with keys that carry none degrades to noeviction. The unsafe log line now names the reason for the policy found. Changeset, CLAUDE.md and README reconciled. M8: the boot check fired unconditionally against the data client. With REDIS_BLOB_CACHE_ENABLED=false and no path snapshot, REDIS_DATA_URL's fallback makes that the CONTROL instance, so a correct control-only deployment got a critical page whose remediation would make its leases and tombstones evictable. Gated on blobCacheEnabled || pathSnapshotEnabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-187: recover from a poisoned Redis version key instead of wedging writes (#195) * US-187: recover from a poisoned Redis version key instead of wedging 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> * US-167: record what a physically split data-plane Redis actually buys (#211) Replaces the "not verified: a real two-Redis deployment" line with the measurement. Control plane and data plane on separate instances, pausing each in turn: a data-plane stall costs 0 of 12,584 requests, a control-plane stall costs 94.8% of 26,643. The ~54% on the single-instance harness is a property of sharing one Redis, not a ceiling on the split. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-180: make vitest excludes path-independent (#210) 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> * US-181: share production's error handler with the test apps (#190) 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> * US-168: tighten bulk-write caps and make event-loop stalls visible (#201) * US-168: tighten bulk-write caps and make event-loop stalls visible 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> * US-168: put the create route's initial files under the real bulk caps M10: POST /v1/sandboxes re-derived MAX_INITIAL_FILES / MAX_INITIAL_FILE_BYTES from MAX_BULK_WRITE_FILES / MAX_BULK_WRITE_BYTES but with its own 128 MiB default, so with the knobs unset /writeFiles capped a batch at 50 MiB while create still accepted 128 MiB of identical synchronous work. It also used a bare Number(), so a non-numeric override became NaN and removed the cap, and it enforced no per-entry limit at all. All three limits now come from lib/env.ts (MAX_BULK_WRITE_FILES moved there and gained positiveIntEnv), and the route enforces the per-file cap per entry. That is what makes CLAUDE.md's "any write surface" true; README, CLAUDE.md and the changeset now say the caps cover both batch surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-169: survive postgres.js throwing from its own socket-write path (#194) * US-169: survive postgres.js throwing from its own socket-write path 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> * US-169: teach the driver-fault fake dialect getSandboxEpoch 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> * US-169: fail a condemned script scope closed, and race the boot migrations M3: endScriptScope never checked #scriptTxLost. After a driver fault every later fs op throws via #assertScriptTxAlive, but just-bash swallows those into a nonzero exit rather than rejecting, so the exec path still reached endScriptScope and it COMMITTED the part of the script that had landed and reported success — the opposite of the invariant the #scriptTxLost doc states. It now delegates to the existing abort path (reject endPromise -> ROLLBACK -> reload) and rethrows the fault. M4: writeFile/appendFile awaited commitBlob — a root-`sql` statement — before #withBareTx reached the liveness assert, so a write in a condemned scope still put a statement on the wire; it was also unraced, so a fault during the blob write never settled. Assert first, route it through #db, and do the same for loadAllPaths and #refreshKnownEpoch. The sticky test now asserts the commitBlob call count too, which is what its comment already claimed. M5: runMigrations was unraced behind the new crash guard, so a driver fault at boot hung forever with no listen instead of exiting 1. Extracted runStartupMigrations() and raced it. Also fixed the script-tx-lost fake, whose `void fn({})` left the abort path's callback rejection unhandled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-167: record what a physically split data-plane Redis actually buys (#211) Replaces the "not verified: a real two-Redis deployment" line with the measurement. Control plane and data plane on separate instances, pausing each in turn: a data-plane stall costs 0 of 12,584 requests, a control-plane stall costs 94.8% of 26,643. The ~54% on the single-instance harness is a property of sharing one Redis, not a ceiling on the split. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-180: make vitest excludes path-independent (#210) 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> * US-181: share production's error handler with the test apps (#190) 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> * US-168: tighten bulk-write caps and make event-loop stalls visible (#201) * US-168: tighten bulk-write caps and make event-loop stalls visible 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> * US-168: put the create route's initial files under the real bulk caps M10: POST /v1/sandboxes re-derived MAX_INITIAL_FILES / MAX_INITIAL_FILE_BYTES from MAX_BULK_WRITE_FILES / MAX_BULK_WRITE_BYTES but with its own 128 MiB default, so with the knobs unset /writeFiles capped a batch at 50 MiB while create still accepted 128 MiB of identical synchronous work. It also used a bare Number(), so a non-numeric override became NaN and removed the cap, and it enforced no per-entry limit at all. All three limits now come from lib/env.ts (MAX_BULK_WRITE_FILES moved there and gained positiveIntEnv), and the route enforces the per-file cap per entry. That is what makes CLAUDE.md's "any write surface" true; README, CLAUDE.md and the changeset now say the caps cover both batch surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-169: hold the boot race on a referenced grace timer so startup fails loudly --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * US-187: make poisoned version-key repair atomic via Lua Replace the split GET-then-SET with one EVAL that only swaps a still-poisoned key, never a concurrently healed counter or tombstone. The repair loser reloads from Postgres then INCRs past the winner. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Stacks on #190 (
fix/181-test-scaffolding-error-shape) — base is that branch, notmain.Addresses the concrete, decision-free defects #168 documents alongside its headline. It does not fix the headline and #168 stays open (no closing keyword).
What this does
1.
MAX_BULK_WRITE_BYTESno longer defaults 2.5x wider than the per-file cap — breaking.It defaulted to 128 MiB against a
MAX_FILE_WRITE_BYTESof 50 MiB, so the bulk route accepted 2.5x what the single-file route did; the issue measured a 120 MiB batch blocking the loop for 669 ms. It now defaults toMAX_FILE_WRITE_BYTESand is derived from it inlib/env.ts, so the two cannot drift: a batch costs no more synchronous work than the widest single write the service already accepts. A caller currently sending a bulk total between 50 MiB and 128 MiB now gets413 PAYLOAD_TOO_LARGEwhere it previously got204. SetMAX_BULK_WRITE_BYTESexplicitly to keep the old ceiling. (The per-entry cap was already closed inaa60a5a; this is the total.)2.
POST /writeFilesgets thewriteBodyLimitmiddleware it never had.Every other write route counts the body off the stream; this one did not, so an over-declared or chunked body was JSON-parsed into memory before anything checked it, bounded only by the 256 MiB global backstop.
writeBodyLimitis now parameterised by limit and mounted on the route. The wire cap is twiceMAX_BULK_WRITE_BYTES, not equal to it: the content caps are on decoded bytes while JSON string escaping roughly doubles the wire size of newline-/quote-heavy text, so an equal cap would reject legal batches. It is a backstop on the parse, not a second content cap.3.
eventLoopLagSnapshot()gets a caller.It had zero non-test callers — not a route, not
/readyz, not a metrics endpoint — so nothing could poll how close a replica ran to the 2 s RediscommandTimeout.GET /readyznow carries it as aneventLoopobject. The field is absent (not null, not zeroed) when the monitor was never started, so an idle loop and an unmeasured one cannot be confused. Reading does not reset the histogram. Folded in: exposing it made a type lie visible — an empty histogram reportsmeanasNaN, whichJSON.stringifyrenders asnullwhere the type promises a number.4. A lone stall now moves something other than
maxMs.Adds
p999Msto the snapshot/log line and anevent_loop_stallline atseverity:"critical"when a window'smaxMscrossesEVENT_LOOP_STALL_THRESHOLD_MS(default 2000 — the Redis client'scommandTimeout, i.e. the point where a stall starts failing other tenants' in-flight commands). It reuses the existingheartbeat_gapseverity shape; no metrics system was invented.DEFAULT_RESOLUTION_MSstays at 20 ms deliberately — the resolution is not what hid the stalls. A 10 s window holds ~400–500 readings, so one stall is ~0.2% of the sample and lands above p99 by construction. Lowering the resolution makes that strictly worse (more readings, a smaller share each) while doubling libuv wakeups; raising it to 100 ms would let a stall reach p99 only by discarding every reading below 100 ms. Idle cost is unchanged either way: 50 timer wakeups/s, each a clock read plus one HDR record — order 1e-5 of a core.What this does NOT do
The headline of #168 — sandbox
exechas no file-size ceiling, sosedover 16 MiB blocks the loop for seconds with 62.8% of the CPU in GC — is untouched. Neither of its real cures (a size ceiling insideSqlFs, or movingbash.execto a worker thread) is here; that is a structural decision and is out of scope for this PR.heartbeat_gapstill thresholds onREDIS_EXEC_LOCK_RENEW_MS(20 s), so it still cannot fire below a 20 s stall.Measurement — the monitor now surfaces a stall it previously hid
sed 's/a/b/g'over a 16 MiB file on the FAULT replica, 4686 ms wall. The windowed log line for that 10 s window:p50/p99sit flat at the 21 ms idle floor exactly as the issue describes;p999Msmoves and the stall line fires. Before this change the same window logged{"p50Ms":21,"p99Ms":21,"maxMs":21,"meanMs":21}-shaped lines with no stall event and no p99.9.Cap verified live too: a 3x20 MiB bulk write (each entry under the per-file cap) →
413 {"code":"PAYLOAD_TOO_LARGE","details":["Bulk write exceeds total byte limit (52428800)"]}; a small batch still204.Verification
pnpm typecheck/pnpm lint:fixclean.pnpm test:unit: 1300 passed / 4 skipped (baseline 1288/4; +12 new). Nothing pre-existing broke.p999Ms, the stall line, theNaNguard and the/readyzfield were each reverted separately.node scripts/loadtest/scenarios/concurrency.mjs— all PASS, exit 0.Not verified
p99.9resolves a single outlier only while a window holds fewer than ~500 readings (HDR percentile rounding). At the default 20 ms resolution a 10 s window holds ~400–500, so it works there — butmaxMsandevent_loop_stallare the signals that always do, and the docs say so.Refs #168.
Summary by cubic
Tightens bulk-write caps so a batch costs no more synchronous work than the widest single write, and makes event-loop stalls visible at the point they start timing out Redis commands. This addresses the concrete bulk-write and observability defects in #168; the issue’s headline
execfile-size ceiling stays open.Bulk write caps
MAX_BULK_WRITE_BYTESnow derives fromMAX_FILE_WRITE_BYTES(50 MiB) instead of defaulting to 128 MiB; onPOST /writeFiles, totals between 50 and 128 MiB now get413 PAYLOAD_TOO_LARGEinstead of204. SetMAX_BULK_WRITE_BYTESexplicitly to keep the old ceiling.filesmap onPOST /v1/sandboxesnow uses the same per-file, total, and count caps asPOST /writeFiles, so creates with 50–128 MiB of initial files also get413; non-numeric env values no longer disable the caps.POST /writeFilesnow enforces a streaming body cap at twice the decoded cap, since JSON string escaping inflates wire size.Event-loop observability
GET /readyzreturns the lag histogram aseventLoop; the field is absent when the monitor never started, and reading it doesn’t reset the histogram.maxMscrossesEVENT_LOOP_STALL_THRESHOLD_MS(default 2000, the RediscommandTimeout) emitsevent_loop_stallatcritical, and snapshots now includep999Msbecause a lone stall stays hidden inp99.meanMsinstead ofnull.The #168 headline — a file-size ceiling on sandbox
exec— remains out of scope and the issue stays open.Written for commit 84f200b. Summary will update on new commits.
Reopened from #193: its base branch
fix/131-epoch-fencewas dropped when #161 merged to main. Rebased onto the new main; base is nowfix/181-test-scaffolding-error-shape.