| 1 | import type { DebuggerRelease } from "./debuggerLease.js"; |
| 2 | import type { GuestDebugger } from "./guestView.js"; |
| 3 | |
| 4 | const key = {}; |
| 5 | interface ChildSession { sessionId?: string; ready: Promise<string>; users: number; retire: boolean; detached: boolean } |
| 6 | export interface FrameSessionLease { ready: Promise<string>; release(failed: boolean): void } |
| 7 | |
| 8 | // Target attachments belong to the root connection, not to one snapshot or |
| 9 | // ref lookup. Detaching between adjacent operations tears down the renderer's |
| 10 | // domain agents while the next operation may already be initializing them. |
| 11 | // Document contexts and remote object groups remain operation-scoped. |
| 12 | export class FrameSessionPool { |
| 13 | private readonly sessions = new Map<string, ChildSession>(); |
| 14 | private closed = false; |
| 15 | private readonly message = (_event: unknown, method: string, raw: unknown) => { |
| 16 | if (method !== "Target.detachedFromTarget") return; |
| 17 | const { sessionId } = raw as { sessionId?: string }; |
| 18 | for (const [target, child] of this.sessions) if (child.sessionId === sessionId) this.sessions.delete(target); |
| 19 | }; |
| 20 | private constructor(private readonly debuggerAPI: GuestDebugger, private readonly connection: DebuggerRelease) { |
| 21 | debuggerAPI.on("message", this.message); |
| 22 | } |
| 23 | static for(debuggerAPI: GuestDebugger, connection: DebuggerRelease): FrameSessionPool { |
| 24 | return connection.shared(key, () => new FrameSessionPool(debuggerAPI, connection)); |
| 25 | } |
| 26 | borrow(targetId: string): FrameSessionLease { |
| 27 | if (this.closed) throw new Error("browser frame session pool is closed"); |
| 28 | const existing = this.sessions.get(targetId); |
| 29 | if (existing) return this.lease(targetId, existing); |
| 30 | const child: ChildSession = { ready: Promise.resolve(""), users: 0, retire: false, detached: false }; |
| 31 | this.sessions.set(targetId, child); |
| 32 | child.ready = (async () => { |
| 33 | const attached = await this.connection.send("Target.attachToTarget", { targetId, flatten: true }) as { sessionId: string }; |
| 34 | child.sessionId = attached.sessionId; |
| 35 | if (this.closed || this.sessions.get(targetId) !== child) { |
| 36 | throw new Error("browser frame session was invalidated during attachment"); |
| 37 | } |
| 38 | for (const method of ["Page.enable", "Runtime.enable", "DOM.enable"]) { |
| 39 | await this.connection.send(method, {}, attached.sessionId); |
| 40 | if (this.closed || this.sessions.get(targetId) !== child) throw new Error("browser frame session was detached during initialization"); |
| 41 | } |
| 42 | return attached.sessionId; |
| 43 | })().catch(error => { |
| 44 | if (this.sessions.get(targetId) === child) this.sessions.delete(targetId); |
| 45 | this.detachChild(child); |
| 46 | throw error; |
| 47 | }); |
| 48 | return this.lease(targetId, child); |
| 49 | } |
| 50 | private lease(target: string, child: ChildSession): FrameSessionLease { |
| 51 | child.users++; |
| 52 | let released = false; |
| 53 | return { ready: child.ready, release: failed => { |
| 54 | if (released) return; |
| 55 | released = true; |
| 56 | child.users--; child.retire ||= failed; |
| 57 | // Cancellation never detaches a session borrowed by another operation. |
| 58 | // An abandoned attachment is removed now and its late result cleans itself. |
| 59 | if (!child.users && child.retire) { |
| 60 | if (this.sessions.get(target) === child) this.sessions.delete(target); |
| 61 | this.detachChild(child); |
| 62 | } |
| 63 | } }; |
| 64 | } |
| 65 | private detachChild(child: ChildSession): void { |
| 66 | if (!child.sessionId || child.detached) return; |
| 67 | child.detached = true; |
| 68 | this.detach(child.sessionId); |
| 69 | } |
| 70 | private detach(sessionId: string): void { |
| 71 | // Cleanup owns only this opaque session ID. A late attach response cannot |
| 72 | // dispatch through, or clear resources belonging to, a replacement pool. |
| 73 | try { if (this.debuggerAPI.isAttached()) void this.debuggerAPI.sendCommand("Target.detachFromTarget", { sessionId }).catch(() => {}); } catch { /* Native page already destroyed. */ } |
| 74 | } |
| 75 | dispose(transportAvailable: boolean): void { |
| 76 | if (this.closed) return; |
| 77 | this.closed = true; |
| 78 | this.debuggerAPI.removeListener("message", this.message); |
| 79 | if (transportAvailable && !this.connection.owned) for (const child of [...this.sessions.values()].reverse()) this.detachChild(child); |
| 80 | this.sessions.clear(); |
| 81 | } |
| 82 | } |
| 83 |