Skip to content

fix(modal): print the error that ended the worker instead of discarding it - #309

Closed
aviggiano wants to merge 1 commit into
mainfrom
fix/worker-records-its-failure
Closed

aviggiano wants to merge 1 commit into
mainfrom
fix/worker-records-its-failure

Conversation

@aviggiano

@aviggiano aviggiano commented Aug 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

What was there

packages/modal/src/worker.ts:619:

).catch(() => {
  console.error("worker terminated");
  process.exitCode = 1;
});

The rejection value was never bound. Combined with diagnosticCodeForError recognising only two error classes, any other failure produced the string worker terminated, exit code 1, and no cause anywhere.

What that cost

Six sandboxes have now ended at stateful-invariant-setup across R45, R46 and R47. R47 attempt 1 finally recorded worker_exit_code: 1 — available only because #302/#303 fixed the same class of gap one layer out — and exit 1 traces to exactly this handler. The worker exited deliberately, knew why, and discarded the reason.

In the meantime I pursued and published two wrong causal theories: a V8 string ceiling in patch capture (refuted by a reviewer running the code) and a git add cgroup OOM (refuted by /usr/bin/time -v showing peak RSS is per-file — 620 MB across four blobs peaks at 155 MB, against a 32 GiB request). One logged error string would have pre-empted both.

What this deliberately does NOT do

My first attempt also persisted a redacted, bounded failure_message into the worker result contract. The suite refused it, by name:

× writes and flushes an unexpected worker failure without serializing its detail
    expect(serialized).not.toContain("placeholder-private-detail")

That invariant is deliberate and correct: status.json and result.json are in MODAL_COLLECT_RESULT_FILES and there is a public results path, so an arbitrary error string could put target source or configuration into a published artifact. redactSecretsInText strips known secret shapes, not arbitrary content, so it does not license crossing that boundary. Reverted; the invariant stands untouched.

stderr is the right destination here — inside the sandbox, outside the collected contract.

The durable channel is a separate decision

stderr is not durable; by the time anyone looks the sandbox is gone. The obvious fix is the volume's worker.log, except that is also in MODAL_COLLECT_RESULT_FILES, so it inherits the same publication question. #307 records three options (a private uncollected file, a strictly-filtered contract field, or extending diagnosticCodeForError to categorise more error classes) and leaves the choice to whoever owns that boundary. It should not ride quietly on a logging fix.

Validation

packages/modal 311/311; typecheck, eslint, prettier clean; full monorepo suite running.

Greptile Summary

This PR improves Modal worker failure diagnostics without changing the persisted result contract.

  • Binds the worker’s top-level rejection reason.
  • Prints an error stack or message to sandbox stderr while retaining exit code 1.
  • Leaves collected worker result files and lifecycle logs unchanged.

Confidence Score: 5/5

The PR appears safe to merge with no actionable defects identified.

The change improves diagnostics only on the existing worker failure path, preserves exit code 1, and does not alter persisted or collected result data.

Important Files Changed

Filename Overview
packages/modal/src/worker.ts The top-level failure handler now reports the rejection reason to stderr while preserving existing termination behavior and artifact boundaries.

Reviews (1): Last reviewed commit: "fix(modal): print the error that ended t..." | Re-trigger Greptile

Context used:

…ng it

The top-level handler bound nothing:

    ).catch(() => {
      console.error("worker terminated");
      process.exitCode = 1;
    });

So every failure that is not one of the two classes `diagnosticCodeForError`
recognises produced the string "worker terminated", exit code 1, and no cause
anywhere -- not in the terminal contract, not on the volume, not in stderr.

That is how five sandboxes ended at `stateful-invariant-setup` across three Aave v4
runs. R47 attempt 1 finally recorded `worker_exit_code: 1`, captured only because of
#303, and exit 1 traces to exactly this handler: the worker exited deliberately,
knew why, and threw the reason away. Two separate memory theories were pursued and
disproven in the meantime -- a V8 string ceiling in patch capture, then a `git add`
cgroup OOM -- both of which one logged error string would have pre-empted.

Deliberately NOT persisted into the worker result contract. That was my first
attempt and the suite refused it: `writes and flushes an unexpected worker failure
without serializing its detail` pins that the contract must not carry failure text,
and the reason holds up -- `status.json` and `result.json` are collected, and there
is a public results path, so an arbitrary error string could put target source or
configuration into a published artifact. `redactSecretsInText` removes known secret
shapes, not arbitrary content, so it is not sufficient to cross that boundary.
Reverted, and the invariant stands.

Stderr is the right place for this: it is inside the sandbox, outside the collected
contract. A durable private channel would be better still -- but `worker.log` is
itself in `MODAL_COLLECT_RESULT_FILES`, so it is subject to the same publication
question and deserves its own change rather than a quiet extension here. Recorded on
#307.

packages/modal 311/311.

Refs #307

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aviggiano

Copy link
Copy Markdown
Collaborator Author

Reviewer 2 found my fourth false causal claim, and I verified it. Closing this PR.

The comment I wrote asserts this handler "used to discard [the reason] … which is how five sandboxes ended at one node." That is wrong, and the code is three functions away in the same file:

packages/modal/src/worker.ts:509-513

const streams = [child.stdout, child.stderr].map(async (stream) => {
  for await (const _chunk of stream) {
    // Drain child output without persisting provider responses or benchmark contents.
  }
});

:527-530

if (exitCode !== 0) {
  await appendGenericLog("operation-failed");
  throw new OperationalDispositionError(failureCategory(options));   // no cause
}

The Smithers workflow — where captureWorkspacePatch → runGit → ENOBUFS actually throws — runs as a child process, and its stderr is drained into a no-op loop deliberately, then a fixed-message error is thrown with no cause. So after this PR the output for the exact six deaths under investigation would be:

worker terminated OperationalDispositionError: worker operation failed

No ENOBUFS, no git, no workspace-handoff, no echidna/. The change does not do what its comment says it does. The reason was discarded two layers earlier.

That is my fourth refuted causal claim in this sequence — after a V8 string ceiling, a git add OOM, and ruling out ENOBUFS because it would be "loud". Merging a comment asserting a fifth would repeat exactly the failure mode of #305.

What actually needs doing, per review

Reviewer 2's suggestion is right and is a different change from this one:

  1. In runChecked, retain a bounded tail (say 8 KB) of the child's stderr instead of discarding it, and pass it as cause. The drain is deliberate — "without persisting provider responses or benchmark contents" — so it must be routed through sanitizePublicDiagnosticMessage (public-worker.ts:964-980), which is the existing precedent in this package.
  2. Only then change the print to console.error("worker terminated", error) so Node renders [cause] chains.

Note the order matters, and reviewer 1 independently found why: execFileSync's ENOBUFS SystemError carries output, stdout and stderr holding up to 32 MB of workspace diff. Printing the object before redaction exists would dump the workspace patch to stderr. And reviewer 2 found the mirror hazard: public-worker.ts:928-933 already embeds 4000 bytes of raw child stderr as a cause, which .stack currently hides — so fixing the formatting without fixing the redaction surfaces an existing leak.

Two reviewers, two different halves of the same trap. That is worth more than this PR was.

Also relevant

#311 now catches the ENOBUFS at source and names the offending paths, which addresses the diagnosis gap for this specific class without touching the worker's privacy boundary at all. Both reviewers said that change was worth more than this one, and I agree.

Leaving #307 open with the stderr-threading design recorded. Closing this rather than merging a comment I cannot stand behind.

@aviggiano aviggiano closed this Aug 6, 2026
aviggiano added a commit that referenced this pull request Aug 7, 2026
* fix(modal): give the worker's terminal failure a reason to print

The worker's top-level catch discarded the rejection value, so every failure
outside the two classes `diagnosticCodeForError` recognises produced the literal
string "worker terminated" and exit code 1. Binding the error alone would not
have helped: `runChecked` drains child stderr into a no-op and throws
`OperationalDispositionError` with no `cause`, so the reason was already gone two
layers earlier.

Retain a bounded, redacted stderr tail in `runChecked` and attach it as `cause`,
then render the whole `cause` chain at the top-level catch. The chain is rendered
by reading only `name`, `code` and `message` at each link — never by inspecting
the error object, because an `execFileSync` ENOBUFS `SystemError` carries
`output`/`stdout`/`stderr` holding up to the whole captured workspace diff.

The mirror leak in `public-worker.ts` — 4000 raw stderr bytes embedded as a
`cause`, hidden today only because `.stack` does not render it — now goes through
the same sanitizer, which is `public-worker.ts`'s own redaction path lifted into
a shared module.

Refs #307. Supersedes #309.

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

* fix(modal): keep the failing frames the terminal reason is meant to carry

`describeWorkerTermination` reads only `name`, `code` and `message`, so the
class of failure #307 is actually chasing -- an unanticipated error in the
worker's own code -- prints "TypeError: Cannot read properties of undefined"
and names no file and no line. The stated reason for dropping the stack does
not hold: `Error.prototype.stack` is a plain string of `name: message` plus
frames and never carries an error's own enumerable properties, so an
`execFileSync` ENOBUFS `SystemError`'s `output`/`stdout`/`stderr` cannot reach
it. That is now measured by a test rather than assumed.

Add `workerTerminationStack`, which redacts and bounds those frames the same
way every other diagnostic string in this module is bounded, and print it from
the top-level catch. Also make a rejection with `undefined` or `null` name
itself instead of collapsing to the bare prefix the issue is about.

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

---------

Co-authored-by: Ubuntu <ubuntu@ip-192-168-2-198.ec2.internal>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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