Skip to content

fix(browser): a contract that held but never went quiet fell through as a clean stop — the deadline is not a stop (v1.31.1) - #191

Merged
harper-joseph merged 1 commit into
mainfrom
fix/readiness-timeout-exit
Sep 21, 2026
Merged

harper-joseph merged 1 commit into
mainfrom
fix/readiness-timeout-exit

Conversation

@harper-joseph

Copy link
Copy Markdown
Contributor

Pre-deploy review of the readiness contracts as they stand on main (browser 1.31.0, armed downstream in render-service kohls v1.25.0-kohls). One defect in the armed path, reproduced on a live page, fixed here with a regression test. Browser v1.31.1; no plugin change.

The defect

awaitContract stops a render on "every clause holds and the page has been quiet for quietMs" — the second half is what protects the rails and everything else no clause names, and the design forbids stopping on the contract alone (measured: 99% of a product page's links lost).

But the loop had one exit that dropped the second half. It only breaks early on held && quiet; when the deadline passed with every clause true and the page still mutating past tolerance, it fell out of the loop, computed satisfied from held alone, and the caller used that as contractStopped. The render then skipped the quiet window, the fallback settle, both waitFor gates and finalDomStable, and snapshotted a page that was still changing.

Reproduced with the render-service harness on a live product page, desktop, quietMs: 60000 (unreachable) and product timeoutMs: 1500:

run settle waitForResults
1 2000 ms empty — no gate ran
2 1953 ms empty — no gate ran

Settle is exactly the timeout plus the scroll pass. On a healthy idle laptop the content happened to be complete by then; on a page that keeps drifting under load, or whose monitor history truncates and answers -1, it would not be.

The fix

awaitContract returns two verdicts, because they are two different things:

  • satisfied — every clause held. This is what the report carries and what the plugin's render_readiness verdict is built from. Unchanged.
  • stopped — the loop left on the held-and-quiet exit. This is what the settle acts on. Set only there; the deadline, the rot valve and a page that went away all fall through to the ordinary settle, exactly as an unsatisfied contract does.

ReadinessResult gains stopped (in-process). The wire shape is unchanged — a satisfied-but-never-quiet render reports satisfied: true with waitedMs ≈ timeoutMs, which is honest but not yet a distinct series; surfacing "held but fell back" as its own metric is a plugin follow-up, noted rather than half-done here.

Test

test/readiness.test.ts: a fixture that satisfies its contract at once and then appends 20 elements every 40 ms for as long as the page lives, with a waitFor rule as the witness that the fallback settle ran. Asserts the gate ran, satisfied is true, stopped is false, and the contract waited out its timeout.

before the fix:  ✖ a contract that holds but never sees the page go quiet falls back to the ordinary settle
after the fix:   ✔ (1271 ms)

Browser suite 295/295, root lint and format:check clean. Version reservations checked: open #178 and #188 sit at 1.29.0, main is 1.31.0, so 1.31.1 is free.

Deploy consequence

This is the browser the fleet should arm on. render-service kohls pins 1.31.0 with onSatisfied: 'quiet' and has not been deployed yet; the companion config change is HarperFast/render-service (reveal-state clause), and its dep bump follows this release. Read off the four prod nodes on 2026-09-21: the plugin is still 0.72.0 everywhere (no render_readiness, nothing staged), so the readiness metric records nothing until kohls-pr's 0.80.0 train is deployed — arm after that, or the fleet's satisfied_ms distribution is invisible.

🤖 Generated with Claude Code

…as a clean stop — the deadline is not a stop; v1.31.1

`awaitContract` only left its loop early on "held AND quiet", but on the deadline exit it computed
`satisfied` from "held" alone, and the caller used that as `contractStopped`. A page whose clauses
all held while it kept mutating past tolerance for the whole timeout therefore skipped the quiet
window, the fallback settle, every `waitFor` gate and the final plateau — the stop-on-contract-alone
behaviour the design forbids, measured elsewhere to lose 99% of a product page's links.

Reproduced on a live product page with `quietMs: 60000` and `timeoutMs: 1500`: settle ended at
2000 / 1953 ms and `waitForResults` was empty on both runs.

`awaitContract` now returns `{ satisfied, stopped }`. `satisfied` is what the report carries (every
clause held); `stopped` is set only on the held-and-quiet exit and is what the settle acts on.
`ReadinessResult` gains `stopped`; the wire shape is unchanged. Regression test: a page that
satisfies its contract at once and never stops changing, with a `waitFor` gate as the witness that
the fallback ran — fails on 1.31.0, passes here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request updates the headless-browser render library to distinguish between a readiness contract being 'satisfied' (all clauses held) and 'stopped' (all clauses held and the page went quiet). When a contract is satisfied but the page never goes quiet within its timeout, the render now correctly falls back to the ordinary settle instead of stopping prematurely. These changes include updates to the ReadinessResult type, the awaitContract function, documentation, and a new integration test simulating a churning page. There are no review comments to address, and I have no additional feedback to provide.

@harper-joseph
harper-joseph merged commit 3274b08 into main Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant