yrby-actioncable 0.3.0: drop unhealable gaps — verified on ActionCable AND AnyCable - #38
Merged
Conversation
jpcamara
force-pushed
the
feat/gap-strike-defense
branch
from
July 2, 2026 01:40
4d6257c to
2b9bb7e
Compare
Owner
Author
Re-worked after evaluation — now verified on BOTH transportsThe original design had four real problems, all fixed on this branch (now stacked on #39, which should merge first):
End-to-end, both stacks (
Unit: 107 Ruby (incl. a faithful AnyCable simulation — fresh instance per message + JSON istate round-trip), 54 client, rubocop/tsc clean. |
jpcamara
force-pushed
the
feat/gap-strike-defense
branch
3 times, most recently
from
July 2, 2026 02:29
fc1206b to
0ebecb3
Compare
…d AnyCable
A causally-incomplete update triggers a resync so the gap heals as one
complete delta. But a permanently-orphaned update (its missing dependency is
gone for good) stays gappy through every resync, and a client retransmitting
it loops endlessly. After `gap_strike_limit` rejections of the same update on
one connection (default 3, minimum 2 -- ArgumentError below), the channel
settles it with { "ack" => id, "dropped" => true } and drops it.
Transport support:
- Plain ActionCable reuses the channel instance across a connection's
messages: strikes live on the instance, mutex-guarded (ActionCable
dispatches to a worker pool, so two receives on one instance can race).
- AnyCable creates a FRESH instance per RPC command, so the table is
persisted via anycable-rails' state_attr_accessor (istate, round-tripped
through anycable-go), declared automatically at include time when
anycable-rails is loaded. Without anycable-rails: ivar fallback.
Correctness hardening from the source review:
- The settle ack carries "dropped" so clients can tell durably-recorded from
abandoned; yrby-client prunes the queue (retransmitting an unhealable
update would loop forever) and surfaces it via onError("ack-dropped")
instead of silently reporting synced over lost data.
- Strike-table eviction: at capacity a single lowest-count entry is evicted,
only when inserting a NEW key -- a client cycling >64 distinct gaps can no
longer wipe the table and reset a tracked key's count (defense bypass),
and an existing key's count is never disturbed (starvation).
- A gap that finally records frees its strike slot.
- gap_strike_limit below 2 raises: strike 1 must send a resync (the heal
attempt) before any drop can be justified.
Tests: strike-out drop + ack; nil disables; distinct gaps tracked separately;
dropped flag on settle only; eviction can't reset tracked strikes (72-update
GapFlood fixture); heal frees the slot; limit validation; AnyCable simulation
(fresh instance per message + JSON istate round-trip, faithful to
anycable-rails' state_attr_accessor) for both strike-out and heal-clears.
Client: dropped ack prunes + surfaces; plain ack stays silent.
Verified end-to-end on BOTH stacks with a new demo e2e
(frontend/gap_strike.mjs): a poisoned client's update is resynced twice, then
settled with dropped:true and never recorded; a healable gap still records
with a plain ack. 9/9 on Puma ActionCable; 9/9 on AnyCable (anycable-go +
RPC), proving istate strike persistence across fresh channel instances.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jpcamara
force-pushed
the
feat/gap-strike-defense
branch
from
July 2, 2026 02:49
0ebecb3 to
af0fa64
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Deliverable 2 of the gap-free-sync plan — the delivery-layer defense against orphaned-pending retry loops. (Deliverable 1, gap-free serving in yrby 0.3.0, stops the server-side poison; this stops a client poisoned by any route from looping.)
The loop it breaks
A causally-incomplete update triggers a resync so the gap heals as one complete delta. But a permanently-orphaned update — its missing dependency is gone for good — stays gappy through every resync. A client retransmitting it loops endlessly:
That's the "id-less frames several times a second" symptom.
The fix
After
gap_strike_limitrejections of the same update on one connection (default 3), the channel settles it with an ack (new:dropped_unhealableoutcome) and drops it, instead of resyncing again:GAP_STRIKE_MAX_KEYS) so endless distinct gaps can't grow it.gap_strike_limit(class macro, mirrorsmax_frame_bytes); setnilto restore resync-every-time.Honest limitation (documented)
Strike state lives on the channel instance. On plain ActionCable the instance is reused across a connection's messages, so strikes accumulate and the drop works. Under AnyCable each command gets a fresh instance, so the count resets per message and the drop never trips — it degrades to the prior resync-every-time behavior (no regression). A durable/Redis-backed counter could extend it to AnyCable if a real poisoned-client case appears; JP's actual incident was server-store-only, already covered by Deliverable 1.
Tests
gap_strike_limit nilalways resyncs (never acks/drops)Bumps yrby-actioncable 0.2.3 → 0.3.0.
🤖 Generated with Claude Code