Skip to content

Commit a1f91e5

Browse files
committed
fix(devframe): cancel pending relay handshakes when panels close
A panel could close before receiving its port while the relay kept retrying. A later grant then registered an unused peer indefinitely when heartbeat was disabled. Send cancellation through the window handshake and validate its source and identity before closing the relay connection. Cover late page scripts, delayed grants, and cancellation isolation.
1 parent c7a2862 commit a1f91e5

5 files changed

Lines changed: 117 additions & 26 deletions

File tree

docs/content/8.references/3.events.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,4 @@ Pushed over each panel's [in-page channel](/guide/in-page-channel) port; the pai
136136
| Name | Posted by | Carries |
137137
|---|---|---|
138138
| `devframe:remote-assets-error` | the remote-assets fallback page, to `window.parent` | The failed package/version/reason, so an embedding hub UI provider can replace the 502 page. |
139-
| `devframe:in-page-channel` | both [in-page channel](/guide/in-page-channel) endpoints, across window boundaries | The versioned handshake envelope (panel hello, page-script port grant). |
139+
| `devframe:in-page-channel` | both [in-page channel](/guide/in-page-channel) endpoints, across window boundaries | The versioned handshake envelope (panel hello, page-script port grant, panel cancellation). |

packages/devframe/src/in-page-channel/panel.ts

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -201,29 +201,33 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(
201201
warnOnce(`in-page channel "${name}": transport lost (${reason}) and the panel has no handshake targets, so it stays disconnected`)
202202
}
203203

204+
function postHandshake(kind: 'hello' | 'cancel'): void {
205+
const message = {
206+
channel: IN_PAGE_CHANNEL_TAG,
207+
v: IN_PAGE_CHANNEL_VERSION,
208+
kind,
209+
name,
210+
panelId,
211+
instanceId: options.instanceId,
212+
}
213+
for (const target of targets) {
214+
for (const origin of allowedOrigins) {
215+
try {
216+
target.postMessage(message, origin)
217+
}
218+
catch {
219+
// An unreachable target must not block the remaining targets.
220+
}
221+
}
222+
}
223+
}
224+
204225
function startHelloLoop(): void {
205226
if (helloTimer || !canHandshake || status !== 'connecting')
206227
return
207228
let delay = options.helloIntervalMs ?? DEFAULT_HELLO_INTERVAL_MS
208229
const tick = (): void => {
209-
const hello = {
210-
channel: IN_PAGE_CHANNEL_TAG,
211-
v: IN_PAGE_CHANNEL_VERSION,
212-
kind: 'hello' as const,
213-
name,
214-
panelId,
215-
instanceId: options.instanceId,
216-
}
217-
for (const target of targets) {
218-
for (const origin of allowedOrigins) {
219-
try {
220-
target.postMessage(hello, origin)
221-
}
222-
catch {
223-
// Unreachable target/origin pair; the loop keeps retrying.
224-
}
225-
}
226-
}
230+
postHandshake('hello')
227231
delay = Math.min(delay * 1.5, HELLO_INTERVAL_CAP_MS)
228232
helloTimer = setTimeout(tick, delay)
229233
}
@@ -302,6 +306,9 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(
302306
setStatus('closed')
303307
disposeAgentTools()
304308
stopTimers()
309+
// Pending relays have no port on which to receive the graceful bye yet.
310+
if (canHandshake)
311+
postHandshake('cancel')
305312
win?.removeEventListener('message', onWindowMessage)
306313
attached?.dispose({ bye: true, reason: 'the panel closed the channel' })
307314
attached = undefined

packages/devframe/src/in-page-channel/protocol.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ export const IN_PAGE_CHANNEL_VERSION = 2
1818
/**
1919
* The handshake envelope. A panel posts a `hello` ("grant me a port for
2020
* channel `name`"); the page script answers with a `grant`, the dedicated
21-
* `MessagePort` transferred alongside.
21+
* `MessagePort` transferred alongside. A closing panel posts `cancel` so
22+
* relays can release pending handshakes before a port has been granted.
2223
*/
2324
export interface InPageChannelHandshakeMessage {
2425
channel: typeof IN_PAGE_CHANNEL_TAG
2526
v: number
26-
kind: 'hello' | 'grant'
27+
kind: 'hello' | 'grant' | 'cancel'
2728
/** User channel name (e.g. `devframes:plugin:a11y`). */
2829
name: string
2930
/** The asking panel's id (grants echo it, so a panel matches its own hello). */
@@ -52,7 +53,7 @@ export function isHandshakeMessage(data: unknown): data is InPageChannelHandshak
5253
return message.channel === IN_PAGE_CHANNEL_TAG
5354
&& typeof message.name === 'string'
5455
&& typeof message.panelId === 'string'
55-
&& (message.kind === 'hello' || message.kind === 'grant')
56+
&& (message.kind === 'hello' || message.kind === 'grant' || message.kind === 'cancel')
5657
}
5758

5859
const INSTANCE_STORAGE_KEY = 'devframe:in-page-channel:instance'

packages/devframe/src/in-page-channel/relay.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,80 @@ it('leaves direct in-page grants available to their own panel', async () => {
308308
await expect(direct.call('highlight', '#direct')).resolves.toBe('/:#direct')
309309
expect(s.pageScript.panels).toHaveLength(2)
310310
})
311+
312+
it('stops pending handshakes when the panel closes before the page script loads', async () => {
313+
const s = session('/')
314+
s.pageScript.close()
315+
const post = vi.spyOn(s.page.win, 'postMessage')
316+
await vi.waitFor(() => expect(post).toHaveBeenCalled())
317+
expect(s.panel.status).toBe('connecting')
318+
s.panel.close()
319+
await new Promise(resolve => setTimeout(resolve, 0))
320+
post.mockClear()
321+
322+
const pageScript = createPageScriptChannel<Protocol>({
323+
name: 'devframes:relay-test',
324+
window: s.page.window,
325+
heartbeat: false,
326+
functions: { highlight: { handler: s.highlight } },
327+
})
328+
cleanup.push(() => pageScript.close())
329+
await new Promise(resolve => setTimeout(resolve, 1200))
330+
expect(s.panel.status).toBe('closed')
331+
expect(pageScript.panels).toHaveLength(0)
332+
expect(post).not.toHaveBeenCalled()
333+
})
334+
335+
it.each(['page', 'transport'] as const)('cleans a pending %s grant when only the panel closes', async (boundary) => {
336+
const s = session('/')
337+
let release!: () => void
338+
if (boundary === 'page') {
339+
const post = s.page.win.postMessage
340+
s.page.win.postMessage = (data, origin, ports) => {
341+
if ((data as { kind: string }).kind === 'grant')
342+
release = () => post(data, origin, ports)
343+
else
344+
post(data, origin, ports)
345+
}
346+
}
347+
else {
348+
const send = s.transport.page.postMessage
349+
s.transport.page.postMessage = (data) => {
350+
if ((data as { kind: string }).kind === 'grant')
351+
release = () => send(data)
352+
else
353+
send(data)
354+
}
355+
}
356+
await vi.waitFor(() => expect(release).toBeTypeOf('function'))
357+
expect(s.panel.status).toBe('connecting')
358+
s.panel.close()
359+
await new Promise(resolve => setTimeout(resolve, 0))
360+
release()
361+
await vi.waitFor(() => expect(s.pageScript.panels).toHaveLength(0))
362+
})
363+
364+
it('only cancels a handshake from its owning window and matching identity', async () => {
365+
const s = session('/')
366+
s.pageScript.close()
367+
const send = vi.spyOn(s.transport.panel, 'postMessage')
368+
await vi.waitFor(() => expect(send).toHaveBeenCalled())
369+
const open = send.mock.calls[0]![0] as { id: string, handshake: object }
370+
const cancel = { ...open.handshake, kind: 'cancel' }
371+
const sibling = fakeWindow()
372+
sibling.win.parent = s.viewer.window
373+
const dispatch = (data: object, source = s.panelWindow.window, origin = 'https://app.test') => {
374+
s.viewer.win.dispatch({ data, source, origin })
375+
}
376+
dispatch(cancel, sibling.window)
377+
dispatch(cancel, s.panelWindow.window, 'https://other.test')
378+
dispatch({ ...cancel, v: 99 })
379+
dispatch({ ...cancel, name: 'another-channel' })
380+
dispatch({ ...cancel, panelId: 'another-panel' })
381+
dispatch({ ...cancel, instanceId: 'another-instance' })
382+
await new Promise(resolve => setTimeout(resolve, 0))
383+
expect(send.mock.calls.some(([data]) => (data as { kind: string }).kind === 'close')).toBe(false)
384+
385+
s.panel.close()
386+
await vi.waitFor(() => expect(send).toHaveBeenCalledWith(expect.objectContaining({ id: open.id, kind: 'close' })))
387+
})

packages/devframe/src/in-page-channel/relay.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ interface RelayConnection {
3333
retryTimer?: ReturnType<typeof setTimeout>
3434
}
3535

36-
function validHandshake(data: unknown, kind: 'hello' | 'grant'): data is InPageChannelHandshakeMessage {
36+
function validHandshake(data: unknown, kind: InPageChannelHandshakeMessage['kind']): data is InPageChannelHandshakeMessage {
3737
return isHandshakeMessage(data)
3838
&& data.v === IN_PAGE_CHANNEL_VERSION
3939
&& data.kind === kind
@@ -137,8 +137,8 @@ export function createInPageChannelRelay(options: InPageChannelRelayOptions): ()
137137
&& (!hello.instanceId || grant.instanceId === hello.instanceId)
138138
}
139139

140-
function onPanelHello(event: MessageEvent): void {
141-
if (!validHandshake(event.data, 'hello') || !event.source
140+
function onPanelHandshake(event: MessageEvent): void {
141+
if ((!validHandshake(event.data, 'hello') && !validHandshake(event.data, 'cancel')) || !event.source
142142
|| !isDescendant(event.source as Window, win)) {
143143
return
144144
}
@@ -147,12 +147,18 @@ export function createInPageChannelRelay(options: InPageChannelRelayOptions): ()
147147
for (const [key, connection] of connections) {
148148
if (connection.source === event.source && connection.hello.panelId === hello.panelId
149149
&& connection.hello.name === hello.name && connection.hello.instanceId === hello.instanceId) {
150+
if (hello.kind === 'cancel') {
151+
close(key)
152+
return
153+
}
150154
if (connection.port)
151155
return
152156
id = key
153157
break
154158
}
155159
}
160+
if (hello.kind === 'cancel')
161+
return
156162
id ??= nanoid()
157163
connections.set(id, { hello, source: event.source as Window })
158164
send({ id, kind: 'open', handshake: hello })
@@ -187,7 +193,7 @@ export function createInPageChannelRelay(options: InPageChannelRelayOptions): ()
187193
if (disposed || event.origin !== origin)
188194
return
189195
if (options.role === 'panel')
190-
onPanelHello(event)
196+
onPanelHandshake(event)
191197
else
192198
onPageGrant(event)
193199
}

0 commit comments

Comments
 (0)