From adc3296acccbecf7be7615aefd97fead175cdb3a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 19:16:15 -0400 Subject: [PATCH] fix(page): retire host subscriptions after their terminal frame The shell ends a host stream after end/error even when the screen stays mounted; the page now drops the entry so a re-subscribe cannot deliver twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../mobile-web-bridge-subscription-client.ts | 16 +++++++-- ...ile-web-bridge-subscription-closed.test.ts | 36 +++++++++++++++++++ .../src/mobile-web-host-subscription-setup.ts | 9 +++++ ...b-source-control-host-subscription.test.ts | 3 +- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/mobile-web/src/mobile-web-bridge-subscription-client.ts b/src/mobile-web/src/mobile-web-bridge-subscription-client.ts index 5a4610236f3..1b950ff3364 100644 --- a/src/mobile-web/src/mobile-web-bridge-subscription-client.ts +++ b/src/mobile-web/src/mobile-web-bridge-subscription-client.ts @@ -1,6 +1,9 @@ import { subscribeHostSession } from './mobile-web-session-host-subscription' import { subscribeMobileWebHostWorkspace } from './mobile-web-host-workspace-subscription' -import { hostSubscriptionSetup } from './mobile-web-host-subscription-setup' +import { + hostSubscriptionSetup, + isMobileWebHostSubscriptionEnd +} from './mobile-web-host-subscription-setup' import type { MobileWebBridgeCapability, MobileWebBridgePageMessage, @@ -135,7 +138,16 @@ export class MobileWebBridgeSubscriptionClient { requestId, nextSequence: 0, eventSchema: setup.eventSchema, - onEvent: setup.onEvent, + onEvent: (event) => { + try { + setup.onEvent(event) + } finally { + // The shell retires host streams after their terminal frame, even if the screen stays mounted. + if (setup.operation === 'hostSubscribe' && isMobileWebHostSubscriptionEnd(event)) { + this.unsubscribe(subscriptionId, subscription) + } + } + }, onError: setup.onError } this.active.set(subscriptionId, subscription) diff --git a/src/mobile-web/src/mobile-web-bridge-subscription-closed.test.ts b/src/mobile-web/src/mobile-web-bridge-subscription-closed.test.ts index aa6f503f531..8c9ab17f6af 100644 --- a/src/mobile-web/src/mobile-web-bridge-subscription-closed.test.ts +++ b/src/mobile-web/src/mobile-web-bridge-subscription-closed.test.ts @@ -75,6 +75,42 @@ function createHarness() { } describe('subscriptionClosed', () => { + it.each(['end', 'error'])('retires a host stream after delivering %s', async (type) => { + const { client, messages } = createHarness() + const onEvent = vi.fn() + const onError = vi.fn() + const subscription = client.hostSubscribe( + { method: 'mobileWeb.files.watch', params: {} }, + onEvent, + onError + ) + client.receive(subscriptionResponse()) + await subscription.ready + const event: MobileWebBridgeShellMessage = { + ...envelope(), + type: 'event', + subscriptionId: SUBSCRIPTION_ID, + sequence: 0, + payload: { type } + } + + client.receive(event) + client.receive({ ...event, sequence: 1 }) + client.receive(subscriptionClosed('unavailable', true)) + + expect(onEvent).toHaveBeenCalledExactlyOnceWith({ type }) + expect(onError).not.toHaveBeenCalled() + expect(messages.at(-1)).toMatchObject({ + type: 'cancel', + target: 'subscription', + id: SUBSCRIPTION_ID + }) + const count = messages.length + subscription.unsubscribe() + client.dispose() + expect(messages).toHaveLength(count) + }) + it('rejects readiness and cancels the subscription on an invalid acknowledgement', async () => { const { client, messages } = createHarness() const onError = vi.fn() diff --git a/src/mobile-web/src/mobile-web-host-subscription-setup.ts b/src/mobile-web/src/mobile-web-host-subscription-setup.ts index 31890c6cf78..908c1715437 100644 --- a/src/mobile-web/src/mobile-web-host-subscription-setup.ts +++ b/src/mobile-web/src/mobile-web-host-subscription-setup.ts @@ -6,6 +6,15 @@ import { import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' import type { MobileWebBridgeSubscriptionSetup } from './mobile-web-bridge-subscription-setup' +export function isMobileWebHostSubscriptionEnd(event: unknown): boolean { + return ( + typeof event === 'object' && + event !== null && + 'type' in event && + (event.type === 'end' || event.type === 'error') + ) +} + export function hostSubscriptionSetup( payload: MobileWebHostRequestPayload, onEvent: (event: unknown) => void, diff --git a/src/mobile-web/src/mobile-web-source-control-host-subscription.test.ts b/src/mobile-web/src/mobile-web-source-control-host-subscription.test.ts index d29f3f3f75a..d98a3cbd4dc 100644 --- a/src/mobile-web/src/mobile-web-source-control-host-subscription.test.ts +++ b/src/mobile-web/src/mobile-web-source-control-host-subscription.test.ts @@ -70,13 +70,12 @@ describe('host-projected Source Control subscription', () => { expect(h.events).toEqual([]) }) - it('keeps a normal end-of-watch retryable so the page subscription is not closed', () => { + it('reports a normal end-of-watch as retryable', () => { const h = harness() h.deliver({ type: 'end' }) expect(h.errors).toEqual([{ code: 'unavailable', retryable: true }]) - expect(h.unsubscribe).not.toHaveBeenCalled() }) it('rejects a malformed payload before it reaches the host', async () => {