From 3a2a59a63530909c7b7ca5bab18b487ec62b7fa5 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Thu, 22 Oct 2026 23:00:38 +0800 Subject: [PATCH] fix(liveshare-protocol): release a stream whose unreadable open reached the owner A 200 open whose body was not a JSON object threw from readable() before the abort listener was armed, so the page never sent stream-cancel and the owner held the id until its own budget expired. The unreadable-open path now cancels the id before rethrowing; the spec pins the cancel. --- .../liveshare-protocol/src/viewer-carrier.ts | 14 +++++++++++--- .../tests/viewer-carrier.client.spec.ts | 3 ++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/experimental/liveshare-protocol/src/viewer-carrier.ts b/packages/experimental/liveshare-protocol/src/viewer-carrier.ts index 1221b4a672..b7a305c5ba 100644 --- a/packages/experimental/liveshare-protocol/src/viewer-carrier.ts +++ b/packages/experimental/liveshare-protocol/src/viewer-carrier.ts @@ -14,7 +14,7 @@ * {@link installViewerCarrier} runs in the browser from its own source text, so it must reference no * module binding: every constant and helper it needs is declared inside its body. * - * @module @deepseek-ai/dsh-experimental-liveshare-protocol/src/viewer-carrier + * @module @deepseek-ai/dsh-experimental-liveshare-protocol */ /** Page global naming the share one viewer page presents; the Liveshare client plugin reads it. */ @@ -227,7 +227,7 @@ export function installViewerCarrier(options: ViewerCarrierOptions): void { const id = nextStreamId++ signal.throwIfAborted() // Each path below sends this at most once: the abort listener only runs on abort, the final - // block only without one, and the open paths return before either is armed. + // block only without one, and an open that ends the stream cancels here before either is armed. const cancel = (): void => { void post({ op: 'stream-cancel', id }, endpoint).catch(() => undefined) } @@ -247,7 +247,15 @@ export function installViewerCarrier(options: ViewerCarrierOptions): void { // no stream for it: polling that id would only read `stream-not-found`, which looks like a lost // logical stream and would be re-opened forever. The frame's own code decides instead — an owner // refusal such as `remote-access/denied` is terminal, while a lost stream stays re-openable. - const refusedOpen = failure(readable(opened, endpoint), endpoint) + let openedBody: Record + try { + openedBody = readable(opened, endpoint) + } catch (error) { + // A 200 answer the page cannot read reached the owner, which may already hold this stream id. + if (opened.status === 200) cancel() + throw error + } + const refusedOpen = failure(openedBody, endpoint) if (refusedOpen !== undefined) throw refusedOpen signal.addEventListener('abort', cancel, { once: true }) try { diff --git a/packages/experimental/liveshare-protocol/tests/viewer-carrier.client.spec.ts b/packages/experimental/liveshare-protocol/tests/viewer-carrier.client.spec.ts index 32b1ff8e8c..9c428becab 100644 --- a/packages/experimental/liveshare-protocol/tests/viewer-carrier.client.spec.ts +++ b/packages/experimental/liveshare-protocol/tests/viewer-carrier.client.spec.ts @@ -230,7 +230,8 @@ describe('viewer carrier streams', () => { expect(posted.map(request => request.op)).toEqual(['stream-open', 'stream-next', 'stream-cancel']) const empty = await readStream([{ status: 200, body: [] }]) expect(markerOf(empty.error)).toEqual({ kind: 'carrier' }) - expect(empty.posted.map(request => request.op)).toEqual(['stream-open']) + // The 200 open reached the owner, so a body the page cannot read still releases the stream id. + expect(empty.posted.map(request => request.op)).toEqual(['stream-open', 'stream-cancel']) }) it('keeps an authentication refusal terminal whatever code its body carries', async () => {