Repository navigation
Unsubscribe ends subscriptions on the node (SSFUtils engine, Polkaswap swap screen) - #1319
Closed
takemiyamakoto wants to merge 2 commits into
Closed
takemiyamakoto wants to merge 2 commits into
takemiyamakoto wants to merge 2 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>
Contributor
Author
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.
Summary
WebSocketEngine.unsubsribe(_:)never ended a subscription, and the Polkaswap swap screen did not even call it. Every pair change on the swap screen left one more SORA block subscription running (found while fixing slow quotes in 4.2.1, #1315, which works around it in the screen with a generation guard).Engine (
Native/Sources/SSFUtils)processUnsubscriptiondecoded the subscribe request's params as[[Data]]. Storage keys are hex, not base64, so it usually threwDecodingErrorbefore sending anything. When it did send, it sentstate_unsubscribeStoragewith the storage keys, which the node answers withfalse. Its completion then decoded that boolean asData, so the local handler was never removed either.chain_subscribeNewHeads→chain_unsubscribeNewHeads,author_submitAndWatchExtrinsic→author_unwatchExtrinsic, …). A node answersfalseto a mismatched method and keeps sending.SubstrateTransactionObserver's extrinsic status watchers) are removed locally. Before, theirtry?unsubscribe threw and they stayed registered for the life of the engine.App
PolkaswapRemoteSubscriptionService.unsubscribe()calledcancelForIdentifier, which only withdraws unanswered requests. It now calls the engine's unsubscribe. It also keeps every subscription ID it creates: a second subscribe call used to overwrite the first one's IDs.SSFPolkaswap/PolkaswapPoolSubscriptionServiceandFailoverChainConnectionalready callunsubsribe. They are fixed by the engine change with no code change.Live node check (SORA mainnet,
wss://mof2.sora.org, node 4.8.13, 2026-11-07)state_unsubscribeStorage([["<storage key>"]])falsestate_unsubscribeStorage(["<subscription id>"])truestate_unsubscribeStorage(["<newHeads id>"])falsechain_unsubscribeNewHeads(["<newHeads id>"])trueThe app's real
WebSocketEnginewas also run against the same node, built in a throwaway macOS package from the production sources:unsubsribethrewDecodingError(base64), nothing was sent, and the node kept delivering: 4 updates in 36 s.Tests
No hosted CI here; all runs are local on this branch's commits.
SSFUtilsTests(FearlessNative-Package, iOS 26.5 simulator): 56/56 pass, including 9 new tests. A real engine runs against a node double that answers like the live node. The cases cover:On master's engine all 7 behaviour tests fail (33 assertions). The 2 method-mapping tests need the new function, so they were left out of that run.
Native/scripts/test-authorized-rpc.py(macOS contract suite): 38/38 pass. It now has 38 cases: one new case for the guarded write, and two existing unsubscribe cases updated. The old "malformed request throws" assertion now expects local removal instead. On master's engine those 3 fail and the other 35 pass.fearlessTests(iOS 26.5, unsigned simulator build): passes on this branch. A real engine talks to a scripted SORA node. The new cases cover:PolkaswapRemoteSubscriptionService.unsubscribe()ends the subscription on the node, and no block follows;SubstrateTransactionObserverremoves its watchers.The 7
FailoverChainConnectionTests, which drive a real engine, also pass. Full suite: 3,515 passed, 75 skipped, 14 failed:AccountImportTests,PasskeyBackupAppAttestRetryTests,RetainedDataRestoreTests, 8×WalletConnectLegacyStorageContinuityTests);SendDependencyErrorSurfacingTests: one clipboard read returned nil, and two found no confirmation after the tap. They use stubbed services and never reach the WebSocket engine or the Polkaswap service. Run alone on a freshly booted simulator, all 3 pass on this branch and on master. They fail only inside the 50-minute full run at host load ≈450.On master's engine and Polkaswap service, all 4 new app tests fail (12 assertions). After five pair changes, every earlier pair still receives each block (
[1, 1, 1, 1, 1]). A released observer leaves its 2 watchers in the engine.scripts/deps/verify-native-source.pyandtest-native-source.pypass. SwiftLint and SwiftFormat report nothing new in the changed files. WebSocketEngine.swift's warnings, including file length, were already on master.Not in this PR
StorageSubscriptionContainer,WebSocketProviderSource,SpecVersionSubscription, the staking account subscriptions and resolver,PoolStakingAccountSubscriptionandCrossChainSubmissionTrackingalso end subscriptions withcancelForIdentifier. Their established subscriptions keep running on the node, and their handlers stay registered in the engine. Moving them tounsubsribeis a follow-up that depends on this fix.Risk and rollback
The change is limited to
unsubsribeand the Polkaswap service. A node that rejects the request costs a logged warning; the handler is still gone. Rollback: revert the two commits.🤖 Generated with Claude Code