Skip to content

fix(sandbox): bound runtime text output, registry cycles and the backend log - #232

Merged
ttbombadil merged 1 commit into
mainfrom
fix/runtime-output-flood
Oct 9, 2026
Merged

ttbombadil merged 1 commit into
mainfrom
fix/runtime-output-flood

Conversation

@ttbombadil

Copy link
Copy Markdown
Collaborator

Root cause (verified)

Sketch code fully controls the simulation's stdout and stderr. Before this change:

  • every plain runtime text line (fputs("x\n", stderr), printf) passed through the parser, was logged twice as WARN ([STDERR] diagnostic received, [Client WS][ERR] diagnostic received) and sent as its own WebSocket message. Only the 100 MB output budget bounded it.
  • every [[IO_REGISTRY_START]]…[[IO_REGISTRY_END]] cycle with changed content sent, INFO-logged and wrote (io-registry-*.json) a registry. Each [[IO_PIN]] outside a collection logged a WARN.
  • Compose used Docker's json-file default without rotation, so the host log grew without bound.

Proof on the old code: the new real-Docker test (tests/integration/runtime-output-storm.test.ts) forwarded 1,240,000 lines in 2 s, one onError and two log calls each. The new unit tests were red with 10,000 WARNs for 10,000 diagnostics, 1,000 registry sends for 1,000 spoofed cycles and 1,000 WARNs for stray pin records.

Fix

  • RuntimeTextLimiter, one per run in ExecutionState:
    • token bucket with a burst of 200 lines, then 50 lines/s;
    • excess is dropped deterministically and counted;
    • the client gets one notice per run (Runtime output rate limit exceeded …);
    • the log reports drops only aggregated (first drop, then at most every 10 s, with counts).
  • Shared delegate: Docker and local runs now use the same stream delegate (delegateParsedLineToStreamHandler), so both get the budget. This removes the duplicated stream-state builder in SandboxRunner.
  • No per-line logging: neither runtime diagnostics (WS onError is now debug) nor ignored pin records are logged per line.
  • Registry bound: RegistryManager processes at most MAX_REGISTRY_COLLECTIONS_PER_RUN = 4 registry cycles per run (the generated main() emits exactly one). Further cycles are ignored and logged once. reset() starts a fresh budget. Runtime pinMode sends were already bounded by the existing fingerprint dedup.
  • Compose log rotation: logging: json-file with max-size ${UNOSIM_LOG_MAX_SIZE:-10m} and max-file ${UNOSIM_LOG_MAX_FILE:-5}.
  • Docs: SECURITY.md, INSTALL_SERVER.md.

Serial output (SERIAL_EVENT) is unchanged; it was already baud-limited by the batcher.

Tests

  • tests/integration/runtime-output-storm.test.ts (integration-docker): a real sandbox floods stderr for 2 s. The test expects fewer than 1,000 forwarded lines, exactly one notice, no per-line STDERR log and fewer than 200 log calls in total.
  • tests/server/services/runtime-output-bounds.test.ts:
    • deterministic limiter (burst, sustained rate, single notice, aggregated logging);
    • 100,000 lines through the real parser, StreamHandler and shared delegate: 201 callbacks and fewer than 5 log calls;
    • registry cycle bound, stray pin records, fresh budget after reset.
  • tests/server/routes/simulation-runtime-diagnostics.test.ts: 10,000 diagnostics through the real WS route produce no per-line log.
  • tests/server/compose-log-rotation.test.ts: Compose rotates the backend log. docker compose config was also validated.

Existing tests are unchanged. npm run check, npm run test:unit, npm run check:docs, Knip, git diff --check, the full ./run-tests.sh (incl. toolchain, Docker, E2E, build, audit, Sonar) and the pre-push gate are green.

🤖 Generated with Claude Code

…end log

Sketch code controls the simulation's stdout and stderr. Every plain text
line was logged twice and sent as its own WebSocket message, and every
spoofed I/O-registry cycle sent, logged and persisted a registry, so one
sketch could saturate the event loop and fill the host log.

- RuntimeTextLimiter per run: burst 200, 50 lines/s, excess dropped and
  counted; the client is told once, drops are logged aggregated.
- Docker and local runs share one stream delegate and thus the budget.
- No log entry per runtime diagnostic line or ignored pin record.
- At most 4 I/O-registry cycles per run (the generated main() emits one).
- Compose rotates the backend's json-file log (5 x 10 MB by default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ttbombadil
ttbombadil merged commit 297bb78 into main Oct 9, 2026
6 checks passed
@ttbombadil
ttbombadil deleted the fix/runtime-output-flood branch October 9, 2026 10:01
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