Skip to content

fix: batch component refreshes on reconnect - #25

Merged
cardmagic merged 2 commits into
mainfrom
agent/browser-reconnect-coverage
Aug 10, 2026
Merged

fix: batch component refreshes on reconnect#25
cardmagic merged 2 commits into
mainfrom
agent/browser-reconnect-coverage

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

Adds the browser reconnect coverage the roadmap called for, and fixes a defect that writing it turned up.

The defect

ComponentSubscriptions#refreshes_for partitions changed registrations by batch and emits one solid-objects-batch-refresh per batch. #reconnect_refreshes did neither: it mapped every stale registration through refresh individually, so a reconnecting client ignored the batches its own components declared.

A page with twenty batched components issued twenty requests on reconnect where a live invalidation issues one.

The timing is what makes it matter. Reconnects cluster. A server restart or a network blip reconnects every client at once, so the amplification arrives as a burst at exactly the moment the app is least able to absorb it.

Both paths now share a private refresh_streams, so a reconnecting client pays what a connected one pays.

Coverage

Five Ruby tests in component_batch_test.rb for the reconnect path: batching, mixed batched/unbatched, distinct batches, revision recording for every batched registration, and no refresh for components already current. Three failed against the old code.

test/browser/reconnect.test.mjs covers the reconnect burst in real Chromium against a real Turbo build:

  • convergence of batched and unbatched components
  • a replay of an already-applied revision leaves the frame untouched, asserted with a marker node a morph would have removed
  • the reconnect cancels the request left in flight by the drop, asserted server-side by observing the socket close before the response is written
  • incarnation ordering after a destroy and recreate, where 2:1 must win over 1:12
  • payload delivery exactly once per revision, and a stale payload replayed after a newer one is dropped

Each was verified by mutating the module and confirming the test fails:

Mutation Test that failed
remove applyFrame revision guards replaying a revision already applied
supersedeOlderRequests returns early reconnect cancels the in-flight request
compare state revision, ignore incarnation destroy and recreate applies the new incarnation
remove the payload revision guard both payload tests

That last check is the point of the exercise. Every batching defect that reached production passed a test suite; passing is not evidence a test can fail.

Validation

bundle exec rake            # 338 runs, 1198 assertions, 0 failures, 0 errors, 13 skips
npm test                    # 26 pass
npm run test:browser        # 13 pass

docs/roadmap.md moves reconnect coverage out of the milestone, records that reconnect bypassed batching, and milestone 3 reduces to Turbo append intents. docs/realtime.md documents that reconnect refreshes a batch together.

@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown

Greptile Summary

The PR makes reconnect refreshes use the same batching path as live invalidations and adds browser-level reconnect coverage.

  • Extracts shared refresh-stream generation for batched and individual component registrations.
  • Verifies reconnect convergence, cancellation, revision ordering, and payload deduplication in Chromium.
  • Documents reconnect batching behavior and updates the roadmap and changelog.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
lib/solid_objects/component_subscriptions.rb Reuses a shared refresh-stream builder so reconnect and live invalidation paths apply identical batching and revision recording.
test/browser/reconnect.test.mjs Adds end-to-end reconnect coverage and centrally drains pending request gates after each test.
test/browser/browser_test_helper.mjs Adds reusable page loading that waits for all tested custom elements to be registered.
test/integration/component_batch_test.rb Covers reconnect batching, mixed registrations, distinct batches, revision recording, and current-component filtering.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Reconnect snapshot] --> B[Select stale registrations]
  B --> C[refresh_streams]
  C --> D{Registration has batch?}
  D -->|No| E[Emit individual component refresh]
  D -->|Yes| F[Group by batch]
  F --> G[Record each registration revision]
  G --> H[Emit one batch refresh per group]
Loading

Reviews (2): Last reviewed commit: "test: release every gated response on te..." | Re-trigger Greptile

Comment on lines +218 to +227
await waitFor(
() => cancelled.has("before-drop"),
"the reconnect should cancel the request from before the drop"
)
await page.waitForFunction(
() => document.getElementById("player").dataset.solidObjectsRevision === "1:12",
null,
{ timeout: 5000 }
)
stalled.release()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Gate lacks failure cleanup

When a preceding wait or assertion fails, execution skips the sole stalled.release() call, leaving the route blocked on its pending response and allowing browser-suite teardown to hang until timeout.

Suggested change
await waitFor(
() => cancelled.has("before-drop"),
"the reconnect should cancel the request from before the drop"
)
await page.waitForFunction(
() => document.getElementById("player").dataset.solidObjectsRevision === "1:12",
null,
{ timeout: 5000 }
)
stalled.release()
try {
await waitFor(
() => cancelled.has("before-drop"),
"the reconnect should cancel the request from before the drop"
)
await page.waitForFunction(
() => document.getElementById("player").dataset.solidObjectsRevision === "1:12",
null,
{ timeout: 5000 }
)
} finally {
stalled.release()
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/browser/reconnect.test.mjs
Line: 218-227

Comment:
**Gate lacks failure cleanup**

When a preceding wait or assertion fails, execution skips the sole `stalled.release()` call, leaving the route blocked on its pending response and allowing browser-suite teardown to hang until timeout.

```suggestion
  try {
    await waitFor(
      () => cancelled.has("before-drop"),
      "the reconnect should cancel the request from before the drop"
    )
    await page.waitForFunction(
      () => document.getElementById("player").dataset.solidObjectsRevision === "1:12",
      null,
      { timeout: 5000 }
    )
  } finally {
    stalled.release()
  }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Applied in 3a2c41b, but more generally than the suggestion. A try/finally fixes the one test that exists; the next gated test has to remember. gate() now registers its release and an afterEach hook drains them, so a test cannot reintroduce this by forgetting.

One correction to the finding: the hang does not reproduce. I forced the cancellation assertion to fail and removed the cleanup, and the suite reported the failure and exited in seven seconds rather than hanging. after closes the browser first, which destroys the client socket; the suspended route function then holds no live handle, so nothing keeps the runner alive. The cleanup is still worth having, and it is in.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Mapping the reconnect path for browser coverage turned up a defect. A
reconnecting subscription refreshed every stale component individually
and ignored the batches those components declared: refreshes_for
partitioned by batch and grouped, reconnect_refreshes did not. A page
with twenty batched components therefore issued twenty requests where a
live invalidation issues one.

That is the worst possible moment for request amplification. Reconnects
cluster: a server restart or a network blip reconnects every client at
once, so the amplification lands as a burst rather than spread over time.

Both paths now share refresh_streams, so a reconnecting client pays what
a connected one pays.

The browser suite gains the reconnect burst it was missing: convergence
of batched and unbatched components, an inert replay of an
already-applied revision, cancellation of the request left in flight by
the drop, incarnation ordering after a destroy and recreate, and payload
delivery exactly once per revision. Each was verified to fail against a
mutated module rather than only to pass against the current one.
A gate the test never releases, because an assertion failed before it got
there, leaves its route awaiting forever. Gates now register themselves
and an afterEach hook drains them, so this cannot be reintroduced by a
future test that forgets.

The reported hang does not reproduce: closing the browser destroys the
client socket, so the suspended route holds no live handle and the runner
exits. Forcing the assertion to fail reported the failure and exited in
seven seconds. The cleanup is worth having regardless.
@cardmagic
cardmagic force-pushed the agent/browser-reconnect-coverage branch from 3a2c41b to f393121 Compare August 10, 2026 17:09
@cardmagic
cardmagic merged commit cceca90 into main Aug 10, 2026
26 checks passed
@cardmagic
cardmagic deleted the agent/browser-reconnect-coverage branch August 10, 2026 17:11
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