Skip to content

fix(protocol): keep subscriptions receiving deltas when a publish rings before registration - #334

Merged
jsam merged 3 commits into
mainfrom
fm/fix-sub-lost-wakeup
Oct 5, 2026
Merged

jsam merged 3 commits into
mainfrom
fm/fix-sub-lost-wakeup

Conversation

@jsam

@jsam jsam commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Intent

I want you to really hyper focus on making the symbolic engine, you know, crisp and really truly production ready. (Context: the adversarial audit, ~/firstmate/data/engine-prod-audit/report.md, found a P0 (code-confirmed): a subscription can silently stop receiving deltas forever: a publish that rings its doorbell before finish_subscribe registers the subscriber is dropped without answer() (src/protocol/subscription/connection.rs:251, publication.rs:104), so 'queued' stays true and no later publish is delivered.)

What Changed

  • Added Subscriber::resume(), which answers the subscriber's doorbell and rings it again if the view published after the subscriber's starting point. Before this, a wake-up rung ahead of registration was dropped as stale without an answer. The doorbell stayed marked as queued, so it ignored every later ring and the subscription stopped getting deltas for good.
  • finish_subscribe in connection.rs now builds the subscriber and calls resume() before adding it to the connection's subscriber map. The comment on the stale wake-up path now explains that registering resumes the doorbell.
  • Added unit tests for resume(), covering a wake-up rung before registration and the case with nothing new. Also added connection tests: a ring before registration for both a new view and a shared view, a registration with nothing new that queues no wake-up, and subscriptions registered during a write burst that converge.
  • No end-to-end test for this. The case "a subscription made during a write burst gets every delta" belongs in the new scenario suite being designed. An integration test for it was removed because it passed even without the fix. The regression guard is the deterministic connection-level unit tests, which fail before the fix.

Risk Assessment

✅ Low: The fix is small and fully closes the P0 lost-wakeup sequence. It resets the doorbell's queued flag, then re-rings if the cell moved past the attachment's publication. The calls are ordered answer, then load, then ring, so a publish either is observed or rings an answered doorbell. finish_subscribe and deliver run on the same &mut connection task, so the re-ring is only consumed after registration. advance() keeps the publication number, so it never causes a spurious ring. Construction in finish_subscribe is the only Subscriber registration path. The regression tests exercise the real behavior rather than reading source.

Testing

Ran the targeted subscription unit tests with and without the fix. The three new connection-level regression tests fail when resume() is disabled and pass with it. Then I started the real inputlayer-server in an isolated temp directory and drove it over /ws with a Node client. In 5 stress runs, 80 subscriptions opened during a 400-write burst all ended at the exact current result, with no seq gaps and the final write delivered to every one. An idle subscription got no spurious pushes, and the next write gave exactly one delta with seq 1. Two early driver bugs (stale rows left in the relation by an earlier run; counting non-subscription notifications) were fixed and the checks re-run. The server and temp data were torn down. The live stress run can't by itself prove the race was hit; the deterministic unit tests are what reproduce the bug.

  • Live validation: ✅ go - 2 of 4 scenarios driven live against the product
Scenario Result Live Evidence
A subscription whose wake-up is rung and dropped before it registers still receives that delta and every later one ⏸️ untested no The prior payload did not establish a live result for this exact ordering: no client can force a wake-up to be rung and dropped before registration over /ws on demand. It was covered only by determini…
Agents subscribing over /ws while another connection streams writes all end at the exact current result, with no seq gaps ✅ pass live live-ws-write-burst.txt: 5 runs × 80 subscriptions, 0 stalled, 0 seq gaps, final write delivered to every subscription
A newly registered subscription with nothing new gets no spurious delta, and the next write gives exactly one delta with seq 1 ✅ pass live live-ws-idle-subscription.txt; unit tests a_registration_with_nothing_new_queues_no_wake_up and resuming_with_nothing_new_queues_no_wake_up
The non-reproducing integration test is removed and the standing-query tests match base, per the round-1 decision ⏸️ untested no This is a repository-state check with no runtime surface, so the prior payload did not establish a live result. The non-live check git diff fe9ffe2a HEAD --stat -- tests came back empty.
Evidence: Regression tests fail with the fix disabled
test protocol::subscription::connection::tests::a_wake_up_before_registration_does_not_silence_a_new_view ... FAILED
test protocol::subscription::connection::tests::a_wake_up_before_registration_does_not_silence_a_shared_view ... FAILED
test protocol::subscription::connection::tests::subscriptions_registered_during_a_write_burst_converge ... FAILED
thread 'protocol::subscription::connection::tests::a_wake_up_before_registration_does_not_silence_a_new_view' (226940) panicked at src/protocol/subscription/connection/tests.rs:67:6:
thread 'protocol::subscription::connection::tests::a_wake_up_before_registration_does_not_silence_a_shared_view' (226941) panicked at src/protocol/subscription/connection/tests.rs:67:6:
thread 'protocol::subscription::connection::tests::subscriptions_registered_during_a_write_burst_converge' (226959) panicked at src/protocol/subscription/connection/tests.rs:522:9:
test result: FAILED. 10 passed; 3 failed; 0 ignored; 0 measured; 3135 filtered out; finished in 12.75s
Evidence: Regression tests pass with the fix
test protocol::subscription::connection::tests::a_snapshot_is_withheld_when_access_is_lost_while_it_opens ... ok
test protocol::subscription::connection::tests::reset_spares_a_newer_registration ... ok
test protocol::subscription::connection::tests::a_commit_between_snapshot_and_registration_is_delivered ... ok
test protocol::subscription::connection::tests::a_snapshot_includes_a_write_acknowledged_before_subscribing ... ok
test protocol::subscription::connection::tests::a_wake_up_before_registration_does_not_silence_a_new_view ... ok
test protocol::subscription::connection::tests::a_wake_up_before_registration_does_not_silence_a_shared_view ... ok
test protocol::subscription::connection::tests::an_unrelated_write_lets_a_new_subscriber_join_at_the_current_revision ... ok
test protocol::subscription::connection::tests::identical_subscriptions_share_one_evaluation_per_commit ... ok
test protocol::subscription::connection::tests::a_snapshot_at_the_current_revision_needs_no_second_evaluation ... ok
test protocol::subscription::connection::tests::a_registration_with_nothing_new_queues_no_wake_up ... ok
test protocol::subscription::connection::tests::unsubscribing_the_last_subscriber_drops_the_view_and_a_reused_name_starts_fresh ... ok
test protocol::subscription::connection::tests::subscriptions_registered_during_a_write_burst_converge ... ok
test protocol::subscription::connection::tests::distinct_subscriptions_are_evaluated_separately ... ok
test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 3135 filtered out; finished in 0.52s
Evidence: Live /ws write-burst subscription convergence (5 runs)
== run 1 relation burst1: 4 connections x 20 .subscribe pipelined behind a query, during a 400-write burst, then one final write
{
 "writes_during_subscribe": 400,
 "final_write": 1400,
 "subscriptions": 80,
 "expected_rows": 402,
 "total_deltas": 12591,
 "seq_gaps": 0,
 "stalled": []
}
exit=0
== run 2 relation burst2: 4 connections x 20 .subscribe pipelined behind a query, during a 400-write burst, then one final write
{
 "writes_during_subscribe": 400,
 "final_write": 1400,
 "subscriptions": 80,
 "expected_rows": 402,
 "total_deltas": 12988,
 "seq_gaps": 0,
 "stalled": []
}
exit=0
== run 3 relation burst3: 4 connections x 20 .subscribe pipelined behind a query, during a 400-write burst, then one final write
{
 "writes_during_subscribe": 400,
 "final_write": 1400,
 "subscriptions": 80,
 "expected_rows": 402,
 "total_deltas": 12061,
 "seq_gaps": 0,
 "stalled": []
}
exit=0
== run 4 relation burst4: 4 connections x 20 .subscribe pipelined behind a query, during a 400-write burst, then one final write
{
 "writes_during_subscribe": 400,
 "final_write": 1400,
 "subscriptions": 80,
 "expected_rows": 402,
 "total_deltas": 11285,
 "seq_gaps": 0,
 "stalled": []
}
exit=0
== run 5 relation burst5: 4 connections x 20 .subscribe pipelined behind a query, during a 400-write burst, then one final write
{
 "writes_during_subscribe": 400,
 "final_write": 1400,
 "subscriptions": 80,
 "expected_rows": 402,
 "total_deltas": 12102,
 "seq_gaps": 0,
 "stalled": []
}
exit=0
Evidence: Live /ws idle subscription: no spurious push, then one delta

snapshot [[1]] {"subscription":"watch","generation":1,"revision":2429} pushes after 1.5s idle: 0 pushes after one write: [{"type":"subscription_delta","seq":1,"inserted":[[2]],"retracted":[]}]

snapshot [[1]] {"subscription":"watch","generation":1,"revision":2429}
pushes after 1.5s idle: 0
pushes after one write: [{"type":"subscription_delta","seq":1,"inserted":[[2]],"retracted":[]}]
- Outcome: 🔧 1 issue found → auto-fixed ✅ across 2 runs (26m27s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

🔧 **Test** - 1 issue found → auto-fixed ✅
  • ⚠️ tests/standing_query_tests/main.rs:418 - The new integration test test_subscriptions_registered_during_a_write_burst_get_every_delta (tests/standing_query_tests/main.rs) passed 5/5 times on pre-fix source, so it does not reliably reproduce the lost wake-up. My external /ws stress drivers also never triggered it on the pre-fix binary. The regression guard really rests on the deterministic connection-level unit tests, which do fail before the fix. The author should decide whether to keep the integration test as a smoke test or tighten it.
  • Live validation: ✅ go - 3 of 5 scenarios driven live against the product
Scenario Result Live Evidence
A subscription whose doorbell is rung before registration still receives that delta and every later one (connection-level regression) ⏸️ untested no The prior payload did not establish a live result for this scenario; only unit tests (a_wake_up_before_registration_does_not_silence_a_new_view / _shared_view, failing pre-fix and passing on HEAD) cov…
Many subscriptions sent over /ws during a write burst each get a snapshot plus gapless deltas converging to the final rows (real server binary) ✅ pass live fixed-binary-transcript.txt (10x32 subs) and fixed-binary-8writers-transcript.txt (8x64 subs, ~1850 commits/round): stuck=0 in every round; standing_query_tests integration test passes
A second client joining a slow shared view mid re-evaluation keeps receiving later deltas ✅ pass live fixed-binary-shared-view-transcript.txt: B holds [1,2,3] in 3/3 rounds
A .subscribe pipelined behind a slow query while another client commits still receives the next commit's delta ✅ pass live fixed-binary-pipelined-subscribe-transcript.txt: delta seq 1 inserted [[3]], RESULT PASS
Registering with nothing new published queues no spurious wake-up or duplicate delta ⏸️ untested no The prior payload did not establish a live result for this scenario; it was covered by the unit test a_registration_with_nothing_new_queues_no_wake_up. Absence of a spurious wake-up is only observable…
  • cargo test --lib protocol::subscription:: (82 passed on HEAD, including the new connection/subscriber regression tests)
  • cargo test --test standing_query_tests (28 passed on HEAD, including test_subscriptions_registered_during_a_write_burst_get_every_delta)
  • Pre-fix check: reverted src/protocol/subscription/{connection,subscriber}.rs to fe9ffe2a, then cargo test --lib protocol::subscription::connection::tests: a_wake_up_before_registration_does_not_silence_a_new_view, ..._shared_view and subscriptions_registered_during_a_write_burst_converge FAIL; sources restored
  • Pre-fix check: cargo test --test standing_query_tests test_subscriptions_registered_during_a_write_burst x5 on pre-fix source (passed all 5 times, so this integration test does not reliably reproduce the bug)
  • Built cargo build --release --bin inputlayer-server for HEAD and for the pre-fix source; ran each with a disposable config/data dir under /tmp on a loopback port
  • Live: drive.py, 10 rounds x 32 .subscribe over /ws during a sequential write burst, fixed binary
  • Live: drive.py, 8 rounds x 64 subscriptions with 8 concurrent writer connections (~1850 commits/round), fixed and pre-fix binaries
  • Live: shared_race.py: second subscriber joins a slow shared view mid re-evaluation, then a later commit must reach it (fixed x3, pre-fix x7)
  • Live: race.py: .subscribe pipelined behind a 2.5s query while another client commits, then a later commit must reach it (fixed and pre-fix)

🔧 Fix applied.
✅ Re-checked - no issues remain.

  • Live validation: ✅ go - 2 of 4 scenarios driven live against the product
Scenario Result Live Evidence
A subscription whose wake-up is rung and dropped before it registers still receives that delta and every later one ⏸️ untested no The prior payload did not establish a live result for this exact ordering: no client can force a wake-up to be rung and dropped before registration over /ws on demand. It was covered only by determini…
Agents subscribing over /ws while another connection streams writes all end at the exact current result, with no seq gaps ✅ pass live live-ws-write-burst.txt: 5 runs × 80 subscriptions, 0 stalled, 0 seq gaps, final write delivered to every subscription
A newly registered subscription with nothing new gets no spurious delta, and the next write gives exactly one delta with seq 1 ✅ pass live live-ws-idle-subscription.txt; unit tests a_registration_with_nothing_new_queues_no_wake_up and resuming_with_nothing_new_queues_no_wake_up
The non-reproducing integration test is removed and the standing-query tests match base, per the round-1 decision ⏸️ untested no This is a repository-state check with no runtime surface, so the prior payload did not establish a live result. The non-live check git diff fe9ffe2a HEAD --stat -- tests came back empty.
  • cargo test --lib protocol::subscription (82 passed with the fix)
  • Disabled registered.resume() in src/protocol/subscription/connection.rs and ran cargo test --lib protocol::subscription::connection: 3 new regression tests FAILED (new view, shared view, write-burst convergence); restored with git checkout
  • cargo test --lib protocol::subscription::connection with the fix: 13 passed
  • Built inputlayer-server and ran it on 127.0.0.1:18931 with a throwaway --data-dir and config under /tmp (removed afterwards)
  • Live Node WebSocket driver, 5 runs, each on a fresh relation: 4 connections × 20 .subscribe frames, each sent right behind an earlier query on the same connection, during a 400-write burst from a separate connection, followed by one final write; each subscription's snapshot plus deltas compared to a live ?rel(X) query
  • Live idle check: .subscribe with no further writes saw 0 subscription pushes in 1.5s, then a single write gave exactly one subscription_delta with seq 1
  • git diff fe9ffe2a HEAD --stat -- tests is empty, confirming the integration test was removed as decided in round 1
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

jsam added 3 commits October 5, 2026 06:09
… a publication rung before registration cannot silence it

The hub puts a new subscriber's doorbell on its view as soon as it attaches,
but the connection registers the subscriber only when the `.subscribe` reply
is released. A publication in between rang the doorbell; the connection took
that wake-up for an unknown subscriber and dropped it without answering, so
the doorbell stayed marked queued and swallowed every later ring. The client
got its snapshot and then nothing, with no error.

Registering now answers the doorbell and rings again when the view published
since the subscriber's starting point. Answering before loading the cell
keeps the existing sequentially consistent handshake: a concurrent
publication is either seen by the check or rings an answered doorbell. No
lock is added, and delivery is unchanged.

Tests force the race deterministically on new and shared views, check a
registration with nothing new queues no wake-up, stress registrations during
a write burst (23 of 24 subscriptions froze at their snapshot without the
fix), and check over /ws that subscriptions sent during a write burst get
every delta with gapless seq.
@jsam
jsam merged commit ddcd158 into main Oct 5, 2026
20 checks passed
@jsam
jsam deleted the fm/fix-sub-lost-wakeup branch October 5, 2026 12:54
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