Skip to content

fix(container-runtime): send the real error text as the JSON-RPC message - #3613

Open
JesseMarkowitz wants to merge 1 commit into
Start9Labs:masterfrom
JesseMarkowitz:fix/container-runtime-rpc-error-message
Open

fix(container-runtime): send the real error text as the JSON-RPC message#3613
JesseMarkowitz wants to merge 1 commit into
Start9Labs:masterfrom
JesseMarkowitz:fix/container-runtime-rpc-error-message

Conversation

@JesseMarkowitz

@JesseMarkowitz JesseMarkowitz commented Jul 31, 2026

Copy link
Copy Markdown

Both error paths in RpcListener set the JSON-RPC error message to typeof error:

// handleRpc, ~line 137
error: {
  code: 0,
  message: typeof error,                                    // → "object"
  data: { details: '' + error, debug: error?.stack },
}

// mapError, ~line 184
error: {
  message: typeof error,                                    // → "object"
  data: { details: error?.message ?? String(error), debug: error?.stack },
}

typeof error is the literal string "object" for every Error ever thrown. So the field a caller reads first — the JSON-RPC spec's "short description of the error" — carries no information on any container-runtime failure, and the real text is reachable only by digging into data.details. Note that mapError already computes the right expression one line below, for details.

Why this is worth fixing beyond tidiness

It is a meaningful part of why a failing service is opaque to diagnose.

When a package's main rejects, SystemForStartOs.start has no catch, so the rejection travels out through handleRpc to the host, and the service actor retries every SYNC_RETRY_COOLDOWN_SECONDS. What the operator sees is a service restarting on a fixed timer, and the one field that should say why says object.

I hit this on a real package. A service restart-looped every 10s for hours; the service log showed only the package's own pre-throw output, the OS log had nothing I could match, and statusInfo.error was null. Every one of that package's actions worked fine — only main failed, and only main was silent. Diagnosing it in the end meant reading the package source and reasoning about which throw could fire. The cause was a one-line guard in the package.

The change

message now carries error.message when present, String(error) otherwise. data.details and data.debug are untouched, so anything already reading those is unaffected. I checked that nothing on the Rust side keys off the previous value.

On the changelog and version

Deliberately not included, and I'd like guidance. CHANGELOG.md's top heading is ## [0.4.0.1], and start-os/v0.4.0.1 is a cut tag — so per the root AGENTS.md rule this entry belongs under a new heading, which pairs with a manifest bump. For StartOS that means root package.json, the Cargo.toml label, Cargo.lock, and a new version/v0_4_0_2.rs migration module. Creating an OS version module defines the upgrade path, and that seemed like a release decision to leave to maintainers rather than presume in a one-line fix.

Happy to add it in whatever form you prefer. Suggested text:

  • A container-runtime RPC failure now reports the actual error. Both error paths sent typeof error as the JSON-RPC message, which is the string "object" for every thrown Error, so the field a caller reads first carried no information and the real text was only in data.details. This is part of why a package whose main rejects presents as a service restarting on a timer with no stated reason.

Related

There is a second, larger half to this that I have not touched, because the fix shape is a design decision: service_actor.rs already carries // TODO: ideally this error should be sent to service logs on the line that swallows this, and log_err() routes to the OS log rather than the service log — which is where an operator actually looks. Filed separately as #3614 rather than guessed at here.

Verification

  • make container-runtime-test — 2 suites, 12 tests, 6 snapshots, all pass
  • npm --prefix projects/start-os/container-runtime run check — clean
  • prettier --check on the touched file — clean

🤖 Generated with Claude Code

https://claude.ai/code/session_01TJE2aaFnjtxseNg2pxPUTx

Both error paths in RpcListener set `message: typeof error`, which is the
literal string "object" for every Error ever thrown. The JSON-RPC `message`
field — the short description a caller reads first — therefore carried no
information on any container-runtime failure, and the actual text was reachable
only by digging into `data.details`.

This is what makes a failing service opaque. When a package's `main` rejects,
`SystemForStartOs.start` has no catch, so the rejection travels through
`handleRpc` to the host, which retries every SYNC_RETRY_COOLDOWN_SECONDS. The
operator sees a service restarting on a timer, and the one field carrying a
reason says "object". Diagnosing it means reading the package source and
inferring which throw fired.

`message` now carries `error.message` when present and `String(error)`
otherwise; `data.details` and `data.debug` are untouched, so nothing that reads
them changes. Nothing keys off the previous value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJE2aaFnjtxseNg2pxPUTx
@JesseMarkowitz

Copy link
Copy Markdown
Author

Issue found by Claude while I was working on the Vikunja service...

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