Skip to content

feat(webdriver): bound memory per render request - #23

Closed
MuncleUscles wants to merge 6 commits into
v0.6-devfrom
feat/webdriver-per-request-limits
Closed

feat(webdriver): bound memory per render request#23
MuncleUscles wants to merge 6 commits into
v0.6-devfrom
feat/webdriver-per-request-limits

Conversation

@MuncleUscles

@MuncleUscles MuncleUscles commented Aug 6, 2026

Copy link
Copy Markdown
Member

Why

The webdriver has no bound on the memory one render can cost, and no bound on how many renders run at once. Both are needed before peak RAM per genvm is a number anyone can state.

Two gaps specifically:

  • asText/asHTML pull the entire page into the Node process. page.evaluate(() => document.body.innerText) returns the whole string over CDP with no cap. A downstream limit cannot help — by the time the caller aborts its read, this process has already built the string.
  • No concurrency cap anywhere. N concurrent renders create N browser contexts on the one shared browser, so per-request limits multiply without bound.

What this does

Peak memory becomes the product of two limits, so each in-flight render is guaranteed its full allocation regardless of how many other callers are active:

peak ≈ MAX_CONCURRENT_RENDERS × (page heap + extracted response) + browser baseline
Env var Default Effect
GVM_WEBDRIVER_MAX_CONCURRENT_RENDERS 4 In-flight renders. Matches the existing --renderer-process-limit=4, so it enforces what the flags already implied rather than reducing capability.
GVM_WEBDRIVER_MAX_RESPONSE_MB 32 Per-request extracted response, in characters.
GVM_WEBDRIVER_RENDER_QUEUE_TIMEOUT 120s Bounded queue wait, well under the caller's 300 s transport timeout.

Three design points worth reviewing:

  1. The size check runs inside the page. Measuring in Node after the transfer would already have paid the cost the limit exists to prevent, so extractBounded compares the length browser-side and returns only a number when the document is too large.
  2. Excess callers queue, they are not shed. A 503 under saturation would make the outcome depend on what else a node happened to be running. Queueing keeps it a function of the request alone.
  3. Both limits report through Resulting-Status with HTTP 200. genvm-web-default.lua:36-46 maps a bad Resulting-Status to a non-fatal WEBPAGE_LOAD_FAILED, whereas an HTTP-level failure is fatal and aborts the caller's whole execution. New limits must not be a new way to abort an execution. No Lua changes needed.

/healthcheck shares the semaphore, since it performs real renders and would otherwise bypass the cap.

Drive-by fix

renderPage did return renderPageWithBrowser(...) without await inside try/finally, so browserInstance.close() decremented the refcount before the render completed. That defeats the BrowserHolder refcount that exists to stop rotation closing a browser mid-render. Added the await.

Testing

npm test — 10 unit tests for the semaphore (FIFO ordering, timeout removes the waiter, no permit leak on throw or double-release, concurrency never exceeds the limit under 50 concurrent tasks).

Verified end-to-end against real Chrome:

PASS  small page renders 200/200
PASS  oversized page -> HTTP 200 + Resulting-Status 507
PASS  oversized payload withheld, limit reported
PASS  all queued renders succeed
PASS  queueing observed (6 renders / 2 permits took 8223ms, expected >=3600)
PASS  queue timeout -> HTTP 200 + Resulting-Status 503 (non-fatal)

npx tsc --noEmit clean. Test runner is Node's built-in node:test via the existing ts-node/esm loader — no new dependencies.

Behaviour change to weigh

GVM_WEBDRIVER_MAX_RESPONSE_MB=32 is the one default that changes what succeeds: a page whose text exceeds 32 M characters now returns 507 instead of being rendered. Typical page text is tens of KB, so this is ~300× the largest realistic page — but it is a real change and the value is worth a second opinion. Everything else either matches an existing implied limit or only takes effect under saturation.

Not in scope

Container memory limit (lives in the deployment manifests, not this repo), Chromium pinning, lowering --max-old-space-size / --renderer-process-limit, and making Render failures non-fatal in the Lua. The last one matters more now that limits exist — worth doing next.

Summary by CodeRabbit

  • New Features
    • Added configurable response-size limits for text, HTML, and screenshots.
    • Added safeguards to limit concurrent rendering and prevent overloaded services.
  • Bug Fixes
    • Render requests now return clear service-unavailable responses when capacity is reached.
    • Oversized responses and insufficient storage conditions now return appropriate error statuses.
    • Improved cleanup restores rendering capacity after failures, timeouts, or disconnected clients.
  • Chores
    • Added automated test and type-check commands for the WebDriver project.

Peak memory is now the product of a concurrency cap and a per-request
allocation, so a caller holding a slot keeps its full budget no matter how
many others are active. Excess callers queue rather than being rejected:
shedding would make the outcome depend on what else a node happened to be
running. Both new limits report through Resulting-Status, which callers
treat as recoverable, rather than failing at the transport level.

The oversized-response check runs inside the page because measuring after
the transfer would already have paid the memory cost it exists to prevent.

Also adds a missing await in renderPage: the browser refcount was released
before the render finished, so rotation could close a browser out from
under an in-flight page.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

👋 This PR targeted main, so I've retargeted it to the latest dev branch v0.6-dev.

main is protected and is only an alias of the latest release branch (v0.6-dev), kept in lockstep automatically. Active v0.6 work lands on v0.6-dev, which reaches v0.6.x through the standing release-gate PR once the cross-repo E2E matrix is green.

@github-actions
github-actions Bot changed the base branch from main to v0.6-dev August 6, 2026 09:38
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@MuncleUscles, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 16 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 22d7ce82-1e12-41dc-abe9-4fecd5847029

📥 Commits

Reviewing files that changed from the base of the PR and between 0e3e3d4 and 35ca8c7.

📒 Files selected for processing (1)
  • webdriver/src/prj/src/index.ts
📝 Walkthrough

Walkthrough

The WebDriver project adds a FIFO semaphore for render concurrency, queue timeouts, abort handling, bounded text/HTML/screenshot extraction, and status handling for unavailable or oversized responses. It also adds semaphore tests and npm scripts for testing and type checking.

Changes

WebDriver render controls

Layer / File(s) Summary
Semaphore implementation and validation
webdriver/src/prj/src/semaphore.ts, webdriver/src/prj/src/semaphore.test.ts, webdriver/src/prj/package.json
Adds bounded FIFO acquisition, timeout and abort errors, safe release, withPermit, comprehensive tests, and npm test/typecheck scripts.
Bounded response extraction
webdriver/src/prj/src/duration.ts, webdriver/src/prj/src/index.ts
Adds positive-integer environment parsing and configurable limits for text, HTML, and screenshots. Extraction throws ResponseLimitExceeded when output exceeds the limit.
Render concurrency and status handling
webdriver/src/prj/src/index.ts
Acquires a shared render permit before browser access, cancels queued work when the request disconnects, returns 503 for queue failures, and returns 507 for heap or response-size failures.

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

Sequence Diagram(s)

sequenceDiagram
  participant RenderRequest
  participant renderSemaphore
  participant Browser
  participant PageExtraction
  RenderRequest->>renderSemaphore: acquire render permit
  renderSemaphore-->>RenderRequest: permit or queue failure
  RenderRequest->>Browser: render page
  Browser->>PageExtraction: extract bounded content
  PageExtraction-->>RenderRequest: content or ResponseLimitExceeded
  RenderRequest->>renderSemaphore: release render permit
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the response-size and memory-bound portion of the changes, but it omits the added concurrency and queue limits.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/webdriver-per-request-limits

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.

@github-actions

github-actions Bot commented Aug 6, 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 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

🤖 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 `@webdriver/src/prj/src/index.ts`:
- Around line 53-54: Validate the value returned by envInt for
GVM_WEBDRIVER_MAX_RESPONSE_MB during startup, requiring a positive integer
before calculating MAX_RESPONSE_CHARS. Reject zero, negative, and non-integer
values with an error message that explicitly names
GVM_WEBDRIVER_MAX_RESPONSE_MB.

In `@webdriver/src/prj/src/semaphore.ts`:
- Around line 15-19: Add a configurable maximum queue depth to the semaphore
used by renderPage, reject new waiters once that limit is reached so the HTTP
handler returns 503, and preserve existing enqueue behavior below the limit.
Track each queued request’s close event and remove its waiter and timeout when
the request closes, updating the waiter bookkeeping and cleanup logic
consistently.
🪄 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: 960299a1-d212-4196-b10d-364ec720a203

📥 Commits

Reviewing files that changed from the base of the PR and between 70fdf78 and 9b2e616.

📒 Files selected for processing (4)
  • webdriver/src/prj/package.json
  • webdriver/src/prj/src/index.ts
  • webdriver/src/prj/src/semaphore.test.ts
  • webdriver/src/prj/src/semaphore.ts

Comment thread webdriver/src/prj/src/index.ts Outdated
Comment thread webdriver/src/prj/src/semaphore.ts
An unbounded queue only defers overload: callers pile up holding live
connections until each trips its own transport timeout. Refusing past a
configured depth fails fast through the same recoverable status, and a
caller that disconnects now frees its slot instead of being handed a permit
for a render nobody will read.

Also validates the response and concurrency limits at startup, since a zero
or negative value silently rejected every page.
@MuncleUscles

Copy link
Copy Markdown
Member Author

Addressed both review findings.

Queue depth — you're right, and it made me re-examine my own reasoning in the PR body. I'd argued for "queue, don't shed" on the grounds that shedding makes the outcome depend on node-local load. But the 120 s timeout already has exactly that property, so a depth cap adds no new class of problem — it just makes the failure explicit and fast instead of making 64 callers each wait two minutes and then fail anyway.

Added GVM_WEBDRIVER_MAX_RENDER_QUEUE (default 64). Overflow reports through the same non-fatal Resulting-Status: 503 as the timeout, so queue-full and queue-timeout are indistinguishable to the caller apart from latency. That was the part worth preserving — a raw HTTP 503 would be fatal and abort the caller's whole execution.

Abandoned waiters — also added. acquire now takes an AbortSignal, wired to the request's close event, so a caller that disconnects frees its queue slot rather than eventually being handed a permit for a render nobody will read.

Validation — added envPositiveInt, applied to GVM_WEBDRIVER_MAX_RESPONSE_MB and GVM_WEBDRIVER_MAX_CONCURRENT_RENDERS. Both now fail at startup naming the variable. (Concurrency was already guarded by the Semaphore constructor, but failing in the env layer gives a clearer message.)

Tests: 17 unit tests now, up from 10 — new coverage for queue-full refusal, fast-fail rather than waiting out the timeout, queue reacceptance after drain, abort-while-queued, already-aborted signal, and abort-after-permit-granted being a no-op.

Re-verified end to end against real Chrome: oversized page still returns 507 with the payload withheld, queueing still observed under saturation, and a mid-flight client disconnect leaves the server healthy.

@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

🤖 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 `@webdriver/src/prj/src/duration.ts`:
- Around line 75-80: Update envPositiveInt to validate the raw environment value
before conversion, using Number(raw) rather than envInt so fractional and
trailing-character inputs are rejected. Require the resulting value to be a safe
positive integer, while preserving default handling for an unset variable and
the existing error behavior.

In `@webdriver/src/prj/src/index.ts`:
- Around line 454-464: The disconnect detection in disconnectSignal should
listen once to res.close instead of req.close while preserving the
!res.writableEnded guard. Update handleRenderRequest and handleHealthcheck to
call disconnectSignal with only res, and adjust its signature accordingly.
🪄 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: 714dbca5-2482-42f8-a320-d187040d059c

📥 Commits

Reviewing files that changed from the base of the PR and between 9b2e616 and 0e3e3d4.

📒 Files selected for processing (4)
  • webdriver/src/prj/src/duration.ts
  • webdriver/src/prj/src/index.ts
  • webdriver/src/prj/src/semaphore.test.ts
  • webdriver/src/prj/src/semaphore.ts

Comment on lines +75 to +80
export function envPositiveInt(envName: string, defaultValue: number): number {
const value = envInt(envName, defaultValue);
if (!Number.isInteger(value) || value < 1) {
throw new Error(
`env ${envName} must be a positive integer, got "${process.env[envName]}"`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Tracked candidate file:\n'
git ls-files | grep -F 'webdriver/src/prj/src/duration.ts' || true

printf '\nRelevant duration.ts outline and target lines:\n'
if [ -f webdriver/src/prj/src/duration.ts ]; then
  ast-grep outline webdriver/src/prj/src/duration.ts || true
  printf '\nLines 1-130:\n'
  sed -n '1,130p' webdriver/src/prj/src/duration.ts | nl -ba
fi

printf '\nInspect parseInt behavior used by this envInt implementation:\n'
node - <<'JS'
for (const raw of ['4.5', '4invalid', '4invalid.5', '0.5', '3', '3.14159']) {
  console.log(`${JSON.stringify(raw)} -> parseInt 10=${parseInt(raw, 10)}, Number=${Number(raw)}, isInteger=${Number.isInteger(Number(raw))}, safe=${Number.isSafeInteger(Number(raw))}`);
}
JS

printf '\nSearch for envPositiveInt/envInt usages:\n'
rg -n "envPositiveInt|envInt" webdriver/src/prj/src -S || true

Repository: genlayerlabs/genvm-manager

Length of output: 1056


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Lines 1-120 of webdriver/src/prj/src/duration.ts:\n'
sed -n '1,120p' webdriver/src/prj/src/duration.ts | awk '{printf "%6d\t%s\n", NR, $0}'

printf '\nInspect parseInt behavior used by this envInt implementation:\n'
node - <<'JS'
for (const raw of ['4.5', '4invalid', '4invalid.5', '0.5', '3', '3.14159']) {
  console.log(`${JSON.stringify(raw)} -> parseInt 10=${parseInt(raw, 10)}, Number=${Number(raw)}, isInteger=${Number.isInteger(Number(raw))}, safe=${Number.isSafeInteger(Number(raw))}`);
}
JS

printf '\nSearch for envPositiveInt/envInt usages:\n'
rg -n "envPositiveInt|envInt" webdriver/src/prj/src -S || true

Repository: genlayerlabs/genvm-manager

Length of output: 6437


🏁 Script executed:

#!/bin/bash
set -euo pipefail

node - <<'JS'
const cases = [
  '4.5',
  '4invalid',
  '4invalid.5',
  '0.5',
  '3',
  '3.14159',
  '4.5invalid',
  '9007199254740991',
  '9007199254740992',
];
for (const raw of cases) {
  const parseInt10 = parseInt(raw, 10);
  const numberVal = Number(raw);
  console.log(`${raw}: parseInt10=${parseInt10}, Number=${numberVal}, isInteger=${Number.isInteger(numberVal)}, safe=${Number.isSafeInteger(numberVal)}`);
}
JS

Repository: genlayerlabs/genvm-manager

Length of output: 795


Validate the raw environment value before integer conversion.

envInt delegates to parseInt, so inputs like 4.5, 4invalid, and 4.5invalid accept only the integer prefix and use 4. Since envPositiveInt checks the parsed value, fractional or trailing-character configs bypass the intended fail-fast behavior for response and concurrency limits. Parse raw input strictly with Number(raw) and fail unless the value is a safe positive integer.

🤖 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/duration.ts` around lines 75 - 80, Update
envPositiveInt to validate the raw environment value before conversion, using
Number(raw) rather than envInt so fractional and trailing-character inputs are
rejected. Require the resulting value to be a safe positive integer, while
preserving default handling for an unset variable and the existing error
behavior.

Comment on lines +454 to +464
function disconnectSignal(
req: http.IncomingMessage,
res: http.ServerResponse,
): AbortSignal {
const controller = new AbortController();
req.on('close', () => {
if (!res.writableEnded) {
controller.abort();
}
});
return controller.signal;

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | fgrep 'webdriver/src/prj/src/index.ts' || true

echo "== relevant lines =="
if [ -f webdriver/src/prj/src/index.ts ]; then
  nl -ba webdriver/src/prj/src/index.ts | sed -n '420,500p'
fi

echo "== usages of disconnectSignal =="
rg -n "disconnectSignal|ServerResponse|IncomingMessage|writableEnded|close" webdriver/src/prj/src/index.ts || true

echo "== package/runtime hints =="
for f in package.json webdriver/src/prj/package.json; do
  [ -f "$f" ] && { echo "--- $f"; cat "$f"; }
done

Repository: genlayerlabs/genvm-manager

Length of output: 274


🌐 Web query:

Node.js http IncomingMessage response events 'close' request completed documentation

💡 Result:

In Node.js, the 'close' event on an http.IncomingMessage object indicates that the request has either been completed or that its underlying connection was terminated prematurely before completion [1][2][3]. Starting in Node.js v16.0.0, the behavior of the 'close' event was changed [4][5]. Before this version, 'close' typically signaled that the underlying socket connection had closed [6][5]. As of v16.0.0 and in all current versions, 'close' is emitted when the request is considered completed—meaning it is no longer strictly tied to the state of the underlying socket [4][5]. Key points regarding the 'close' event for IncomingMessage: 1. Meaning: The event indicates successful completion of the message reception or, alternatively, that the connection was terminated prematurely (e.g., client disconnection or network failure) before the response was fully processed [1][2][3]. 2. Version History: In Node.js v16.0.0 and later, the 'close' event behavior was updated to emit upon request completion rather than underlying socket closure [4][5]. This was a breaking change [6]. 3. Relation to Other Events: Because the 'close' event is a standard stream event, it can sometimes be emitted after other stream events like 'end' [7]. Developers should be aware that 'close' does not guarantee the request was successful; it only signals that no further data will be emitted for that specific request [1][8]. For logic requiring confirmation of successful data receipt, ensure you are also handling the 'end' event and checking for error conditions, as 'close' may trigger regardless of whether the request finished normally or was interrupted [1][3].

Citations:


🌐 Web query:

Node.js http ServerResponse close event finished premature connection termination documentation

💡 Result:

In Node.js, the close event on an http.ServerResponse instance indicates that the response has either completed or that its underlying connection was terminated prematurely before the response could be finished [1][2]. Key technical details regarding this behavior include: Event Purpose and Distinction: The close event serves as a general indicator that the response object has reached its final state [3]. Because it is emitted in both successful completion and premature termination scenarios, it is distinct from the finish event [3]. The finish event specifically signals that the response has been fully flushed to the underlying system for transmission [3][4]. Detecting Premature Termination: To differentiate between a successful completion and a premature termination, you can check the status of the response object when the close event fires [5][3]. A reliable way to do this is by checking the finished property [5]: res.on('close', => { if (!res.finished) { // The connection was terminated prematurely // Perform cleanup or logging here } }); Lifecycle Nuances: 1. Finish vs. Close: The finish event records that Node.js has handed the final bytes of the response to the underlying system [3]. It does not guarantee that the client has received those bytes [3]. 2. Error Handling: Relying solely on the close event for error handling can be complex, as historical behavior and ongoing discussions in the Node.js project have highlighted that the response object does not always emit an error event upon premature closure [6][7]. Checking res.finished is the standard approach to identify these clean aborts [5][6]. Documentation Note: Historically, the documentation for the close event underwent clarifications to distinguish its behavior from the finish event, ensuring developers understand that close is emitted regardless of whether the response finished successfully or was cut short [8][9][5]. Always refer to the specific version of the official Node.js HTTP API documentation for your project, as minor behavioral adjustments regarding event emission and error handling are occasionally introduced [1][10][11][12].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant file section =="
awk '{printf "%6d\t%s\n", NR, $0}' webdriver/src/prj/src/index.ts | sed -n '428,480p'

echo "== disconnectSignal call sites =="
python3 - <<'PY'
from pathlib import Path
p=Path('webdriver/src/prj/src/index.ts')
text=p.read_text()
for i,line in enumerate(text.splitlines(),1):
    print(f"{i}: {line}") if 'disconnectSignal' in line or 'disconnectSignal(' in line else None
PY

echo "== queue/semaphore-related calls around queued requests =="
python3 - <<'PY'
from pathlib import Path
p=Path('webdriver/src/prj/src/index.ts')
for term in ['semaphore', 'permit', 'queue', 'AbortSignal', 'render', 'healthcheck', 'disconnectSignal']:
    print(f"\n--- {term} ---")
    text=p.read_text()
    lines=text.splitlines()
    for i,line in enumerate(lines,1):
        if term.lower() in line.lower():
            start=max(1,i-3); end=min(len(lines),i+3)
            for n in range(start,end+1):
                print(f"{n}: {lines[n-1]}")
            print()
PY

echo "== declared node version hints =="
python3 - <<'PY'
from pathlib import Path
for p in [Path('package.json'), Path('webdriver/src/prj/package.json'), Path('package-lock.json'), Path('webdriver/src/prj/package-lock.json'), Path('yarn.lock'), Path('pnpm-lock.yaml')]:
    if p.exists():
        text=p.read_text(errors='ignore')
        vals = []
        if p.name != 'package-lock.json' and p.name != 'webdriver/src/prj/package-lock.json' and p.name != 'pnpm-lock.yaml':
            vals += [t.strip() for t in text.split() if 'node' in t.lower() and ('version' in t.lower() or 'engines' in t.lower())]
        else:
            vals += [t.strip() for t in text.split() if 'node@' in t or 'engines:' in t or p.name == 'yarn.lock' and ('node' in t or p.name == 'package-lock.json')]
        print(f"{p}: {' '.join(vals[:50]) if vals else '<no node version hints>'}")
PY

Repository: genlayerlabs/genvm-manager

Length of output: 22919


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all visible node version strings =="
python3 - <<'PY'
from pathlib import Path
terms = ['.node-version', 'Dockerfile', 'renovate.json', 'mise.toml', '.tool-versions', '.github/workflows']
files = []
for p in Path('.').glob('**/*'):
    if not p.is_file() or '__pycache__' in p.parts or 'node_modules' in p.parts or '.git' in p.parts:
        continue
    if any(x in str(p) for x in terms) or p.name in ['package.json', 'package-lock.json']:
        files.append(p)
for p in files:
    text = p.read_text(errors='ignore')
    vals = [l.strip() for l in text.splitlines() if any(s in l.lower() for s in ['node', '18', 'lts'] ) and (s not in p.name or 'engines' in l.lower())][:3]
    if vals:
        print(f'--- {p} ---')
        print(vals)
PY

echo "== semaphore acquire implementation =="
fd -a 'semaphore(\.ts|\.js)$' . | head -20
while IFS= read -r f; do
  echo "--- $f ---"
  wc -l "$f"
  cat -n "$f" | sed -n '1,220p'
done < <(fd -a 'semaphore(\.ts|\.js)$' .)

echo "== exact response close writableEnded / finished docs behavior from Node v18 docs =="
python3 - <<'PY'
import urllib.request
url = "https://registry.npmjs.org/@types/node/-/node-18.20.0.tgz"
tarball = urllib.request.urlopen(url, timeout=20).read()
print(f"tarball_len={len(tarball)}")
import tarfile, io, re
tf = tarfile.open(fileobj=io.BytesIO(tarball), mode='r:gz')
members = [m.name for m in tf.getmembers() if 'http.d.ts' in m.name.lower()]
print("http.d.ts candidates:", members)
for m in members:
    text=tf.extractfile(m).read().decode('utf-8','replace')
    hits=[line for line in text.splitlines() if "close" in line and ("ServerResponse" in line or "IncomingMessage" in line) or "writableEnded" in line or "finished" in line]
    # Print around HTTP response interfaces
    lines=text.splitlines()
    for i,line in enumerate(lines[:2000],1):
        if "interface Server extends" in line or "interface ServerResponse" in line or "interface IncomingMessage" in line:
            print(f"\n-- {m}:{i} --")
            for j in range(max(1,i-120), min(len(lines),i+220)+1):
                if "close" in lines[j-1] or "writableEnded" in lines[j-1] or "finished" in lines[j-1]:
                    print(f"{j}: {lines[j-1]}")
PY

Repository: genlayerlabs/genvm-manager

Length of output: 353


🏁 Script executed:

#!/bin/bash
set -u

echo "== likely node version config files =="
git ls-files | grepE '^(Dockerfile|\.node-version|\.tool-versions|\.nvmrc|renovate\.json|mise\.toml|package\.json|\.github/workflows/)' | sort || true

echo "== node version references =="
python3 - <<'PY'
from pathlib import Path
terms = ['node', 'node-version', 'engines', 'lts']
for p in Path('.').glob('*'):
    if p.name in terms or p.name == 'package.json' or p.name == '.nvmrc':
        print(f'--- {p} ---')
        print(p.read_text(errors='ignore')[:3000])
PY

echo "== semaphore implementation files =="
fd -a 'semaphore(\.ts|\.js)$' . || true

while IFS= read -r f; do
  [ -z "$f" ] && continue
  echo "--- $f ---"
  wc -l "$f"
  cat -n "$f" | sed -n '1,240p'
done < <(fd -a 'semaphore(\.ts|\.js)$' .)

echo "== Node TypeScript typings v18 close/event signatures =="
python3 - <<'PY'
import urllib.request
url = "https://registry.npmjs.org/@types/node/-/node-18.20.0.tgz"
try:
    data = urllib.request.urlopen(url, timeout=20).read()
except Exception as e:
    print(f"FAIL downloading typings: {e}")
    raise SystemExit
print(f"tarball_len={len(data)}")
import tarfile, io
tf = tarfile.open(fileobj=io.BytesIO(data), mode='r:gz')
for m in tf.getmembers():
    if 'http.d.ts' in m.name.lower():
        text = tf.extractfile(m).read().decode('utf-8', 'replace')
        print(f"\n--- {m.name} ---")
        for i,l in enumerate(text.splitlines(),1):
            if 'IncomingMessage' in l or 'ServerResponse' in l or l.strip().startswith('listen('):
                start=max(1,i-80); end=min(len(text.splitlines()),i+100)
                print(f"\n---- around line {i} ----")
                for j in range(start,end+1):
                    if 'close' in text.splitlines()[j-1] or 'writableEnded' in text.splitlines()[j-1] or 'finished' in text.splitlines()[j-1]:
                        print(f"{j}: {text.splitlines()[j-1]}")
PY

Repository: genlayerlabs/genvm-manager

Length of output: 634


Use the response’s close event for premature disconnects.

IncomingMessage emits close in Node 18+ when the request is deemed complete, so a normal queued request can cancel renderPage while res.writableEnded is still false. Listen on res.once('close') and keep the existing !res.writableEnded guard, then update both handleRenderRequest and handleHealthcheck callers to satisfy the handler with only res.

🤖 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 454 - 464, The disconnect
detection in disconnectSignal should listen once to res.close instead of
req.close while preserving the !res.writableEnded guard. Update
handleRenderRequest and handleHealthcheck to call disconnectSignal with only
res, and adjust its signature accordingly.

How loaded a host happens to be is not a property of the page, so it must
not reach the contract. Reporting saturation as a render result let two
hosts under different load answer the same request differently, with both
answers looking legitimate; a transport failure instead says plainly that
this host could not run it.

The page-size and heap limits keep reporting as render results, since any
host with the same configuration reaches the same verdict on them. That
does mean those two must be configured identically across hosts.
@MuncleUscles

Copy link
Copy Markdown
Member Author

Pushed a correction that reverses a decision I defended earlier in this PR.

Prompted by #21 and Kira's question on it: I had routed both new failure classes through Resulting-Status, making both non-fatal. They are not the same kind of thing.

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

Saturation is host-local. Making it contract-visible means a busy host and an idle host return different answers for the same request, and both look legitimate — silent divergence, strictly worse than an honest "this host could not run it". The Lua's existing error_on_status = true turns a real 503 into a fatal error with no config change needed.

The irony is that CodeRabbit pushed me toward a bounded queue, I defended the non-fatal channel as "the part worth preserving", and that was the part that was wrong. The bounded queue was right; the channel wasn't.

SemaphoreAborted now returns without writing, since the caller has already hung up. Healthcheck reports unhealthy on saturation, which is correct — the probe only renders when nothing has succeeded for the cache duration, so saturated and nothing completed in that window genuinely is not serving.

Verified against real Chrome under two configs, since queueing and shedding need opposite settings:

### saturating (permits=1, queue=1, timeout=500ms)
PASS  small page renders 200/200
PASS  oversized page -> HTTP 200 + Resulting-Status 507
PASS  oversized payload withheld, limit reported
PASS  saturation sheds with real HTTP 503
PASS  shed responses carry no Resulting-Status

### permissive (permits=2, default queue)
PASS  all queued renders succeed
PASS  queueing observed (6 renders / 2 permits took 7944ms, expected >=3600)

17/17 unit tests and tsc --noEmit still clean.

Follow-up worth its own issue: GVM_WEBDRIVER_MAX_RESPONSE_MB and GVM_WEBDRIVER_MAX_PAGE_HEAP_MB are per-host env vars, but they are now explicitly contract-visible. Two hosts configured differently disagree about whether a page rendered — leader renders it, validator rejects it. Any contract-visible limit needs to be protocol-uniform, not per-host config. Same class as storageUnitPrice.

`newPage` ran before the try whose finally disposes of the context, so a
failure there stranded the context for the shared browser's whole lifetime.
Page creation fails precisely when the browser is under pressure, which
made the response to pressure be to leak more of it.
The test is not "the page is big" but "the page is bigger than this host's
limit", and that threshold is local configuration. Reporting it as a render
result let hosts with different settings answer the same request
differently, each answer looking legitimate, and handed anyone who knew the
spread a way to craft a page landing in the gap between them.

This also corrects the pre-existing heap limit, which reported the same way.
Both can become contract-visible once their values are agreed protocol
constants rather than per-host settings.
@MuncleUscles

Copy link
Copy Markdown
Member Author

Correcting the previous comment — the split I described was still wrong, in the same way.

I claimed the size caps are "properties of the page, so any host with the same configuration reaches the same verdict". The flaw is that the test isn't "the page is big", it's "the page is bigger than this host's limit" — and that threshold is local configuration. The qualifier "with the same configuration" was carrying the whole argument while nothing enforces it.

That makes it an attack, not just a hazard: someone who knows the config spread across validators crafts a page sized into the gap and gets them to return different results, each individually legitimate.

It also contradicts the standard applied to storage, where the page cap is a compile-time top_limits constant specifically because host-supplied values were the failure mode.

Everything now leaves through the transport channel:

Condition Channel Contract
Genuine page/network failure (404, DNS, TLS, timeout) HTTP 200 + Resulting-Status sees it — a real fact about the web
Response over cap HTTP 507 fatal
Page heap over cap HTTP 507 fatal
Queue full / timeout HTTP 503 fatal
Sidecar internal failure HTTP 500 fatal

The only thing that stays contract-visible is what the remote site did, which is the one category that is genuinely an observation about the world.

This corrects pre-existing behaviour, not just my additions. The heap limit already reported as Resulting-Status: 507 → non-fatal WEBPAGE_LOAD_FAILED. That's now fatal. Worth a deliberate look: a contract that today catches a heap-limit failure will instead see its execution abort. I think that's correct — it was relying on host-local behaviour — but it is a real change and reviewers should agree to it rather than inherit it.

The actual fix, for later: make both values agreed protocol constants instead of per-host env vars, at which point they can legitimately become contract-visible. Until then, fatal is the safe failure mode. Note the residual — a config mismatch still causes divergence, just as timeout-vs-result rather than result-vs-result. Much weaker, not nothing. Uniformity is what actually closes it.

Verified: oversized page now returns a real HTTP 507 with no Resulting-Status; queueing, shedding and the render path all unchanged. 17/17 unit tests, typecheck clean.

@kp2pml30

kp2pml30 commented Aug 11, 2026

Copy link
Copy Markdown
Member

I am going to integrate it into #24 (1fc7576). I can not merge it here because:

  • in my opinion error for a page that is too big should not be an internal error (discussed in DM)
  • there is no integration / jsonnet test for crossing that limit (added one in the PR)
  • ts tests are not wired anywhere and are not executed, I am afraid

For this reason, I am closing it in favor of that (well, consider it merging). Thanks for improving the webdriver!

@kp2pml30 kp2pml30 closed this Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants