Skip to content

fix(subscriptions): end owners' subscriptions on the node with unsubscribe (stacked on #1320) - #1323

Closed
takemiyamakoto wants to merge 3 commits into
masterfrom
hotfix/ws-unsubscribe-subscription-owners
Closed

takemiyamakoto wants to merge 3 commits into
masterfrom
hotfix/ws-unsubscribe-subscription-owners

Conversation

@takemiyamakoto

Copy link
Copy Markdown
Contributor

Stacked on #1320. Merge #1320 first. This branch contains #1320's two commits (e104bacbb engine, 694290aa4 Polkaswap). This PR's own change is the last commit, f0fc6248e. Review that commit only: f0fc624

Problem

#1320 made WebSocketEngine.unsubsribe end a subscription on the node: it sends the subscription ID with the unsubscribe method that matches the subscribe method. SORA mainnet (node 4.8.13) ends a subscription only that way.

The other subscription owners still tore down with cancelForIdentifier. That call only withdraws a request the node has not answered yet. After the node answers, the subscription keeps running on the node until the socket closes, and its handler stays registered in the engine.

Change

Each owner below now ends its subscription with unsubsribe. I checked at each site that the identifier comes from subscribe (or, for the cross-chain watcher, from addSubscription).

Owner Node subscription When it is ended
StorageSubscriptionContainer state_subscribeStorage release
WebSocketProviderSource state_subscribeStorage release
SpecVersionSubscription state_subscribeRuntimeVersion unsubscribe(): ChainRegistry calls it when a chain is updated or removed
BaseStakingAccountSubscription (StakingAccountSubscriptionV14) state_subscribeStorage stash or controller change, release
BaseStakingAccountResolver (StakingAccountResolverV14) state_subscribeStorage release
PoolStakingAccountSubscription state_subscribeStorage release
ReviewedCrossChainDispatchWatcher the author_submitAndWatchExtrinsic subscription opened by the submission finality or timeout

Two more leaks in the three staking owners are fixed too:

  • They end the subscription on the connection that opened it. Before, they looked up chainRegistry.getConnection(for:) at teardown. ChainRegistry.handleUpdatedSubstrateChain replaces a chain's connection when the chain's nodes change, so that lookup can return the new connection. The same UInt16 identifier may belong to another owner's subscription there, and unsubsribe would end that one.
  • A subscription that a second subscribe replaces is ended. StakingAccountUpdatingService calls resolveKeysAndSubscribe() after the resolver's init already did. PoolStakingAccountUpdatingService calls subscribeRemote() after the init already did. Each time the first subscription's identifier was overwritten, so it ran until the socket closed. Key encodings that finish out of order in StakingAccountSubscription could do the same.

Cross-chain dispatch watcher. The submission opens the author_submitAndWatchExtrinsic subscription with a plain call. The watcher then follows it through a watcher that has no subscribe request (addSubscription with the node's subscription ID). cancelForIdentifier found no request for that identifier, so the watcher stayed registered for good. It also kept the engine → watcher → connection reference cycle alive. Now:

  • After finalized, the node ends the watch itself, and the watcher is removed with unsubsribe.
  • Otherwise (the 60 s timeout), the watcher sends author_unwatchExtrinsic(["<subscription id>"]). It is removed once the node answers, so statuses sent before the answer still find their handler.

Not changed: one-shot request cancellation (JSONRPCWorkerDefault, JSONRPCOperation, the SSFStorageQueryKit worker), FailoverChainConnection.cancelForIdentifier (a forwarder) and SubstrateTransactionObserver.cancelSubscription (private, never called).

Tests

SubscriptionOwnerUnsubscribeTests (9 tests) drives each owner against a real WebSocketEngine connected to ScriptedSubstrateNode from #1320. The node double now also serves state_subscribeRuntimeVersion and author_submitAndWatchExtrinsic. Like SORA 4.8.13, it ends a subscription only for its ID sent with the matching unsubscribe method. The staking owners get the engine through FailoverChainConnection, as in production, and encode real keys with Asset Hub Polkadot metadata. The stash-change test uses the real Core Data stash item provider.

Test This PR (f0fc6248e) #1320's owner code
StorageSubscriptionContainer released: node ends sub-1, handler removed pass fail: no unsubscribe sent, sub-1 live, handler registered
WebSocketProviderSource released: same pass fail: same
SpecVersionSubscription.unsubscribe(): state_unsubscribeRuntimeVersion(["sub-1"]) pass fail: nothing sent; a later runtime upgrade still reaches the sync service
StakingAccountSubscriptionV14: a controller change ends the old subscription; release ends the last pass fail: both subscriptions stay live
StakingAccountResolverV14 resolved twice (as the updating service does): one subscription runs; release ends it pass fail: 2 live, 2 handlers left after release
PoolStakingAccountSubscription subscribed twice (as the updating service does): one runs; release ends it pass fail: 2 live, 2 handlers left after release
PoolStakingAccountSubscription after the registry replaced the connection: ended on the original connection, nothing sent to the new one pass fail: the original stays live
Cross-chain watcher, no finality before the timeout: author_unwatchExtrinsic(["sub-1"]), watcher removed pass fail: nothing sent, watcher stays
Cross-chain watcher after finalized: watcher removed, no unwatch needed pass fail: watcher stays registered

This PR: 9/9 pass. #1320's owner code: 9/9 fail with 31 assertion failures. That run used the same test sources, with the seven owner files replaced by #1320's versions (SHA-256 checked against the branch).

Local validation (no hosted CI)

Everything ran on one Mac: Xcode 27.2 (27B52), a private iOS 26.5 iPhone 17 simulator created for this run, Debug, unsigned (CODE_SIGNING_ALLOWED=NO), -collect-test-diagnostics never. Host load was 100–1000 from other builds.

  • Targeted, commit f0fc6248e: 65/65 pass. That is the 9 new tests plus CrossChainReviewedRouteTests 33, FailoverChainConnectionTests 7, ChainRegistryTests 6, ConnectionPoolTests 4, PolkaswapRemoteSubscriptionServiceTests 3, SpecVersionSubscriptionTests 2 and SubstrateTransactionObserverTests 1.
  • Full fearlessTests, same build: every test passes except the 11 keychain tests that always fail in unsigned builds. Those are addressed separately in Fix the failing fearlessTests: Japanese Welcome glyphs, bold HUD, Send pasteboard, Profile PIN race #1322. The full run took three passes:
    • The first pass was cut off after 3,018 tests, when CoreSimulatorService was terminated at 06:03 KST (not by this run). Result: 2,941 passed, 70 skipped, 6 failed (3 keychain tests, plus 3 tests over the 2-minute allowance).
    • The 47 classes that pass never reached were then run on the same build: 699 passed, 6 skipped, 10 failed (8 keychain tests, plus the 2 layout tests below over the allowance). The migration test that timed out in the first pass passed here.
    • AccountConfirmPresentationTests.testOrdinaryFactoryFootersFitAt{Largest,Normal}TextInEveryLocale take about 127 s each at this load. With a 300 s allowance both pass.
  • SwiftFormat and SwiftLint: the seven app files have the same findings as on Unsubscribe ends subscriptions on the node (SSFUtils engine, Polkaswap swap screen) #1320 (no new ones), and the new test file is formatted. CrossChainSubmissionTracking.swift was already over SwiftLint's file length limit.
  • Evidence (local, in ignored build/): .worktrees/fearless-iOS-ws-unsubscribe-owners/build/reports/ws-unsubscribe-owners-20261108/ has the logs, .xcresult bundles, the run scripts, source SHA-256 per run, and the build product hashes (hashes-fixed.txt).

Risk and rollback

  • The owners now send unsubscribe requests that they did not send before. The engine sends them only while connected and never resends them. A false answer is logged.
  • The cross-chain watcher sends one author_unwatchExtrinsic when it gives up before finality. That call stops the status notifications only. It does not affect the extrinsic.
  • Rollback: revert f0fc6248e. Unsubscribe ends subscriptions on the node (SSFUtils engine, Polkaswap swap screen) #1320 is unaffected.

🤖 Generated with Claude Code

takemiyamakoto and others added 3 commits November 7, 2026 19:21
WebSocketEngine.unsubsribe(_:) never ended a subscription. It decoded the
subscribe request's params as [[Data]] (storage keys are hex, not base64, so
this usually threw before anything was sent) and otherwise sent
state_unsubscribeStorage with the storage keys. A SORA mainnet node
(4.8.13, 2026-11-07) answers that with `false` and keeps notifying; the
completion also decoded the boolean answer as Data, so the local handler
was never removed either.

Now the handler is removed at once and the node gets the subscription ID it
assigned, through the unsubscribe method that matches the subscribe method
(chain_subscribeNewHeads -> chain_unsubscribeNewHeads, and so on; the node
answers `false` to a mismatched method). A subscription still waiting for
its ID is ended when the ID arrives and is not resent after a reconnect; a
subscribe request that was never sent is dropped, while a guarded write is
never withdrawn. Watchers registered without a subscribe request, such as
extrinsic status streams, are removed locally.

Tests: SSFUtilsTests drive a real engine against a node double that answers
like the live node; the AuthorizedRPC contract suite covers the locked send
path and the guarded case (38 cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PolkaswapRemoteSubscriptionService.unsubscribe() called cancelForIdentifier,
which only withdraws requests that have not been answered. Every pair change
on the swap screen therefore left another SORA block subscription running.
It now calls the engine's unsubscribe, and keeps every subscription ID it
creates (a second subscribe call no longer overwrites the first one's IDs).

Tests run a real WebSocketEngine against a scripted SORA node: unsubscribe
ends the subscription on the node, five pair changes leave one subscription,
and releasing a SubstrateTransactionObserver removes its watchers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cribe

The remaining subscription owners ended their subscriptions with
cancelForIdentifier, which only withdraws a request that has not been
answered. An established subscription therefore kept running on the node
until the socket closed, and its handler stayed registered in the engine.
They now call WebSocketEngine.unsubsribe, which #1320 made end the
subscription on the node with its subscription ID and matching method:

- StorageSubscriptionContainer and WebSocketProviderSource (storage,
  on release)
- SpecVersionSubscription (runtime version; ChainRegistry unsubscribes
  when a chain is updated or removed)
- StakingAccountSubscription, StakingAccountResolver and
  PoolStakingAccountSubscription (staking storage, on a stash change and
  on release)

The three staking owners now end a subscription on the connection that
opened it. ChainRegistry replaces a chain's connection when the chain's
nodes change, and the same UInt16 identifier can belong to another
owner's subscription on the new connection. They also end a subscription
that a second subscribe replaces: StakingAccountUpdatingService and
PoolStakingAccountUpdatingService subscribe again after the owners' init
already has, so the first subscription's identifier was overwritten and
it ran until the socket closed.

The cross-chain dispatch watcher follows the author_submitAndWatchExtrinsic
subscription opened by the submission through a watcher registered without
a subscribe request. cancelForIdentifier found no request and left the
watcher registered for good. It is now removed with unsubsribe; when the
watcher stops before the node reported finality (its timeout), it first
ends the watch with author_unwatchExtrinsic.

One-shot request cancellation (JSONRPCWorkerDefault, JSONRPCOperation)
is unchanged.

Tests: SubscriptionOwnerUnsubscribeTests drive each owner against a real
WebSocketEngine and ScriptedSubstrateNode, now extended to runtime version
and extrinsic status subscriptions; each is ended only by its own
unsubscribe method with the subscription ID, as SORA 4.8.13 answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@takemiyamakoto

Copy link
Copy Markdown
Contributor Author

Folded into #1326, which brings every open iOS PR into master in one change, so one code-owner approval covers it. This PR's branch is kept.

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