diff --git a/CHANGELOG.md b/CHANGELOG.md index dc62e6a36e9..29a67c3c648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.70.1] + +### Fixed + +- Fixed stale perpetuals data and missing 24h price change after returning from background (#27530) +- Fixed a bug where closing positions on HIP-3 markets (e.g., xyz:BRENTOIL) failed with "Asset ID not found" when navigating via the Perps tab (#27854) + ## [7.70.0] ### Added @@ -11008,7 +11015,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.1...HEAD +[7.70.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...v7.70.1 [7.70.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.1...v7.70.0 [7.69.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.0...v7.69.1 [7.69.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.3...v7.69.0 diff --git a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx index c2c760bbcad..ce59bb17707 100644 --- a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx +++ b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx @@ -41,6 +41,7 @@ jest.mock('../index', () => ({ const mockUseSelector = useSelector as jest.MockedFunction; const mockConnect = PerpsConnectionManager.connect as jest.Mock; const mockDisconnect = PerpsConnectionManager.disconnect as jest.Mock; +const mockEnsureConnected = PerpsConnectionManager.ensureConnected as jest.Mock; describe('PerpsAlwaysOnProvider', () => { let mockAppStateListener: ((state: string) => void) | null = null; @@ -53,6 +54,7 @@ describe('PerpsAlwaysOnProvider', () => { mockConnect.mockResolvedValue(undefined); mockDisconnect.mockResolvedValue(undefined); + mockEnsureConnected.mockResolvedValue(undefined); mockSubscriptionRemove = jest.fn(); addEventListenerSpy = jest @@ -167,7 +169,7 @@ describe('PerpsAlwaysOnProvider', () => { expect(mockDisconnect).toHaveBeenCalledTimes(1); }); - it('calls connect after delay when app returns to foreground', () => { + it('calls ensureConnected after delay when app returns to foreground', () => { render( child @@ -175,7 +177,7 @@ describe('PerpsAlwaysOnProvider', () => { ); // Clear the initial mount connect call - mockConnect.mockClear(); + mockEnsureConnected.mockClear(); act(() => { mockAppStateListener?.('background'); @@ -185,13 +187,13 @@ describe('PerpsAlwaysOnProvider', () => { }); // Should not reconnect immediately — uses a timer delay - expect(mockConnect).not.toHaveBeenCalled(); + expect(mockEnsureConnected).not.toHaveBeenCalled(); act(() => { jest.runAllTimers(); }); - expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockEnsureConnected).toHaveBeenCalledTimes(1); }); it('cancels pending reconnect timer if app goes background before timer fires', () => { @@ -201,7 +203,7 @@ describe('PerpsAlwaysOnProvider', () => { , ); - mockConnect.mockClear(); + mockEnsureConnected.mockClear(); // Goes active — schedules reconnect timer act(() => { @@ -217,8 +219,8 @@ describe('PerpsAlwaysOnProvider', () => { jest.runAllTimers(); }); - // connect should NOT have been called (timer was cancelled) - expect(mockConnect).not.toHaveBeenCalled(); + // ensureConnected should NOT have been called (timer was cancelled) + expect(mockEnsureConnected).not.toHaveBeenCalled(); expect(mockDisconnect).toHaveBeenCalledTimes(1); }); @@ -250,7 +252,7 @@ describe('PerpsAlwaysOnProvider', () => { , ); - mockConnect.mockClear(); + mockEnsureConnected.mockClear(); mockDisconnect.mockClear(); // Pull-down: active → inactive → active @@ -266,7 +268,7 @@ describe('PerpsAlwaysOnProvider', () => { }); expect(mockDisconnect).toHaveBeenCalledTimes(1); - expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockEnsureConnected).toHaveBeenCalledTimes(1); }); it('calls disconnect and removes AppState subscription on unmount', () => { diff --git a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx index 0573d565a90..138e0dfad95 100644 --- a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx +++ b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx @@ -58,7 +58,7 @@ export const PerpsAlwaysOnProvider: React.FC<{ children: React.ReactNode }> = ({ } else if (nextState === 'active') { // Small delay to allow system to stabilize after background reconnectTimer = setTimeout(() => { - PerpsConnectionManager.connect().catch((err) => { + PerpsConnectionManager.ensureConnected().catch((err) => { Logger.error(ensureError(err, 'PerpsAlwaysOnProvider.reconnect'), { tags: { feature: PERPS_CONSTANTS.FeatureName }, context: { diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index 32c0363acf4..16a8dae1cb6 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -1046,9 +1046,10 @@ describe('PerpsStreamManager', () => { await Promise.resolve(); }); - // Now subscribeToPrices should have been called + // Now subscribeToPrices should have been called without includeMarketData expect(mockSubscribeToPrices).toHaveBeenCalledWith({ symbols: ['BTC-PERP', 'ETH-PERP'], + includeMarketData: false, callback: expect.any(Function), }); @@ -1237,6 +1238,7 @@ describe('PerpsStreamManager', () => { expect(mockSubscribeToPrices).toHaveBeenCalledTimes(1); expect(mockSubscribeToPrices).toHaveBeenCalledWith({ symbols: ['ETH-PERP'], + includeMarketData: false, callback: expect.any(Function), }); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index af12dc4c846..a904007ec50 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -520,9 +520,13 @@ class PriceStreamChannel extends StreamChannel> { }, ); - // Subscribe to all market prices + // WARNING: Do NOT set includeMarketData: true here. It triggers + // per-symbol activeAssetCtx subscriptions (N symbols × N DEXs = N² + // WebSocket connections). assetCtxs (1 per DEX) is always established + // by the subscription service regardless of this flag. const unsub = controller.subscribeToPrices({ symbols: this.allMarketSymbols, + includeMarketData: false, callback: (updates: PriceUpdate[]) => { const priceMap: Record = {}; updates.forEach((update) => { diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts index 1f42dec8338..4cbcbeba7be 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts @@ -123,6 +123,7 @@ const resetManager = (manager: unknown) => { initPromise: Promise | null; disconnectPromise: Promise | null; pendingReconnectPromise: Promise | null; + ensureConnectedPromise: Promise | null; unsubscribeFromStore: (() => void) | null; previousAddress: string | undefined; previousPerpsNetwork: 'mainnet' | 'testnet' | undefined; @@ -157,6 +158,7 @@ const resetManager = (manager: unknown) => { m.initPromise = null; m.disconnectPromise = null; m.pendingReconnectPromise = null; + m.ensureConnectedPromise = null; m.unsubscribeFromStore = null; m.previousAddress = undefined; m.previousPerpsNetwork = undefined; @@ -1275,6 +1277,116 @@ describe('PerpsConnectionManager', () => { }); }); + describe('ensureConnected', () => { + it('cancels grace period, disconnects, and reconnects when connected', async () => { + // Establish connection first + await PerpsConnectionManager.connect(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + + // Simulate the state after AlwaysOnProvider calls disconnect() which + // decrements refCount to 0 and starts grace period + const m = PerpsConnectionManager as unknown as { + isInGracePeriod: boolean; + gracePeriodTimer: number | null; + connectionRefCount: number; + }; + m.connectionRefCount = 0; + m.isInGracePeriod = true; + m.gracePeriodTimer = 123; + + // Clear mocks to track ensureConnected calls + (Engine.context.PerpsController.disconnect as jest.Mock).mockClear(); + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + await PerpsConnectionManager.ensureConnected(); + + // Grace period should be cancelled + expect(m.isInGracePeriod).toBe(false); + + // Should have disconnected then reconnected + expect(Engine.context.PerpsController.disconnect).toHaveBeenCalled(); + expect(Engine.context.PerpsController.init).toHaveBeenCalled(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + }); + + it('reconnects after long background when grace period already fired', async () => { + // Establish connection, then simulate grace period already fired + await PerpsConnectionManager.connect(); + + // Simulate performActualDisconnection already ran (grace period fired) + const m = PerpsConnectionManager as unknown as { + isConnected: boolean; + isInitialized: boolean; + hasPreloaded: boolean; + isPreloading: boolean; + connectionRefCount: number; + }; + m.isConnected = false; + m.isInitialized = false; + m.hasPreloaded = false; + m.isPreloading = false; + // Grace period fired → refCount was already 0 when disconnect ran + m.connectionRefCount = 0; + + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + await PerpsConnectionManager.ensureConnected(); + + // Should have reconnected (connect() runs full init path since isConnected=false) + expect(Engine.context.PerpsController.init).toHaveBeenCalled(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + }); + + it('connects when not previously connected', async () => { + // Manager starts in disconnected state (from resetManager in beforeEach) + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + await PerpsConnectionManager.ensureConnected(); + + expect(Engine.context.PerpsController.init).toHaveBeenCalled(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + }); + + it('resets connectionRefCount to 1 after ensureConnected', async () => { + // Simulate refCount drift: connect() twice so refCount = 2 + await PerpsConnectionManager.connect(); + await PerpsConnectionManager.connect(); + + const m = PerpsConnectionManager as unknown as { + connectionRefCount: number; + }; + expect(m.connectionRefCount).toBe(2); + + await PerpsConnectionManager.ensureConnected(); + + // ensureConnected resets to 0 then connect() brings it to 1 + expect(m.connectionRefCount).toBe(1); + }); + + it('deduplicates concurrent ensureConnected calls', async () => { + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + // Fire two calls concurrently + const [result1, result2] = await Promise.all([ + PerpsConnectionManager.ensureConnected(), + PerpsConnectionManager.ensureConnected(), + ]); + + expect(result1).toBeUndefined(); + expect(result2).toBeUndefined(); + // init should only be called once since second call reuses the promise + expect(Engine.context.PerpsController.init).toHaveBeenCalledTimes(1); + }); + }); + describe('getActiveProviderName', () => { it('returns activeProvider from PerpsController state', () => { // Arrange diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index d24d66c988c..96e5b5f3305 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -50,6 +50,7 @@ class PerpsConnectionManagerClass { private connectionRefCount = 0; private initPromise: Promise | null = null; private disconnectPromise: Promise | null = null; + private ensureConnectedPromise: Promise | null = null; private hasPreloaded = false; private isPreloading = false; private prewarmCleanups: (() => void)[] = []; @@ -470,9 +471,12 @@ class PerpsConnectionManagerClass { } /** - * Perform the actual disconnection after grace period expires + * Perform the actual disconnection after grace period expires. + * @param options.force - Bypass refCount guard (used by ensureConnected). */ - private async performActualDisconnection(): Promise { + private async performActualDisconnection( + options: { force?: boolean } = {}, + ): Promise { DevLogger.log( `PerpsConnectionManager: Grace period expired, performing disconnection (refCount: ${this.connectionRefCount})`, ); @@ -481,8 +485,8 @@ class PerpsConnectionManagerClass { this.gracePeriodTimer = null; this.isInGracePeriod = false; - // Only disconnect if we still have no references - if (this.connectionRefCount <= 0) { + // Only disconnect if we still have no references (unless forced) + if (options.force || this.connectionRefCount <= 0) { if (this.isConnected || this.isInitialized) { // Track that we're disconnecting this.isDisconnecting = true; @@ -1061,6 +1065,54 @@ class PerpsConnectionManagerClass { } } + /** + * Called on foreground return. Always forces a full reconnect. + * + * The grace period timer handles battery savings (disconnects after 30s + * in background). But regardless of whether the timer fired or not, + * we cannot trust the WebSocket state after backgrounding: + * - Grace period fired: already disconnected, need fresh connect + * - Grace period didn't fire (iOS suspends JS timers): WebSocket + * likely dead but isConnected still true, need fresh connect + * + * By always doing disconnect + connect, behavior is identical on both + * iOS and Android regardless of how long the app was backgrounded. + */ + async ensureConnected(): Promise { + // Guard against concurrent calls (e.g. rapid foreground transitions) + if (this.ensureConnectedPromise) { + return this.ensureConnectedPromise; + } + + this.ensureConnectedPromise = this.performEnsureConnected(); + try { + await this.ensureConnectedPromise; + } finally { + this.ensureConnectedPromise = null; + } + } + + private async performEnsureConnected(): Promise { + // Cancel grace period if still pending — we're taking over + this.cancelGracePeriod(); + + // Force clean state so connect() runs the full init → ping → preload path. + // Uses force: true to bypass the refCount guard — ensureConnected must + // always tear down, regardless of how many components hold references. + if (this.isConnected || this.isInitialized) { + await this.performActualDisconnection({ force: true }); + } + + // Reset refCount so connect() brings it to exactly 1. + // Without this, repeated background/foreground cycles would drift + // refCount upward (1→2→3…), eventually preventing grace-period + // disconnects from firing (they require refCount ≤ 0). + this.connectionRefCount = 0; + + // Full reconnect: init → ping → preload + await this.connect(); + } + async disconnect(): Promise { this.connectionRefCount--; DevLogger.log( diff --git a/app/constants/ota.ts b/app/constants/ota.ts index 70e0dd691f3..ec21ae8bdf2 100644 --- a/app/constants/ota.ts +++ b/app/constants/ota.ts @@ -6,7 +6,7 @@ import otaConfig from '../../ota.config.js'; * Reset to v0 when releasing a new native build * We keep this OTA_VERSION here to because changes in ota.config.js will affect the fingerprint and break the workflow in Github Actions */ -export const OTA_VERSION: string = 'v7.65.1'; +export const OTA_VERSION: string = 'v7.70.1'; export const RUNTIME_VERSION = otaConfig.RUNTIME_VERSION; export const PROJECT_ID = otaConfig.PROJECT_ID; export const UPDATE_URL = otaConfig.UPDATE_URL; diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index 764f4e42ea1..480ac72df40 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -2119,16 +2119,13 @@ export class HyperLiquidProvider implements PerpsProvider { '[buildAssetMapping] getValidatedDexs failed, falling back to main DEX', { error: String(dexError) }, ); - this.#cachedAllPerpDexs = this.#cachedAllPerpDexs ?? [null]; dexsToMap = [null]; } - // Use cached perpDexs array (populated by getValidatedDexs) - // Defensive: ensure non-null even if getValidatedDexs had an unexpected issue - if (!this.#cachedAllPerpDexs) { - this.#cachedAllPerpDexs = [null]; - } - const allPerpDexs = this.#cachedAllPerpDexs; + // Local fallback only — never write [null] into #cachedAllPerpDexs here. + // That cache is owned exclusively by #fetchValidatedDexsInternal; writing a + // fallback here would prevent subsequent callers from retrying perpDexs(). + const allPerpDexs = this.#cachedAllPerpDexs ?? [null]; this.#deps.debugLogger.log( 'HyperLiquidProvider: Starting asset mapping rebuild', @@ -4772,6 +4769,12 @@ export class HyperLiquidProvider implements PerpsProvider { return [null]; } + // Populate #cachedAllPerpDexs so buildAssetMapping can compute perpDexIndex. + // Without this, getValidatedDexs returns from #cachedValidatedDexs (string names) + // but #cachedAllPerpDexs (raw objects for index computation) stays null, + // causing "Could not find perpDexIndex for DEX xyz" failures. + this.#cachedAllPerpDexs = allDexs; + // Extract HIP-3 DEX names (filter out null which represents main DEX) const availableHip3Dexs: string[] = []; allDexs.forEach((dex) => { diff --git a/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts b/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts index a1e2877498a..95b4801741d 100644 --- a/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts @@ -3275,8 +3275,8 @@ describe('HyperLiquidSubscriptionService', () => { await jest.runAllTimersAsync(); - // Should not call meta/metaAndAssetCtxs when market data not requested - expect(mockInfoClient.meta).not.toHaveBeenCalled(); + // assetCtxs subscription is always established (lightweight, 1 per DEX) + // so meta may be called for the assetCtxs mapping, but metaAndAssetCtxs should not expect(mockInfoClient.metaAndAssetCtxs).not.toHaveBeenCalled(); unsubscribe(); diff --git a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts index 7c7cc8dda64..2b3e64732e1 100644 --- a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts +++ b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts @@ -1031,32 +1031,39 @@ export class HyperLiquidSubscriptionService { // Ensure global subscriptions are established this.#ensureGlobalAllMidsSubscription(); - // HIP-3: Establish assetCtxs subscriptions ONLY for DEXs with requested symbols - // Performance: Avoid unnecessary WebSocket connections for unused DEXs - if (includeMarketData) { - // Extract unique DEXs from requested symbols - const dexsNeeded = new Set(); - symbols.forEach((symbol) => { - const { dex } = parseAssetName(symbol); - dexsNeeded.add(dex); + // Extract unique DEXs from requested symbols + const dexsNeeded = new Set(); + symbols.forEach((symbol) => { + const { dex } = parseAssetName(symbol); + dexsNeeded.add(dex); + }); + + // Always ensure assetCtxs subscriptions (1 per DEX, lightweight). + // Provides prevDayPx for percentChange24h even without includeMarketData + // (e.g., prewarm after reconnection). Uses incrementRefCount: false when + // not explicitly requested so lifecycle is managed by component subscriptions. + dexsNeeded.forEach((dex) => { + const dexName = dex ?? ''; + this.#ensureAssetCtxsSubscription(dexName, { + incrementRefCount: includeMarketData, + }).catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.subscribeToPrices', + ), + this.#getErrorContext( + 'subscribeToPrices.ensureAssetCtxsSubscription', + { dex: dexName }, + ), + ); }); + }); - // Only subscribe to DEXs that have requested symbols + // dexAllMids and activeAssetCtx only when market data explicitly requested + if (includeMarketData) { dexsNeeded.forEach((dex) => { const dexName = dex ?? ''; - this.#ensureAssetCtxsSubscription(dexName).catch((error) => { - this.#logErrorUnlessClearing( - ensureError( - error, - 'HyperLiquidSubscriptionService.subscribeToPrices', - ), - this.#getErrorContext( - 'subscribeToPrices.ensureAssetCtxsSubscription', - { dex: dexName }, - ), - ); - }); - this.#ensureDexAllMidsSubscription(dexName).catch((error) => { this.#logErrorUnlessClearing( ensureError( @@ -1110,14 +1117,6 @@ export class HyperLiquidSubscriptionService { // Cleanup DEX-level assetCtxs subscriptions if (includeMarketData) { - // Extract unique DEXs from requested symbols - const dexsNeeded = new Set(); - symbols.forEach((symbol) => { - const { dex } = parseAssetName(symbol); - dexsNeeded.add(dex); - }); - - // Cleanup assetCtxs subscription for each DEX dexsNeeded.forEach((dex) => { const dexName = dex ?? ''; this.#cleanupAssetCtxsSubscription(dexName); diff --git a/docs/perps/perps-connection-architecture.md b/docs/perps/perps-connection-architecture.md index 300bfe6ef2d..397dd2621a5 100644 --- a/docs/perps/perps-connection-architecture.md +++ b/docs/perps/perps-connection-architecture.md @@ -67,7 +67,7 @@ graph TD - Call `connect()` on mount (when `isPerpsEnabled`) - Call `disconnect()` when app goes to background (triggers 20s grace period in Manager) -- Call `connect()` when app returns to foreground (with `ReconnectionDelayAndroidMs` stabilization delay) +- Call `ensureConnected()` when app returns to foreground (forces disconnect + fresh reconnect after stabilization delay) - Call `disconnect()` on unmount **Does NOT**: @@ -362,13 +362,14 @@ The stream hooks used by PerpsHomeView gracefully handle the not-yet-connected s ### Manager Layer Methods -| Method | Signature | Purpose | -| --------------------------- | ----------------------------------------------- | ----------------------------------------------- | -| `connect()` | `() => Promise` | Initialize connection if first provider | -| `disconnect()` | `() => Promise` | Disconnect if last provider (with grace period) | -| `reconnectWithNewContext()` | `(options?: ReconnectOptions) => Promise` | Coordinate full reconnection | -| `getConnectionState()` | `() => ConnectionState` | Get current connection state (for polling) | -| `resetError()` | `() => void` | Clear error state | +| Method | Signature | Purpose | +| --------------------------- | ----------------------------------------------- | ----------------------------------------------------- | +| `connect()` | `() => Promise` | Initialize connection if first provider | +| `disconnect()` | `() => Promise` | Disconnect if last provider (with grace period) | +| `ensureConnected()` | `() => Promise` | Foreground return: force disconnect + fresh reconnect | +| `reconnectWithNewContext()` | `(options?: ReconnectOptions) => Promise` | Coordinate full reconnection | +| `getConnectionState()` | `() => ConnectionState` | Get current connection state (for polling) | +| `resetError()` | `() => void` | Clear error state | ### Controller Layer Methods @@ -518,7 +519,7 @@ The Manager's `pendingReconnectPromise` ensures only one reconnection happens at | Account switch | `reconnectWithNewContext()` | default | Manager (automatic via Redux subscription) | Clears caches immediately before reconnection | | Network switch | `reconnectWithNewContext()` | default | Manager (automatic via Redux subscription) | Same as account switch | | App background | `disconnect()` | - | PerpsAlwaysOnProvider → Manager | Grace period (20s) before actual disconnect | -| App foreground | `connect()` | - | PerpsAlwaysOnProvider → Manager | ReconnectionDelayAndroidMs stabilization | +| App foreground | `ensureConnected()` | - | PerpsAlwaysOnProvider → Manager | Forces disconnect + reconnect after delay | ---