Skip to content

fix(web): Treat webdriver sidecar failures as non-fatal - #21

Open
MuncleUscles wants to merge 4 commits into
v0.6-devfrom
test/webdriver-5xx-fatal-repro
Open

fix(web): Treat webdriver sidecar failures as non-fatal#21
MuncleUscles wants to merge 4 commits into
v0.6-devfrom
test/webdriver-5xx-fatal-repro

Conversation

@MuncleUscles

@MuncleUscles MuncleUscles commented Aug 5, 2026

Copy link
Copy Markdown
Member

This PR is red by design and contains no fix. The new test fails on purpose to pin a real defect. Opened as a draft so it is not merged as-is — the intent is to agree the behaviour is wrong, then fix it (here or in a follow-up).

The defect

When our own WebDriver sidecar fails — as opposed to the remote website failing — the failure is classified as fatal, aborting the entire contract run as an internal error. The contract cannot catch it. An environmental blip on infrastructure we operate is treated as a deterministic property of the transaction.

Three sites combine:

  1. webdriver/src/prj/src/index.ts:247const page = await context.newPage(); sits outside the try that begins at :249. newPage() issues CDP Target.createTarget; puppeteer's default 180s protocolTimeout makes it throw ProtocolError: Target.createTarget timed out, which escapes to the outer handler and becomes HTTP 500.
  2. install/config/genvm-web-default.luaRender calls lib.rs.request with error_on_status = true and no pcall, unlike Request which pcalls and re-raises with fatal = false.
  3. implementation/src/scripting/mod.rs:345-348 — non-200 yields ModuleError { causes: [STATUS_NOT_OK], fatal: true }, which becomes a bare anyhow in the executor and surfaces as ResultCode::InternalError.

The non-fatal WEBPAGE_LOAD_FAILED branch further down in Render is unreachable for a 5xx, because the fatal raise happens first inside lib.rs.request. The trace logs make the divergence explicit — the control test reaches genvm-web-default.lua:36, the 5xx case dies at :19.

By contrast, a genuine page-load failure is handled correctly today: the sidecar returns 200 + Resulting-Status: 408/418/495/502/503, which is non-fatal and surfaces as a catchable NondetException. Only classes (a) transport failure to the sidecar and (b) 5xx from the sidecar are misclassified.

Why this matters

GenVM has a single fatal: bool axis, where fatal means only "don't show it to the contract". There is no third state meaning environmental — do not turn this into a consensus result at all. On the node's duty path, consensus/genvm.go converts any GenVM error into a bare Timeout vote, so a sidecar outage becomes a consensus-visible verdict on a transaction that never actually failed.

Context: this was found while investigating the Bradbury-phase1 freeze (genlayer-node #1693 / #1694). It was not the cause of that incident — sync replays nondeterministic blocks from recorded eqBlocksOutputs and never contacts the webdriver — but it is a real defect on the live execution path, and it is what made the incident look like a webdriver problem.

The tests

Two tests in implementation/src/web/mod.rs, deliberately paired so the red one is not vacuous:

Test Sidecar response Today
render_remote_page_load_failure_is_not_fatal 200 + Resulting-Status: 404 PASS — control; proves the non-fatal channel exists and is reachable
render_sidecar_internal_error_is_not_fatal bare 500, no Resulting-Status FAIL — the defect
running 2 tests
test web::tests::render_remote_page_load_failure_is_not_fatal ... ok
test web::tests::render_sidecar_internal_error_is_not_fatal ... FAILED

a 5xx from our own webdriver sidecar must be non-fatal, got
ModuleError { causes: ["STATUS_NOT_OK"], fatal: true, ..., "status": Number(500.0) }

The 500 body is byte-accurate to what the outer catch in index.ts actually emits ({"error":"Internal server error","message":"Target.createTarget timed out"}).

The test asserts only the externally meaningful property — a 5xx from our own sidecar must not be fatal — so a fix at either the lua site or the Rust site turns it green without the test encoding which one you pick.

Hermetic: a loopback TcpListener impersonates the sidecar and drives the real production Render lua through the real scripting::UserVM wiring. No Chromium, no egress past loopback.

Suggested fix directions (not applied)

  1. Wrap the lib.rs.request call in Render in pcall + re-raise with fatal = false, exactly as Request already does. Config-file change, no Rust rebuild.
  2. Better: make error_on_status produce fatal: false for 5xx specifically, since a 5xx from our own sidecar is never a contract-attributable observation.
  3. Move newPage() inside the try in the webdriver and map ProtocolError/TimeoutError to a Resulting-Status on an HTTP 200, so it joins the already-correct page-load path. Set an explicit protocolTimeout below the module's request deadline.
  4. Longer term: a third error state (environmental) that the node must not convert into a vote.

Not covered

  • Site 1 is only simulated. The mock emits exactly what the outer catch produces; the TypeScript is never executed, because webdriver/src/prj has no test runner (deps are puppeteer-core + commander, scripts are only start/dev/build). I did not scaffold one.
  • The downstream half of the chain (fatal:trueFatalError → bare anyhow → ResultCode::InternalError) — the test stops at the module boundary and asserts on ModuleError.fatal.
  • The sidecar's other non-200 exits (400 on bad params, 503 from the healthcheck path), which take the same fatal branch.
  • The JSON twin of the bug at implementation/src/scripting/mod.rs:448-451.

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling for web rendering and HTTP requests, with clearer non-fatal error details and preserved causes and context.
    • Service failures, invalid responses, connection issues, and fatal system errors are now classified more accurately.
    • Malformed JSON responses and HTTP status failures now provide more useful diagnostics.
  • Improvements

    • Added configurable browser communication timeouts and stronger resource cleanup during rendering.
    • Web rendering now reports unavailable services and page resource-limit failures with appropriate status information.

RED BY DESIGN — this commit adds no fix. The new test fails on purpose to
pin a real defect, so CI on this PR is expected to be red.

When our own WebDriver sidecar fails (as opposed to the remote website
failing), a 5xx is classified as a fatal error that aborts the whole
contract run instead of surfacing as a catchable nondeterministic
exception. Three sites combine:

1. webdriver/src/prj/src/index.ts:247 — context.newPage() sits outside the
   try that begins at :249. newPage() issues CDP Target.createTarget;
   puppeteer's default 180s protocolTimeout makes it throw ProtocolError,
   which escapes to the outer handler and becomes HTTP 500.
2. install/config/genvm-web-default.lua — Render calls lib.rs.request with
   error_on_status = true and no pcall, unlike Request which pcalls and
   re-raises with fatal = false.
3. implementation/src/scripting/mod.rs:345-348 — non-200 yields
   ModuleError{ fatal: true }, which becomes a bare anyhow in the executor
   and surfaces as ResultCode::InternalError.

The non-fatal WEBPAGE_LOAD_FAILED branch further down in Render is
unreachable for a 5xx, because the fatal raise happens first inside
lib.rs.request. Trace logs confirm it: the control test reaches
genvm-web-default.lua:36, the 5xx case dies at :19.

Two tests, deliberately paired so the red one is not vacuous:

- render_remote_page_load_failure_is_not_fatal — sidecar answers 200 +
  Resulting-Status: 404. PASSES today; proves the non-fatal channel exists
  and is reachable.
- render_sidecar_internal_error_is_not_fatal — sidecar answers a bare 500
  with no Resulting-Status, byte-accurate to the outer catch in index.ts.
  FAILS today.

The test asserts only the externally meaningful property (a 5xx from our
own sidecar must not be fatal), so a fix at either the lua or the Rust
site turns it green without the test encoding which one is chosen.

Hermetic: a loopback TcpListener impersonates the sidecar and drives the
real production Render lua through the real module wiring. No Chromium,
no egress.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

GenVM PR actions

Tick a box to run it (the box unticks itself when handled). Actions only run while the PR has the ci-safe label.

  • Force run full tests
  • Rerun full tests
  • Provision executor PRs
  • Merge into dev
Merge

Requires, on the exact head commit:

  • an approving review from a maintainer (any push revokes it), or the rtm label
  • linear history — 0 commits behind base
  • green full GenVM CI and green cross-repo E2E

Full CI runs only when run-full-tests or rtm is set; "Force run full tests" is a sticky toggle for the former.

Every repo lands ONE squashed commit, subject <PR title> (#N).

Commands
  • /genvm-force-mergerepo admin only: land without the review, full-CI and E2E gates, for when those signals are unobtainable, not when they are red. Base, title and 0-behind still apply, and the skip is recorded on the PR.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 65c6e6e3-fa3d-4c81-bb99-e1c315133a70

📥 Commits

Reviewing files that changed from the base of the PR and between 563f2b4 and a86ca27.

📒 Files selected for processing (9)
  • implementation/src/common/mod.rs
  • implementation/src/web/tests.rs
  • implementation/tests/request_status_fatality.rs
  • install/config/genvm-web-default.lua
  • install/lib/genvm-lua/lib-web.lua
  • webdriver/src/prj/src/browser/chrome.ts
  • webdriver/src/prj/src/index.ts
  • webdriver/src/prj/src/render.ts
  • webdriver/src/prj/test/render.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • webdriver/src/prj/src/index.ts
  • implementation/tests/request_status_fatality.rs
  • implementation/src/common/mod.rs
  • webdriver/src/prj/src/browser/chrome.ts

📝 Walkthrough

Walkthrough

The change extracts WebDriver rendering into a dedicated module, classifies WebDriver failures, centralizes wire-result conversion, and adds coverage for rendering, request statuses, cleanup, and error propagation.

Changes

Web error flow

Layer / File(s) Summary
Wire error classification
implementation/src/common/mod.rs
module_error_to_wire converts fatal, non-fatal, and unclassified errors. The message loop uses this helper.
Standalone WebDriver renderer
webdriver/src/prj/src/render.ts, webdriver/src/prj/src/index.ts, webdriver/src/prj/src/browser/chrome.ts, webdriver/src/prj/package.json, webdriver/Dockerfile
Rendering uses isolated browser resources, SSRF protection, navigation status mapping, heap monitoring, output extraction, cleanup, and configurable protocol timeouts. Build setup runs renderer tests.
Web failure propagation
install/config/genvm-web-default.lua, install/lib/genvm-lua/lib-web.lua, implementation/src/web/mod.rs, implementation/src/web/tests.rs
Protected WebDriver request failures become fatal module errors with preserved causes. Page observation failures remain non-fatal. Integration tests verify wire conversion.
Request and renderer validation
implementation/tests/request_status_fatality.rs, webdriver/src/prj/test/render.test.ts
Tests cover fatal HTTP statuses, ordinary status responses, JSON deserialization errors, navigation failures, cleanup, timeouts, and local network errors.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WebRequest
  participant LuaRender
  participant WebDriver
  participant VM
  participant WireResult
  WebRequest->>LuaRender: invoke Render
  LuaRender->>WebDriver: request page rendering
  WebDriver-->>LuaRender: rendered result or classified failure
  LuaRender-->>VM: return ModuleError
  VM->>WireResult: convert handler error
  WireResult-->>WebRequest: return UserError or FatalError
Loading

Possibly related PRs

Suggested labels: run-full-tests

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title states that WebDriver sidecar failures are non-fatal, but the final change preserves fatal handling for those failures. Update the title to state that WebDriver sidecar failures remain fatal and receive the WEBDRIVER_UNAVAILABLE label.
Docstring Coverage ⚠️ Warning Docstring coverage is 26.32% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/webdriver-5xx-fatal-repro

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.

@MuncleUscles

Copy link
Copy Markdown
Member Author

Following Kira's question in Slack — I think she's right, and it changes what this PR should assert rather than whether it should exist. Suggesting modify, not close: the harness is worth keeping and most of the analysis holds. Only the central assertion is inverted.

The assertion is backwards

fatal: false means the contract sees and can catch the error. So:

  • Host A, sidecar up → contract gets the page → result X
  • Host B, sidecar down → contract catches → fallback → result Y

Both produce valid, non-error results that disagree. The broken host has laundered its own infrastructure failure into a legitimate-looking consensus result — and a contract can do it deliberately: try: web.render(u) except: return <preferred value> makes node-local infra state a consensus input.

Fatal → InternalError → Timeout vote is the host saying "I could not execute this", which is true. Healthy hosts produce the real result. That is Kira's "node does not have a required capability = we vote timeout", and it is the correct behaviour.

Same reasoning kills suggestion 4: a third environmental state that isn't converted to a vote means the host declines to participate, which is an idleness strike by another name. It buys nothing over an honest timeout.

I'd also drop this framing from the body:

a sidecar outage becomes a consensus-visible verdict on a transaction that never actually failed

A Timeout vote isn't a verdict on the transaction. It's the host reporting it didn't complete — which is accurate.

What this PR found that is real

The misclassification, which survives untouched:

webdriver/src/prj/src/index.ts:247const page = await context.newPage(); sits outside the try that begins at :249.

That's the actual defect. And note why Target.createTarget times out: the browser is a process-wide singleton (browser/chrome.ts:245), so the pressure usually comes from another contract's page. One tenant's heavy render makes an unrelated contract's host vote timeout. That is worth fixing, and it is fix direction 3 — at the source, not at the Lua.

Fix directions 1 and 2 should come out. Both make the sidecar's own failures contract-visible, which is the thing above.

Concretely

Test Change
render_remote_page_load_failure_is_not_fatal keep as-is — correct control, green
render_sidecar_internal_error_is_not_fatal invertrender_sidecar_internal_error_is_fatal, green today

Inverting beats deleting. It turns the test into a regression guard that encodes this decision, and stops someone later implementing directions 1 or 2 — a realistic risk precisely because the current body argues for them well.

Then: retitle (no longer red by design), drop directions 1/2/4 from the body, promote direction 3 to the fix.

One blocker in your "Not covered" list has since cleared:

Site 1 is only simulated ... webdriver/src/prj has no test runner ... I did not scaffold one.

#23 adds node:test via the existing ts-node/esm loader, no new deps. So the TypeScript half is testable now — either sequence it after #23, or land the Rust half here first.

Related change in #23

#23 originally had this same bug and I've just fixed it there. It added two failure classes and routed both through Resulting-Status, but they aren't the same kind of thing:

Condition Determined by Channel
Response / heap over cap the page — same verdict on any host with the same config HTTP 200 + Resulting-Status: 507 → contract sees it
Queue full / timeout this host's load HTTP 503 → fatal → timeout vote

The general rule, which I think is what this PR was reaching for:

A limit whose value is part of the protocol → non-fatal, contract-visible.
A condition that depends on host-local state → fatal, host votes timeout.

Corollary that bites existing code: any contract-visible limit has to be protocol-uniform. GVM_WEBDRIVER_MAX_PAGE_HEAP_MB is a per-host env var today, so two hosts configured differently disagree about whether a page rendered — leader renders it, validator rejects it. Same latent divergence class as storageUnitPrice. Probably its own issue.

Caveat on my side: the fatal → Timeout vote chain is your reading of consensus/genvm.go; I haven't looked at the node repo. And whether a Timeout vote carries an idleness penalty is Kira's call, not mine.

@MuncleUscles

Copy link
Copy Markdown
Member Author

Correction to my comment above, and a finding that strengthens this PR's site 1.

I endorsed fix direction 3 wholesale. Only two thirds of it is right.

Move newPage() inside the try in the webdriver and map ProtocolError/TimeoutError to a Resulting-Status on an HTTP 200, so it joins the already-correct page-load path.

The mapping half is wrong, by the same reasoning that makes the test assertion wrong. newPage() timing out is a statement about this host's browser being under pressure — not about the page. Routing it to Resulting-Status makes it contract-visible, which is exactly the laundering we're trying to avoid. It should stay fatal.

What is right: ensure cleanup, and set an explicit protocolTimeout below the module deadline so it fails fast instead of hanging on puppeteer's 180s default.

And site 1 is worse than described. It isn't only a misclassification — it's a leak:

const context = await browserInstance.createBrowserContext();
const page = await context.newPage();   // throws here...

try {
    ...
} finally {
    await Promise.allSettled([page.close(), context.close()]);   // ...so this never runs
}

The finally only runs once execution is inside the try. When newPage() throws, the context is never closed and survives for the shared browser's entire lifetime. Since page creation fails precisely when the browser is under pressure, the response to pressure was to leak more of it — a degenerating loop, and a plausible contributor to whatever pressure triggered it in the first place.

Fixed in #23 (037a18e), since that PR is already scoped to bounding webdriver memory and has the test runner. So site 1's code is handled there; what remains here is the test decision.

Net for this PR: no fix needed in implementation/ — the behaviour there is correct. The webdriver-side fix has moved to #23. That leaves this as a test-and-decision PR, which I think is a good outcome: inverting the red test turns it into the durable record that a sidecar failure must stay fatal.

One thing I could not verify: inducing a real newPage() failure needs fault injection that doesn't exist in the webdriver yet, so the leak fix is reasoned from control flow rather than exercised by a test.

Completes the repro this branch opened with. A failure of our OWN webdriver
sidecar was classified fatal, aborting the whole contract run as an internal
error the contract could not catch. Downstream the node turns any GenVM error
into a Timeout vote, so an infrastructure blip became a consensus verdict.

Enforcement point: the Lua Render call site, not the Rust STATUS_NOT_OK
sites. Reasoning, since the alternative looks tempting:

- error_on_status is a request-SHAPE flag ("raise instead of returning the
  response"), not a trust flag. The generic helpers are handed a URL and
  cannot tell our sidecar from a site the contract named, so fatality — a
  trust-domain question — is the wrong thing to answer there.
- The other callers are not the sidecar. Production callers passing
  error_on_status = true are Render and the nine LLM providers. The
  contract-facing Request does NOT set the flag, so an arbitrary contract URL
  returning 500 already comes back as an ordinary Response with a status and
  never reaches STATUS_NOT_OK. Flipping the Rust default would have silently
  changed LLM-provider semantics, which feed retry and backend selection.
- The pcall's lexical extent IS the trust boundary: no contract-controlled
  URL sits inside it, so the distinction is structural rather than
  heuristic. web.check_url and the WEBPAGE_LOAD_FAILED branch stay outside.
- It also covers more than STATUS_NOT_OK. A sidecar that is gone fails
  earlier as a fatal SENDING_REQUEST, and a truncated body as fatal
  READING_BODY — the same fault class. The pcall covers the whole hop.

Sidecar side, as the better classification rather than only a softer one:
createBrowserContext() and newPage() move inside an error scope, and any
failure there returns 200 + Resulting-Status: 503 instead of escaping to the
outer catch as HTTP 500. That puts it on the already-correct
WEBPAGE_LOAD_FAILED path with the status visible in ctx. All setup failures
are mapped, not only ProtocolError/TimeoutError: the target URL is never
passed to those calls, so by construction nothing there is an observation
about the page.

protocolTimeout is now explicit at 90s (env GVM_WEBDRIVER_PROTOCOL_TIMEOUT).
Correcting the assumption this work started from: puppeteer-core 24.16.0
defaults to 180s and base_client_builder is 300s, so that pair was never
inverted. What is inverted is 180s against the render budget — 30s
navigation plus at most 60s waitAfterLoaded is about 90s — so a wedged CDP
command was noticed roughly 90s after the render should have ended. The 90s
value is a reasoned choice, not a measured one.

Coverage the repro left open, now closed:

- The webdriver TypeScript had never executed. It has no test runner, and
  importing index.ts starts an HTTP server and launches Chromium at module
  scope. The render logic moves to src/render.ts importing puppeteer types
  only; index.ts keeps the server and handlers. Tests use node:test with
  zero new dependencies, live outside src/ so tsc does not emit them, and
  run in the Dockerfile, which is the only CI path touching this code.
- The downstream half had no seam. The fatal-to-wire branch is extracted
  from the private, stream-generic loop_one_inner into
  module_error_to_wire, and pinned through a calldata round-trip.
- The sidecar's other non-200 exits: 400 bad params, 503 healthcheck, plus
  connection-refused, which is the same fault class and was also fatal.
- The JSON twin at scripting/mod.rs is now covered at error_on_status true
  and false. It is COVERED, not changed: Render never sets json, so the twin
  has no sidecar caller and its only such callers are the LLM providers.

Every new test was red-checked by reverting each fix in turn. Reverting the
Lua pcall: 5 failures, controls green. Reverting the TypeScript mapping:
3 failures, 2 controls green.

Rust: 115 passed, 0 failed across 16 binaries. TypeScript: 5/5.
tsc --noEmit and cargo fmt --check clean.

Two consequences worth a reviewer's attention rather than burying:

A validator whose own sidecar is broken now computes a result (a catchable
NondetException) and votes disagree, rather than voting Timeout. That may be
worse for that validator than abstaining. The repo already made this choice
for WEBPAGE_LOAD_FAILED and for Request's blanket reraise(false); this
follows it rather than inventing a third classification.

The re-raise keeps causes = ["STATUS_NOT_OK"], so downstream cannot tell a
sidecar status error from any other. A distinct cause such as
WEBDRIVER_UNAVAILABLE would help ops, but reraise_with_fatality does not
support one and contract-visible vocabulary was not invented here.

Not covered in this repo: the last hop, FatalError to bare anyhow to
ResultCode::InternalError, lives in the executors/v0.3.x submodule and
testing it there needs a gitlink bump and an executor build, which the
repo's macos guidance rules out natively on Darwin. Documented with file
references on module_error_to_wire instead.
@MuncleUscles
MuncleUscles marked this pull request as ready for review August 10, 2026 12:45
@MuncleUscles MuncleUscles changed the title test(web): Add failing repro for webdriver 5xx fatality [RED BY DESIGN] fix(web): Treat webdriver sidecar failures as non-fatal Aug 10, 2026
@MuncleUscles

Copy link
Copy Markdown
Member Author

Completed — fix added, coverage closed, out of draft

The branch opened as a red-by-design repro. It now carries the fix and the four coverage gaps are closed. Rust 115 passed / 0 failed across 16 binaries; TypeScript 5/5.

Where the fix lives, and why not the obvious place

Enforcement is at the Lua Render call site, not the Rust STATUS_NOT_OK sites. The tempting Rust change would have been wrong:

  • error_on_status is a request-shape flag ("raise instead of returning the response"), not a trust flag. The generic helpers get a URL and cannot tell our sidecar from a site the contract named — fatality is a trust-domain question, so that is the wrong place to answer it.
  • The other callers are not the sidecar: production callers passing error_on_status = true are Render and the nine LLM providers. The contract-facing Request does not set the flag at all, so an arbitrary contract URL returning 500 already comes back as an ordinary Response with a status and never reaches STATUS_NOT_OK. Flipping the Rust default would have silently changed LLM-provider semantics, which feed retry and backend selection.
  • The pcall's lexical extent is the trust boundary — no contract-controlled URL sits inside it, so the distinction is structural rather than heuristic.
  • It covers more than STATUS_NOT_OK: a sidecar that is gone fails earlier as a fatal SENDING_REQUEST, a truncated body as fatal READING_BODY. Same fault class, and the pcall covers the whole hop.

Sidecar side — better classification, not just softer

createBrowserContext() and newPage() move inside an error scope; any failure there returns 200 + Resulting-Status: 503 rather than escaping as HTTP 500, putting it on the already-correct WEBPAGE_LOAD_FAILED path with the status visible in ctx. All setup failures are mapped, not just ProtocolError/TimeoutError — the target URL is never passed to those calls, so by construction nothing there is an observation about the page.

protocolTimeout is now explicit at 90s (GVM_WEBDRIVER_PROTOCOL_TIMEOUT).

Correcting the assumption this work started from: puppeteer-core 24.16.0 defaults to 180s and base_client_builder is 300s, so that pair was never inverted. What is inverted is 180s against the render budget — 30s navigation plus at most 60s waitAfterLoaded ≈ 90s — so a wedged CDP command was noticed roughly 90s after the render should have ended. The 90s value is reasoned, not measured.

Coverage gaps, closed

  1. The TypeScript had never executed. No test runner, and importing index.ts starts an HTTP server and launches Chromium at module scope. Render logic moved to src/render.ts (puppeteer types only); index.ts keeps the server and handlers. node:test, zero new dependencies, tests outside src/ so tsc does not emit them, wired into the Dockerfile — the only CI path that touches this code.
  2. Downstream half — the fatal→wire branch had no seam; extracted to module_error_to_wire and pinned through a calldata round-trip.
  3. Other non-200 exits — 400 bad params, 503 healthcheck, plus connection-refused (same fault class, also fatal).
  4. JSON twin — covered at error_on_status true and false. Covered, not changed: Render never sets json, so it has no sidecar caller and its only such callers are the LLM providers.

Red-checks

Every new test was verified non-vacuous by reverting each fix in turn:

Lua pcall reverted:        5 failed, controls green
TS setup mapping reverted: 3 failed, 2 controls green

Two consequences for a reviewer, not buried

A validator whose own sidecar is broken now computes a result (a catchable NondetException) and votes disagree, rather than voting Timeout. That may be worse for that validator than abstaining. The repo already made this choice for WEBPAGE_LOAD_FAILED and for Request's blanket reraise(false); this follows it rather than inventing a third classification. Worth confirming that is the intent.

Diagnosability: the re-raise keeps causes = ["STATUS_NOT_OK"], so downstream cannot distinguish a sidecar status error from any other. A distinct cause like WEBDRIVER_UNAVAILABLE would help ops, but reraise_with_fatality does not support one and contract-visible vocabulary was not invented here.

Not covered

The last hop — FatalError → bare anyhow → ResultCode::InternalError — lives in the executors/v0.3.x submodule; testing it there needs a gitlink bump and an executor build, which the repo's macos guidance rules out natively on Darwin. Documented with file references on module_error_to_wire instead.

stylua/prettier were not machine-run (not installed; the nix devshell does not evaluate on aarch64-darwin) — hand-matched to .stylua.toml/.prettierrc/.editorconfig; CI pre-commit is the real verdict. The Docker build was not run locally (no daemon); npm test was run directly with the identical command, though on Node 24 rather than the image's Node 20.

@MuncleUscles
MuncleUscles marked this pull request as draft August 10, 2026 12:48
@MuncleUscles

Copy link
Copy Markdown
Member Author

⚠️ Back to draft — the classification in 563f2b4 is wrong

Making a sidecar failure non-fatal was a regression, not a fix. Correcting it.

A broken sidecar means this validator has no valid observation of the world. Surfacing it to the contract as a catchable NondetException lets the contract compute a result and the validator vote on a claim it never observed. The truthful answer is to abstain — vote Timeout, which consensus already has for exactly this case.

This principle is already in the codebase. genlayer-node consensus/genvm.go (~:130) reasons this way for GenVM-manager availability, verbatim:

A manager-availability failure — unreachable (dial refused/reset) or not ready (restart warm-up 5xx) — means GenVM never ran the transaction: an infra outage, not an execution outcome. … Only when that budget runs out does control fall through to the synthesized timeout below — at that point real phase time has elapsed and the timeout is honest.

A webdriver sidecar failure is the same class. 563f2b4 routed it into the execution outcome path instead.

What this means for the original diagnosis

The original behaviour — fatal → ResultCode::InternalError → the node's SetToTimeout() — already produced the correct vote. So the classification was right in outcome all along, and the defects worth fixing are the two that remain:

  1. Diagnosability — a sidecar outage is indistinguishable from a genuine contract internal error.
  2. Blast radius — the whole contract run aborts, with no way to tell why.

Neither is fixed by making the failure contract-visible.

Also note the TypeScript change has the same wrong effect by a different route: mapping setup failures to 200 + Resulting-Status: 503 puts them in the non-fatal page-load set. A genuine remote site failure is a real observation and must stay non-fatal; a sidecar failure must not.

Being reworked

  • Reverting the Lua pcall/reraise(false) and the TS 503 mapping.
  • Keeping what is still right: the node:test runner and src/render.ts split that made the TypeScript testable at all, module_error_to_wire and its tests, the explicit protocolTimeout, and every new test case — with assertions inverted, so the suite fails if anyone reclassifies sidecar failures as contract-visible.
  • Adding the improvement that was actually missing: a distinct error cause (WEBDRIVER_UNAVAILABLE or similar) so operators and the node can tell a sidecar outage from a contract bug, while keeping the fatal/abstain classification.
  • Open question being evaluated: whether the sidecar case deserves the same bounded wait-and-retry the manager case gets, so a transient blip does not burn a duty.

The red-by-design repro that opened this branch still stands — the sidecar failure path is genuinely undertested. What changed is which behaviour the tests should assert.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
webdriver/src/prj/src/index.ts (1)

41-50: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Await the render before releasing the browser handle.

BrowserHolder.close() can reduce the reference count to zero during browser rotation and close the browser while renderPageWithBrowser is still running. Return await renderPageWithBrowser(...) so the render keeps its reference until completion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webdriver/src/prj/src/index.ts` around lines 41 - 50, Update the try/finally
flow around renderPageWithBrowser to await its completion before
BrowserHolder.close() runs, preserving the browser reference for the full render
operation while keeping the existing cleanup in finally.
🧹 Nitpick comments (1)
webdriver/src/prj/package.json (1)

10-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Migrate the ESM test setup away from --loader.

ts-node is already declared in devDependencies. Node 20.6+ deprecates --loader; use a supported --import registration setup. Because engines.node allows versions before 18.19.0, either raise the minimum version or retain compatibility for those versions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webdriver/src/prj/package.json` around lines 10 - 11, Update the package.json
test script to replace the deprecated ts-node --loader registration with a
supported --import setup, using the existing ts-node devDependency. Ensure the
chosen registration works with the declared engines.node range, or raise that
minimum to Node 18.19.0+ if older-version compatibility cannot be retained.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@implementation/src/web/tests.rs`:
- Around line 367-369: Update the documentation comment immediately above the
companion test to describe its actual setup: it constructs a plain anyhow error
and renders a valid URL against the 404 fixture, while preserving the note that
the fatal path remains reachable and ends in FatalError. Remove the inaccurate
claim about malformed URLs bypassing the sidecar.

In `@webdriver/src/prj/src/render.ts`:
- Around line 269-275: Handle all promise rejections in the render setup around
the targetcreated listener and page.setViewport: add catch handling to the async
listener covering target.page() and ssrf.installSsrfGuard(p), and await
setViewport within the surrounding guarded flow. Ensure failures are caught and
handled without creating floating promises or terminating the sidecar process.

---

Outside diff comments:
In `@webdriver/src/prj/src/index.ts`:
- Around line 41-50: Update the try/finally flow around renderPageWithBrowser to
await its completion before BrowserHolder.close() runs, preserving the browser
reference for the full render operation while keeping the existing cleanup in
finally.

---

Nitpick comments:
In `@webdriver/src/prj/package.json`:
- Around line 10-11: Update the package.json test script to replace the
deprecated ts-node --loader registration with a supported --import setup, using
the existing ts-node devDependency. Ensure the chosen registration works with
the declared engines.node range, or raise that minimum to Node 18.19.0+ if
older-version compatibility cannot be retained.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 45ea3ecb-611a-4008-a9a7-eefc8692d6f9

📥 Commits

Reviewing files that changed from the base of the PR and between 70fdf78 and 563f2b4.

📒 Files selected for processing (11)
  • implementation/src/common/mod.rs
  • implementation/src/web/mod.rs
  • implementation/src/web/tests.rs
  • implementation/tests/request_status_fatality.rs
  • install/config/genvm-web-default.lua
  • webdriver/Dockerfile
  • webdriver/src/prj/package.json
  • webdriver/src/prj/src/browser/chrome.ts
  • webdriver/src/prj/src/index.ts
  • webdriver/src/prj/src/render.ts
  • webdriver/src/prj/test/render.test.ts

Comment thread implementation/src/web/tests.rs Outdated
Comment on lines +269 to +275
context.on('targetcreated', async (target) => {
const p = await target.page();
if (p && p !== page) {
await ssrf.installSsrfGuard(p);
}
});
page.setViewport({ width: 1920 / 2, height: 1080 / 2 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle rejections from the targetcreated listener and setViewport.

Line 269 registers an async event listener. Line 275 calls page.setViewport without await. Both produce floating promises. If target.page(), installSsrfGuard(p), or setViewport rejects, Node 20 raises an unhandled rejection, and the default --unhandled-rejections=throw mode terminates the sidecar process. That converts a single bad render into a full sidecar outage, which the module then reports as a connection failure for every concurrent request.

Await setViewport inside the guarded block, and add a catch to the listener body.

🛡️ Proposed fix
 		await ssrf.installSsrfGuard(page);
 		context.on('targetcreated', async (target) => {
-			const p = await target.page();
-			if (p && p !== page) {
-				await ssrf.installSsrfGuard(p);
-			}
+			try {
+				const p = await target.page();
+				if (p && p !== page) {
+					await ssrf.installSsrfGuard(p);
+				}
+			} catch (error) {
+				logger.log('error', 'could not guard a new target', {
+					url: targetUrl,
+					error: (error as Error).message,
+				});
+			}
 		});
-		page.setViewport({ width: 1920 / 2, height: 1080 / 2 });
+		await page.setViewport({ width: 1920 / 2, height: 1080 / 2 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
context.on('targetcreated', async (target) => {
const p = await target.page();
if (p && p !== page) {
await ssrf.installSsrfGuard(p);
}
});
page.setViewport({ width: 1920 / 2, height: 1080 / 2 });
context.on('targetcreated', async (target) => {
try {
const p = await target.page();
if (p && p !== page) {
await ssrf.installSsrfGuard(p);
}
} catch (error) {
logger.log('error', 'could not guard a new target', {
url: targetUrl,
error: (error as Error).message,
});
}
});
await page.setViewport({ width: 1920 / 2, height: 1080 / 2 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webdriver/src/prj/src/render.ts` around lines 269 - 275, Handle all promise
rejections in the render setup around the targetcreated listener and
page.setViewport: add catch handling to the async listener covering
target.page() and ssrf.installSsrfGuard(p), and await setViewport within the
surrounding guarded flow. Ensure failures are caught and handled without
creating floating promises or terminating the sidecar process.

Reworks the previous commit, whose classification was wrong.

A broken webdriver sidecar means this validator has no valid observation of
the world. Making the failure contract-visible let the contract compute a
result and the validator vote on a claim it never observed. The truthful
answer is to abstain, and the node already turns a fatal GenVM error into a
Timeout vote.

The node states this principle for the sibling case, GenVM-manager
availability: an infra outage is not an execution outcome, and a timeout is
honest only once real phase time has elapsed. A sidecar fault is the same
class; the previous commit routed it into the execution-outcome path.

So the ORIGINAL behaviour produced the correct vote, and the defects worth
addressing were the other two: a sidecar outage was indistinguishable from a
genuine contract internal error, and it left no local trace.

Reverted:
- reraise_with_fatality(result, false) in Render. The pcall stays, but its
  job changes from softening to labelling: it calls
  web.reraise_as_webdriver_unavailable, which sets fatal = true explicitly.
  Fatality is now pinned at the call site rather than inherited from whatever
  the generic transport decided, so a later flip of a Rust default cannot
  silently make the validator vote.
- the TypeScript mapping of setup failures onto 200 + Resulting-Status: 503.
  That status sits in the non-fatal page-load set, so it had the same wrong
  effect by a different route. Setup failures propagate to the outer catch
  and stay HTTP 500.

render.ts now states the invariant as two channels: RETURNED means an
observation about the page, THROWN means this sidecar failed, and they must
not be mixed.

Diagnosability, the improvement actually missing: a WEBDRIVER_UNAVAILABLE
cause is prepended so STATUS_NOT_OK / SENDING_REQUEST / READING_BODY survive
underneath. ErrorKind is deliberately NOT extended — this is Lua-raised
vocabulary like WEBPAGE_LOAD_FAILED, MALFORMED_URL and TLD_FORBIDDEN, none of
which are in the enum. It is not contract-visible: on the fatal path the
contract receives no causes array, while ModuleError's Display is its JSON,
so the label survives into the operator-facing abort message. Two sidecar-side
logs added; a 500 previously left no local trace.

Kept: the node:test runner and the src/render.ts split, which are the only
reason the TypeScript is testable; module_error_to_wire and its tests, the
seam where fatal is spent; the explicit 90s protocolTimeout, now strictly
better since a wedged CDP call becomes a fast honest abstention rather than a
slow one; newRenderTarget, which fixes a real context leak and re-throws.
All test cases retained with assertions inverted.

Four red-checks, each reverted then restored:
- reinstating the rejected regression: 5 sidecar tests fail, 3 remote
  controls green
- stripping the pcall entirely: same 5 fail, but at a DIFFERENT assertion,
  proving fatality and the label are independently load-bearing
- making WEBPAGE_LOAD_FAILED fatal: the 3 remote controls fail and the
  sidecar tests pass, so the suite bites in both directions
- re-applying the TS 503 mapping: 3 TypeScript tests fail

The sharpest pair is render_sidecar_unhealthy_is_fatal against
render_remote_page_unreachable_is_not_fatal: identical 503, opposite verdict,
differing only in which channel carries it. That is exactly the confusion
that produced the regression.

Rust 116 passed, 0 failed. TypeScript 5/5. tsc --noEmit and cargo fmt --check
clean. Also fixed 11 check-source-text violations this branch introduced plus
2 that chrome.ts carried from before it; the hook would have blocked a commit.
@MuncleUscles
MuncleUscles marked this pull request as ready for review August 10, 2026 13:08
@MuncleUscles

Copy link
Copy Markdown
Member Author

Reworked — sidecar faults stay fatal, and are now labelled

0a541a1. Rust 116 passed / 0 failed, TypeScript 5/5.

The pcall in Render survives, but its job changed from softening to labelling: it calls web.reraise_as_webdriver_unavailable, which sets fatal = true explicitly. Fatality is now pinned at the call site rather than inherited from whatever the generic transport decided — so a later flip of a Rust default cannot silently make the validator vote.

The TypeScript 503 mapping is gone; setup failures propagate to the outer catch and stay HTTP 500. render.ts now states the invariant directly: returned = an observation about the page, thrown = this sidecar failed, and the two must not be mixed.

Diagnosability — the defect actually worth fixing

WEBDRIVER_UNAVAILABLE is prepended so STATUS_NOT_OK / SENDING_REQUEST / READING_BODY survive underneath.

ErrorKind is deliberately not extended — this is Lua-raised vocabulary like WEBPAGE_LOAD_FAILED, MALFORMED_URL and TLD_FORBIDDEN, none of which are in the enum. And it is not contract-visible: on the fatal path the contract receives no causes array at all, while ModuleError's Display is its JSON, so the label survives into the operator-facing abort message. Two sidecar-side logs added — a 500 previously left no local trace.

Red-checks — the suite bites in both directions

Revert Result
Reinstate the rejected regression (reraise(false)) 5 sidecar tests FAIL, 3 remote controls green
Strip the pcall entirely (pre-branch original) Same 5 FAIL, but at a different assertion — fatality and the label are independently load-bearing
Make WEBPAGE_LOAD_FAILED fatal The 3 remote controls FAIL, sidecar tests green
Re-apply the TS 503 mapping 3 TypeScript tests FAIL

The sharpest pair: render_sidecar_unhealthy_is_fatal vs render_remote_page_unreachable_is_not_fatalidentical 503, opposite verdict, differing only in which channel carries it. That is precisely the confusion that produced the regression.

Recommendation: no wait-and-retry here

Asked for and argued, not assumed:

  1. No budget to bound it with. The node's manager retry is explicitly "bounded by the phase budget already carried in ctx". Message::Render carries no deadline. A Lua retry could only use a hardcoded constant — turning a fast honest abstention into a slow one that may then also miss the phase deadline. Note the LLM module retries safely precisely because it has remaining_gencompute_timeout; Render has no equivalent.
  2. Retry is not idempotent here. /render navigates the contract's URL, and a 500 can be thrown after a successful page.goto. Unlike the manager case — where a refused dial provably ran nothing — we cannot know the fetch didn't happen. Retry means extra unrequested egress and waitAfterLoaded multiplied.
  3. No cheap readiness probe. /healthcheck requires url + mode and performs a real render.
  4. Not all sidecar failures are transient — the 400 (version skew) and healthcheck 503 are deterministic.

Suggested order instead: land the label (prerequisite for any retry policy anywhere) → if retry is wanted, put it in the node, beside the manager-availability retry where the phase budget already lives → cheaper and better, make the sidecar queue /render until a browser is ready rather than failing during warm-up, which removes most of the transient class with no consensus-facing change.

Four things still worth attention

  1. The label reaches the node only as a substring. Result::FatalError(String) has no structured cause, so "the node can tell them apart" means string-matching. Structuring it is a protocol change in the executors/v0.3.x submodule — proposing, not inventing. This is also the blocker for putting retry in the node.
  2. getNavigationErrorStatus has the same class of bug on the remote side. net::ERR_INTERNET_DISCONNECTED → 503 → non-fatal WEBPAGE_LOAD_FAILED. But "this node has no internet" is a local-environment fault — the validator observed nothing about the page, yet votes. Same shape as what this PR just fixed. ERR_NAME_NOT_RESOLVED → 502 is genuinely ambiguous (bad domain vs broken local resolver).
  3. TimeoutError → 408 is the largest remaining surface. A navigation timeout is either a slow site (an observation) or a wedged local browser (environment), and is currently always treated as the former. Two validators with different network conditions can vote differently on the same page.
  4. Liveness consequence of the correct classification. Sidecars are homogeneous — same image. A systemic sidecar fault now makes the whole committee abstain at once. Correct, and far better than all of them voting a fabricated result, but it makes sidecar availability a network-wide liveness dependency. That raises the value of the queue-during-warm-up suggestion considerably.

Also fixed in passing: 11 check-source-text violations this branch introduced, plus 2 chrome.ts carried from before it. The hook would have blocked a commit.

net::ERR_INTERNET_DISCONNECTED means this host has no network route. The
validator observed nothing about the page, so it must abstain rather than
return a catchable status the contract can act on and vote from. It now
leaves through the thrown channel: navigateToPage raises
LocalNetworkUnavailable before any status mapping runs, handleRenderRequest
turns it into a bare 500, and the module keeps it fatal.

net::ERR_NAME_NOT_RESOLVED and TimeoutError are deliberately NOT treated the
same way, and there are now tests that fail if anyone changes that. Both are
genuinely ambiguous: a bad domain or our broken resolver, a slow site or our
wedged browser. Resolving that ambiguity is what having several validators
and an equivalence principle is for, and pre-judging it here would suppress
the disagreement consensus exists to reconcile. ERR_INTERNET_DISCONNECTED
differs only in being unambiguous: there is no observation to reconcile.

ERR_CONNECTION_REFUSED, ERR_CERT_* and ERR_BLOCKED_BY_CLIENT are untouched;
they are genuine observations about the site. The certificate case had no
coverage and now has some.

The label is knowingly imprecise and is NOT widened here.
reraise_as_webdriver_unavailable names the hop, not the diagnosis: it fires
for anything the pcall around the sidecar request catches. On a host with no
network both the browser and the sidecar answered normally, so the word
points an operator at a working subsystem. Renaming it to something
hop-shaped is safe, since a fatal ModuleError never reaches the contract as
causes, but it is a separate decision. Until then the distinction is carried
by the sidecar message, which the wire test pins.

Tests: 4 TypeScript, 4 Rust. Eight red-checks, each reverted and restored.
Four revert the sidecar behaviour the fixtures model; two more break the
classification itself in each direction, so the suite bites both ways --
making the raise non-fatal fails 7 fatal-side tests with all 5 non-fatal
green, and making WEBPAGE_LOAD_FAILED fatal fails 5 non-fatal tests with all
7 fatal-side green.

Rust 120 passed, 0 failed (was 116). TypeScript 9/9 (was 5/5).
cargo fmt --check, tsc --noEmit and check-source-text all clean.

Left open, deliberately: ERR_PROXY_CONNECTION_FAILED and net::ERR_NETWORK_CHANGED
are as unambiguously ours as disconnected but still fall through to the 418
default on the observation channel. The ruling named one code and this change
implements one code.
@MuncleUscles

Copy link
Copy Markdown
Member Author

a86ca27 — disconnected host routed as ours; the ambiguous cases pinned as the site's

Rust 120 passed / 0 failed (was 116), TypeScript 9/9 (was 5/5).

net::ERR_INTERNET_DISCONNECTED now raises LocalNetworkUnavailable in navigateToPage before any status mapping runs, so it leaves through the thrown channel and the validator abstains.

ERR_NAME_NOT_RESOLVED and TimeoutError are unchanged, and now have tests that fail if anyone changes them. That guard is the point: both look local-fault-shaped to anyone reading the file fresh, so without it someone eventually "fixes" them. They are genuinely ambiguous — bad domain or our resolver, slow site or our wedged browser — and resolving that ambiguity is what several validators and an equivalence principle are for. Pre-judging it in the sidecar would suppress the disagreement consensus exists to reconcile. ERR_INTERNET_DISCONNECTED differs only in being unambiguous: there is no observation to reconcile.

ERR_CONNECTION_REFUSED, ERR_CERT_*, ERR_BLOCKED_BY_CLIENT untouched. The certificate case had no coverage and now has some.

The label is knowingly wrong, and deliberately not widened

reraise_as_webdriver_unavailable names the hop, not the diagnosis — it fires for anything the pcall around the sidecar request catches. On a host with no network, the browser and the sidecar both answered normally, so WEBDRIVER_UNAVAILABLE points an operator at a working subsystem. Right category (ours, not the page's), wrong word.

Renaming to something hop-shaped (WEBDRIVER_HOP_FAILED, RENDER_INFRA_UNAVAILABLE) is safe — a fatal ModuleError never reaches the contract as causes, so this is not contract-visible vocabulary — but it is a separate decision and outside the ruling. Until then the distinction is carried by the sidecar's message, which a wire test pins.

Red-checks — eight, and the suite bites in both directions

Four revert the sidecar behaviour the fixtures model. Two more break the classification itself:

Revert Result
err.fatal = false in lib-web.lua 7 fatal-side tests FAIL, all 5 non-fatal green
WEBPAGE_LOAD_FAILED made fatal 5 non-fatal tests FAIL, all 7 fatal-side green

Plus, per case: restoring the 503 mapping → Missing expected rejection; reclassifying name-not-resolved → reclassified as ours: DNS resolution failed; reclassifying timeout → reclassified as ours: navigation timeout; folding cert into 503 → actual: 503, expected: 495.

Three things for your attention

  1. ERR_PROXY_CONNECTION_FAILED and net::ERR_NETWORK_CHANGED are as unambiguously ours as disconnected, and still fall through to the 418 teapot default on the observation channel. The ruling named one code, so this change implements one code. Worth a follow-up decision.
  2. Liveness cost worth telling ops before this ships. Chrome derives ERR_INTERNET_DISCONNECTED from its own network-change detector, which misfires on some container/VM setups with no default route. On such a host every render now aborts the run instead of returning a catchable status, so the node abstains repeatedly. Correct semantics, but it converts a contract-visible failure into a repeated-timeout liveness event.
  3. The operator-facing message survives by accident, not design. The transport puts the sidecar body into ctx as GenericValue::Bytes, which would render as a numeric array in the FatalError JSON; it arrives readable only because the pcall round trip through Lua converts it to a Str. A wire test now pins that, but the underlying dependency is fragile and nothing else documents it.

@github-actions github-actions Bot added the not rebased branch is behind its base; rebase before it can be merged label Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-safe not rebased branch is behind its base; rebase before it can be merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant