Skip to content

Commit cceca90

Browse files
authored
Merge pull request #25 from cardmagic/agent/browser-reconnect-coverage
fix: batch component refreshes on reconnect
2 parents b4938a8 + f393121 commit cceca90

8 files changed

Lines changed: 467 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@
99
Retention runs on its own thread, so a slow pass cannot delay replacing a
1010
crashed role, and a failed pass retries at monitor cadence with a doubling
1111
backoff rather than deferring for the whole interval.
12+
- Batch component refreshes on reconnect. A reconnecting subscription refreshed
13+
every stale component individually, ignoring the batches those components
14+
declared, so a page with twenty batched components issued twenty requests
15+
instead of one. That happens at the worst moment: a server restart reconnects
16+
every client at once. Reconnect now shares the batching the live invalidation
17+
path uses.
18+
- Cover the reconnect burst in the browser suite: convergence of batched and
19+
unbatched components, an inert replay of an already-applied revision,
20+
cancellation of the request left in flight by the drop, incarnation ordering
21+
after a destroy and recreate, and payload delivery exactly once per revision.
1222
- Add Ruby 4.0 to the compatibility matrix, which now covers Ruby 3.3, 3.4, and
1323
4.0 against Rails 8.0 and 8.1.
1424

docs/realtime.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,11 @@ ordered commits within one incarnation. Out-of-order invalidations at or below
393393
the last transmitted pair are ignored. The durable state row remains source of
394394
truth.
395395

396+
Stale components that share a `batch:` are refreshed together, exactly as a
397+
live invalidation refreshes them, so reconnecting costs one request per batch
398+
rather than one per component. That matters most on a restart, when every
399+
client reconnects at once.
400+
396401
The component endpoint rejects a requested revision newer than the committed
397402
snapshot. This is a final server-side guard; browser safety primarily comes
398403
from monotonic channel filtering plus replace-frame detachment or morph

docs/roadmap.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@
5252
- A JavaScript suite covering every browser module, run in CI with Node's test
5353
runner and jsdom, plus a browser suite running the same modules against real
5454
Chromium and a real Turbo build, with every GitHub Actions reference pinned to
55-
a commit SHA
55+
a commit SHA. The browser suite covers the reconnect burst: convergence of
56+
batched and unbatched components, an inert replay of an applied revision,
57+
cancellation of the request left in flight by the drop, incarnation ordering
58+
after a destroy and recreate, and payload delivery exactly once per revision
5659

5760
## Partially implemented
5861

@@ -73,7 +76,10 @@
7376
implemented; application-directed Turbo append intents are not. Batch
7477
coalescing happens in the browser rather than the broadcast executor, so one
7578
commit still sends one Action Cable message per changed observable even
76-
though it costs one browser request.
79+
though it costs one browser request. Reconnect convergence previously
80+
bypassed batching entirely, issuing one request per stale component at the
81+
moment a restart reconnects every client at once; it now shares the batching
82+
the live invalidation path uses.
7783
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
7884
distributed per-actor rate limits and global admission control do not.
7985
- Administration: actor and dead-letter views plus policy hooks exist; richer
@@ -84,7 +90,7 @@
8490
## Next milestones
8591

8692
1. Add result lookup by request ID and broader deadlock retry classification.
87-
2. Add Turbo append intents and expand reconnect coverage in a full browser.
93+
2. Add Turbo append intents.
8894
3. Add distributed rate limits, global admission hooks, and cache-capacity
8995
eviction.
9096
4. Expand security scanning beyond the Brakeman scan, such as dependency

lib/solid_objects/component_subscriptions.rb

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,26 +52,19 @@ def refreshes_for(invalidation)
5252
registration.dependencies.include?(observable_name) &&
5353
newer_revision?(registration.dom_id, instance_id, revision)
5454
end
55-
batched, individual = changed.partition(&:batch)
56-
streams = individual.map { |registration| refresh(registration, instance_id, revision) }
57-
batched.group_by(&:batch).each_value do |group|
58-
group.each { |registration| record_revision(registration, instance_id, revision) }
59-
streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
60-
end
61-
streams
55+
refresh_streams(changed, instance_id, revision)
6256
end
6357

6458
# @rbs (ActorSnapshot) -> Array[String]
6559
def reconnect_refreshes(snapshot)
66-
registrations.filter_map do |registration|
67-
next unless newer_revision?(
60+
stale = registrations.select do |registration|
61+
newer_revision?(
6862
registration.dom_id,
6963
snapshot.instance_id,
7064
snapshot.revision
7165
)
72-
73-
refresh(registration, snapshot.instance_id, snapshot.revision)
7466
end
67+
refresh_streams(stale, snapshot.instance_id, snapshot.revision)
7568
end
7669

7770
class << self
@@ -91,6 +84,21 @@ def validate_identity!(registration, reference)
9184

9285
attr_reader :registrations, :revisions
9386

87+
# Live invalidations and reconnect replays share this, so a reconnecting
88+
# client pays the same number of requests a connected one does.
89+
# @rbs (Array[ComponentRegistration], Integer, Integer) -> Array[String]
90+
def refresh_streams(changed, instance_id, revision)
91+
batched, individual = changed.partition(&:batch)
92+
streams = individual.map do |registration|
93+
refresh(registration, instance_id, revision)
94+
end
95+
batched.group_by(&:batch).each_value do |group|
96+
group.each { |registration| record_revision(registration, instance_id, revision) }
97+
streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
98+
end
99+
streams
100+
end
101+
94102
# @rbs (ComponentRegistration, Integer, Integer) -> void
95103
def record_revision(registration, instance_id, revision)
96104
revisions[registration.dom_id] = [ instance_id, revision ]

sig/generated/lib/solid_objects/component_subscriptions.rbs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ module SolidObjects
3131

3232
attr_reader revisions: untyped
3333

34+
# Live invalidations and reconnect replays share this, so a reconnecting
35+
# client pays the same number of requests a connected one does.
36+
# @rbs (Array[ComponentRegistration], Integer, Integer) -> Array[String]
37+
def refresh_streams: (Array[ComponentRegistration], Integer, Integer) -> Array[String]
38+
3439
# @rbs (ComponentRegistration, Integer, Integer) -> void
3540
def record_revision: (ComponentRegistration, Integer, Integer) -> void
3641

test/browser/browser_test_helper.mjs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,20 @@ export async function openPage(origin) {
6464
const browser = await chromium.launch()
6565
const context = await browser.newContext()
6666
const page = await context.newPage()
67-
await page.goto(`${origin}/`)
68-
await page.waitForFunction(() => Boolean(customElements.get("solid-objects-refresh")))
67+
await loadPage(page, origin)
6968
return { browser, page }
7069
}
70+
71+
// The browser modules keep applied revisions in module scope, so a test that
72+
// needs to start from a lower revision than the previous one left behind has to
73+
// reload rather than only reset the DOM.
74+
export async function loadPage(page, origin) {
75+
await page.goto(`${origin}/`)
76+
await page.waitForFunction(() =>
77+
Boolean(
78+
customElements.get("solid-objects-refresh") &&
79+
customElements.get("solid-objects-batch-refresh") &&
80+
customElements.get("solid-objects-payload")
81+
)
82+
)
83+
}

0 commit comments

Comments
 (0)