Repository navigation
fix(subscriptions): end owners' subscriptions on the node with unsubscribe (stacked on #1320) - #1323
Closed
takemiyamakoto wants to merge 3 commits into
Closed
takemiyamakoto wants to merge 3 commits into
takemiyamakoto wants to merge 3 commits into
Conversation
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>
3 of 7 tasks
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. |
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.
Stacked on #1320. Merge #1320 first. This branch contains #1320's two commits (
e104bacbbengine,694290aa4Polkaswap). This PR's own change is the last commit,f0fc6248e. Review that commit only: f0fc624Problem
#1320 made
WebSocketEngine.unsubsribeend 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 fromsubscribe(or, for the cross-chain watcher, fromaddSubscription).StorageSubscriptionContainerstate_subscribeStorageWebSocketProviderSourcestate_subscribeStorageSpecVersionSubscriptionstate_subscribeRuntimeVersionunsubscribe(): ChainRegistry calls it when a chain is updated or removedBaseStakingAccountSubscription(StakingAccountSubscriptionV14)state_subscribeStorageBaseStakingAccountResolver(StakingAccountResolverV14)state_subscribeStoragePoolStakingAccountSubscriptionstate_subscribeStorageReviewedCrossChainDispatchWatcherauthor_submitAndWatchExtrinsicsubscription opened by the submissionTwo more leaks in the three staking owners are fixed too:
chainRegistry.getConnection(for:)at teardown.ChainRegistry.handleUpdatedSubstrateChainreplaces a chain's connection when the chain's nodes change, so that lookup can return the new connection. The sameUInt16identifier may belong to another owner's subscription there, andunsubsribewould end that one.StakingAccountUpdatingServicecallsresolveKeysAndSubscribe()after the resolver'sinitalready did.PoolStakingAccountUpdatingServicecallssubscribeRemote()after theinitalready did. Each time the first subscription's identifier was overwritten, so it ran until the socket closed. Key encodings that finish out of order inStakingAccountSubscriptioncould do the same.Cross-chain dispatch watcher. The submission opens the
author_submitAndWatchExtrinsicsubscription with a plain call. The watcher then follows it through a watcher that has no subscribe request (addSubscriptionwith the node's subscription ID).cancelForIdentifierfound no request for that identifier, so the watcher stayed registered for good. It also kept the engine → watcher → connection reference cycle alive. Now:finalized, the node ends the watch itself, and the watcher is removed withunsubsribe.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) andSubstrateTransactionObserver.cancelSubscription(private, never called).Tests
SubscriptionOwnerUnsubscribeTests(9 tests) drives each owner against a realWebSocketEngineconnected toScriptedSubstrateNodefrom #1320. The node double now also servesstate_subscribeRuntimeVersionandauthor_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 throughFailoverChainConnection, as in production, and encode real keys with Asset Hub Polkadot metadata. The stash-change test uses the real Core Data stash item provider.f0fc6248e)StorageSubscriptionContainerreleased: node endssub-1, handler removedsub-1live, handler registeredWebSocketProviderSourcereleased: sameSpecVersionSubscription.unsubscribe():state_unsubscribeRuntimeVersion(["sub-1"])StakingAccountSubscriptionV14: a controller change ends the old subscription; release ends the lastStakingAccountResolverV14resolved twice (as the updating service does): one subscription runs; release ends itPoolStakingAccountSubscriptionsubscribed twice (as the updating service does): one runs; release ends itPoolStakingAccountSubscriptionafter the registry replaced the connection: ended on the original connection, nothing sent to the new oneauthor_unwatchExtrinsic(["sub-1"]), watcher removedfinalized: watcher removed, no unwatch neededThis 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.f0fc6248e: 65/65 pass. That is the 9 new tests plusCrossChainReviewedRouteTests33,FailoverChainConnectionTests7,ChainRegistryTests6,ConnectionPoolTests4,PolkaswapRemoteSubscriptionServiceTests3,SpecVersionSubscriptionTests2 andSubstrateTransactionObserverTests1.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:AccountConfirmPresentationTests.testOrdinaryFactoryFootersFitAt{Largest,Normal}TextInEveryLocaletake about 127 s each at this load. With a 300 s allowance both pass.CrossChainSubmissionTracking.swiftwas already over SwiftLint's file length limit.build/):.worktrees/fearless-iOS-ws-unsubscribe-owners/build/reports/ws-unsubscribe-owners-20261108/has the logs,.xcresultbundles, the run scripts, source SHA-256 per run, and the build product hashes (hashes-fixed.txt).Risk and rollback
unsubscriberequests that they did not send before. The engine sends them only while connected and never resends them. Afalseanswer is logged.author_unwatchExtrinsicwhen it gives up before finality. That call stops the status notifications only. It does not affect the extrinsic.f0fc6248e. Unsubscribe ends subscriptions on the node (SSFUtils engine, Polkaswap swap screen) #1320 is unaffected.🤖 Generated with Claude Code