fix(pulse-core): reference-count shared subscriptions - #1000
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
determined-001
force-pushed
the
fix/sep10-challenge-validation
branch
from
August 10, 2026 00:18
1018fab to
2900c1b
Compare
determined-001
changed the base branch from
fix/sep10-challenge-validation
to
main
August 10, 2026 00:21
`subscribe()` and `subscribeContract()` memoise by key, so concurrent callers
asking for the same address or contract receive the *same* Watcher object.
`unsubscribe()` called `stop()` on it unconditionally. `Watcher.stop()` sets
`_stopped`, calls `removeAllListeners()`, and makes `emit()` return false
without dispatching — so the first caller to leave silently killed every other
caller's event flow.
The failure is invisible from the outside. In apps/web both SSE routes key on
data that is not per-connection (`address`, `contract:${contractId}`) and
unsubscribe on teardown, so when one visitor closed their tab, every other
visitor watching the same contract kept an open connection, kept receiving
heartbeats, and never received another event — indistinguishable from a quiet
contract.
Adds a namespaced refcount map (`addr:` / `contract:` / `config:`, since the
three registries have independent key spaces). `subscribe*()` retains,
`unsubscribe*()` releases, and the watcher stops only on the last release.
Details worth noting:
- Each watcher's stop handler clears its own refcount entry, so a consumer
calling `watcher.stop()` directly cannot strand a count and leave the next
subscription for that key permanently unstoppable.
- `release()` on an unknown key returns true (stop). An already-torn-down
entry should not keep a watcher alive.
- `unsubscribeAll()` / `unsubscribeAllContracts()` are teardown and
deliberately ignore counts; `unsubscribeAll()` now iterates a snapshot
because the stop handlers mutate the registry it was iterating.
Single-subscriber behaviour is unchanged: one subscribe, one unsubscribe,
stopped immediately.
Verified end to end against a live server: two clients streaming mainnet USDC
(CCW67TSZ...), first disconnected, second went on receiving real
`contract.emitted` events rather than going silent.
12 new tests; all 603 existing pulse-core tests still pass.
determined-001
force-pushed
the
fix/watcher-refcount
branch
from
August 10, 2026 00:22
07984b3 to
39117e0
Compare
determined-001
pushed a commit
that referenced
this pull request
Aug 10, 2026
The two repositories are independent - neither is a fork of the other - and their mains had diverged both ways: 42 commits here, 22 there (contributor PRs #33-#53: CAP-67 unified ingestion, abi-registry attestation and semantic taxonomy, the SEP-48 gap memo). This is a real two-way merge rather than a push, so every contributor commit on the chalkeion-stellar side keeps its authorship and its merge attribution. Conflicts and how they were resolved: - packages/abi-registry/package.json - union of both bin entries (abi-registry, abi-registry-generate, orbital-codegen); kept the newer @stellar/stellar-sdk ^16.1.0 and added yargs, which the verify CLI needs. - packages/abi-registry/src/index.ts - both sides only appended exports; kept both blocks. - packages/abi-registry/test/ChainedAbiRegistryClient.test.ts - both sides appended a describe block at end of file; kept both. - packages/pulse-core/src/EventEngine.ts - chalkeion-stellar extracted withTimestampDate into src/timestampDate.ts while this side still defined it locally, which collided with the new import (TS2440). Dropped the local copy - the extracted one is identical - and kept the refcount keys from #1000. - docs/COOKBOOK.md - both sides added recipes over the same numbering. Kept this side's 10-18 and renumbered the incoming unified-ingestion recipe to 19. - pnpm-lock.yaml - regenerated from the merged manifests rather than hand-resolved. One integration failure the merge itself produced: openData.test.ts derives the taxonomy from pulse-core's exhaustive describeEvent switch, and the incoming CAP-67 work added asset.clawback and fee.incurred without corresponding open-data records. Added both to generate-open-data.mjs and regenerated the artifacts (46 records). Verified on the merged tree: pnpm build, lint, format:check, typecheck of all five projects, Horizon type-drift check, and the suites for pulse-core (698), abi-registry (456), pulse-notify (77), anchor-sdk (72), orbital-indexer (8) and apps/web (40).
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.
The bug
subscribe()andsubscribeContract()memoise by key, so concurrent callers asking for the same address or contract receive the sameWatcherobject.unsubscribe()calledstop()on it unconditionally.Watcher.stop()sets_stopped, callsremoveAllListeners(), and makesemit()return false without dispatching — so the first caller to leave silently killed every other caller's event flow.Proven against
pulse-core:Why it was invisible
In
apps/webboth SSE routes key on data that is not per-connection —address, and`contract:${contractId}`— and unsubscribe on teardown. So when one visitor closed their tab, every other visitor watching the same contract kept an open connection, kept receiving heartbeats, and never received another event. Indistinguishable from a quiet contract.Cheap to weaponise: connect to a popular contract, disconnect, repeat, and every other viewer's stream goes silent.
The fix
A namespaced refcount map (
addr:/contract:/config:, since the three registries have independent key spaces).subscribe*()retains,unsubscribe*()releases, and the watcher stops only on the last release.Details worth review:
watcher.stop()directly cannot strand a count and leave the next subscription for that key permanently unstoppable.release()on an unknown key returnstrue(stop). An already-torn-down entry must not keep a watcher alive.unsubscribeAll()/unsubscribeAllContracts()are teardown and deliberately ignore counts.unsubscribeAll()now iterates a snapshot, because the stop handlers mutate the registry it was iterating.Single-subscriber behaviour is unchanged: one subscribe, one unsubscribe, stopped immediately.
Verification
Beyond unit tests — verified end-to-end against a live server. Two clients streaming mainnet USDC (
CCW67TSZ...), first disconnected:Client B went on receiving real
contract.emittedevents rather than going silent.12 new tests; all 603 existing
pulse-coretests still pass.