返回 DeepSeek-Reasonix
frameRuntime.ts
根目录 / desktop / electron / src / main / browser / frameRuntime.ts
1 import { randomUUID } from "node:crypto";
2 import { AsyncLocalStorage } from "node:async_hooks";
3 import type { GuestFrame, GuestPage } from "./guestView.js";
4 import { acquireDebugger, type DebuggerRelease, type DebuggerSender } from "./debuggerLease.js";
5 import { browserFailure } from "./errors.js";
6 import { abortable } from "./captureQueue.js";
7 import { FrameSessionPool, type FrameSessionLease } from "./frameSessionPool.js";
8
9 const operationSignals = new AsyncLocalStorage<AbortSignal>();
10 export function frameOperationSignal(): AbortSignal | undefined { return operationSignals.getStore(); }
11 export function withFrameOperationSignal<T>(signal: AbortSignal, work: () => Promise<T>): Promise<T> {
12 return operationSignals.run(signal, work);
13 }
14
15 interface Context { frameId: string; sessionId?: string; executionContextId: number; sessionFrame?: GuestFrame }
16 type Execute = (contextId: number, sessionId: string | undefined, send: DebuggerSender, sessionFrame?: GuestFrame) => Promise<unknown>;
17
18 // One operation owns its frame contexts and object groups. Root connections
19 // and target sessions have a single page-level owner; contexts are never shared.
20 export class FrameRuntime {
21 private readonly release: DebuggerRelease;
22 private readonly pool: FrameSessionPool;
23 private readonly main: GuestFrame;
24 private readonly sessions = new Map<string, string>();
25 private readonly borrowed = new Map<string, FrameSessionLease>();
26 private readonly contexts = new Map<string, Promise<Context>>();
27 private readonly objectSessions = new Set<string | undefined>();
28 private readonly group = `reasonix-frame-owner-${randomUUID()}`;
29 private root?: Promise<Context>;
30 private closed = false;
31 private failed = false;
32 private readonly signal = operationSignals.getStore();
33
34 constructor(private readonly page: GuestPage, private readonly deadline = Date.now() + 1000) {
35 this.main = page.mainFrame;
36 this.release = acquireDebugger(page.debugger);
37 this.pool = FrameSessionPool.for(page.debugger, this.release);
38 }
39
40 private assertLive(): void {
41 this.signal?.throwIfAborted();
42 if (this.closed || this.page.isDestroyed() || this.main.detached || this.page.mainFrame !== this.main) throw browserFailure("stale_document", "frame operation no longer owns the page");
43 if (Date.now() >= this.deadline) throw browserFailure("page_not_ready", "child frame protocol deadline exceeded");
44 }
45
46 private async send(method: string, params?: unknown, sessionId?: string): Promise<unknown> {
47 this.assertLive();
48 return this.wait(this.release.send(method, params, sessionId), method);
49 }
50
51 private async wait<T>(command: Promise<T>, method: string): Promise<T> {
52 let timer: ReturnType<typeof setTimeout> | undefined;
53 try {
54 const result = await Promise.race([this.signal ? abortable(command, this.signal) : command, new Promise<never>((_resolve, reject) => {
55 timer = setTimeout(() => reject(browserFailure("page_not_ready", `child frame protocol deadline exceeded at ${method}`)), Math.max(1, this.deadline - Date.now()));
56 })]);
57 this.assertLive();
58 return result;
59 } finally { clearTimeout(timer); }
60 }
61
62 private context(frameId: string, sessionId?: string, sessionFrame?: GuestFrame): Promise<Context> {
63 const key = `${sessionId ?? "root"}:${frameId}`;
64 let existing = this.contexts.get(key);
65 if (!existing) {
66 existing = this.send("Page.createIsolatedWorld", { frameId, worldName: "reasonix-browser-v1", grantUniveralAccess: false }, sessionId).then(world => {
67 const { executionContextId } = world as { executionContextId: number };
68 if (!Number.isInteger(executionContextId)) throw browserFailure("script_runtime_error", "isolated world returned no context");
69 return { frameId, sessionId, executionContextId, sessionFrame };
70 });
71 this.contexts.set(key, existing);
72 }
73 return existing;
74 }
75
76 private async attach(frameId: string): Promise<string> {
77 this.assertLive();
78 let lease = this.borrowed.get(frameId);
79 if (!lease) { lease = this.pool.borrow(frameId); this.borrowed.set(frameId, lease); }
80 return this.wait(lease.ready, "Target.attachToTarget/domain initialization");
81 }
82
83 private rootContext(): Promise<Context> {
84 return this.root ??= (async () => {
85 await this.send("Page.enable");
86 const tree = await this.send("Page.getFrameTree") as { frameTree: { frame: { id: string } } };
87 return this.context(tree.frameTree.frame.id);
88 })();
89 }
90
91 private async child(parent: Context, nativeParent: GuestFrame, nativeChild: GuestFrame): Promise<Context> {
92 const url = nativeChild.url;
93 const siblings = () => nativeParent.frames ?? nativeParent.framesInSubtree.filter(frame => frame.parent?.frameTreeNodeId === nativeParent.frameTreeNodeId);
94 const index = siblings().findIndex(frame => frame.frameTreeNodeId === nativeChild.frameTreeNodeId);
95 const verify = () => {
96 this.assertLive();
97 if (index < 0 || siblings()[index]?.frameTreeNodeId !== nativeChild.frameTreeNodeId || nativeChild.detached || nativeParent.detached || nativeChild.url !== url || nativeChild.parent?.frameTreeNodeId !== nativeParent.frameTreeNodeId) throw browserFailure("stale_document", "frame changed during resolution");
98 };
99 verify();
100 const owner = await this.send("Runtime.evaluate", { contextId: parent.executionContextId, expression: `(() => { const target = window.frames[${index}]; const find = root => { for (const el of root.querySelectorAll('*')) { if ((el.tagName === 'IFRAME' || el.tagName === 'FRAME') && el.contentWindow === target) return el; if (el.shadowRoot) { const nested = find(el.shadowRoot); if (nested) return nested; } } }; return find(document); })()`, returnByValue: false, objectGroup: this.group, timeout: 500 }, parent.sessionId) as { result?: { objectId?: string } };
101 if (!owner.result?.objectId) throw browserFailure("stale_document", "frame owner disappeared");
102 this.objectSessions.add(parent.sessionId);
103 const describe = async () => {
104 verify();
105 const result = await this.send("DOM.describeNode", { objectId: owner.result!.objectId }, parent.sessionId) as { node: { frameId?: string; backendNodeId?: number } };
106 if (!result.node.frameId) throw browserFailure("stale_document", "frame owner has no document");
107 return result.node as { frameId: string; backendNodeId?: number };
108 };
109 const original = await describe();
110 let frameId = original.frameId;
111 const targets = await this.send("Target.getTargets") as { targetInfos: { targetId: string; type: string }[] };
112 if (!targets.targetInfos.some(target => target.targetId === frameId && target.type === "iframe")) return this.context(frameId, parent.sessionId, parent.sessionFrame);
113 let sessionId = this.sessions.get(frameId);
114 if (!sessionId) {
115 let attached: string;
116 try { attached = await this.attach(frameId); }
117 catch (error) {
118 // Refresh only a failed read-only target attachment. The same retained
119 // DOM object and native frame must still identify the original owner.
120 verify();
121 await this.send("Target.getTargets");
122 const refreshed = await describe();
123 if (original.backendNodeId === undefined || original.backendNodeId !== refreshed.backendNodeId || refreshed.frameId === frameId) throw error;
124 frameId = refreshed.frameId;
125 attached = await this.attach(frameId);
126 }
127 sessionId = attached;
128 this.sessions.set(frameId, sessionId);
129 }
130 verify();
131 return this.context(frameId, sessionId, nativeChild);
132 }
133
134 async run(frame: GuestFrame, code: string, execute?: Execute): Promise<unknown> {
135 try { return await this.runFrame(frame, code, execute); }
136 catch (error) { this.failed = true; throw error; }
137 }
138
139 private async runFrame(frame: GuestFrame, code: string, execute?: Execute): Promise<unknown> {
140 this.assertLive();
141 const url = frame.url;
142 const path: GuestFrame[] = [];
143 for (let current = frame; current.frameTreeNodeId !== this.main.frameTreeNodeId;) {
144 path.unshift(current);
145 if (!current.parent) throw browserFailure("stale_document", "frame no longer has a parent");
146 current = current.parent;
147 }
148 let context = await this.rootContext();
149 let parent = this.main;
150 for (const child of path) { context = await this.child(context, parent, child); parent = child; }
151 const assertFrame = () => {
152 this.assertLive();
153 if (frame.detached || frame.url !== url) throw browserFailure("stale_document", "frame navigated during evaluation");
154 };
155 assertFrame();
156 if (execute) {
157 // Custom operations (file uploads) must use the same deadline and
158 // cancellation fence as script evaluation. Never expose the raw lease
159 // sender: a late lookup could otherwise dispatch into a departed frame.
160 const send: DebuggerSender = async (method, params, sessionId) => {
161 assertFrame();
162 const result = await this.send(method, params, sessionId);
163 assertFrame();
164 return result;
165 };
166 const result = await execute(context.executionContextId, context.sessionId, send, context.sessionFrame);
167 assertFrame();
168 return result;
169 }
170 const result = await this.send("Runtime.evaluate", { expression: code, contextId: context.executionContextId, returnByValue: true, awaitPromise: true, timeout: Math.max(1, this.deadline - Date.now()) }, context.sessionId) as { result?: { value?: unknown }; exceptionDetails?: unknown };
171 if (result.exceptionDetails) throw browserFailure("script_runtime_error", "isolated child-frame runtime failed");
172 assertFrame();
173 return result.result?.value;
174 }
175
176 async close(failed = false): Promise<void> {
177 if (this.closed) return;
178 this.failed ||= failed;
179 this.closed = true;
180 // Cleanup commands are tracked by the connection owner even after this
181 // operation's deadline. Their eventual replies cannot resurrect contexts.
182 const cleanup = (async () => {
183 const targets: Array<string | undefined> = [...this.sessions.values()].reverse();
184 targets.push(undefined);
185 for (const sessionId of targets) {
186 // The shared target stays attached for adjacent operations. Only this
187 // operation's object group is released here.
188 if (this.objectSessions.has(sessionId)) await this.release.send("Runtime.releaseObjectGroup", { objectGroup: this.group }, sessionId).catch(() => {});
189 }
190 })();
191 let timer: ReturnType<typeof setTimeout> | undefined;
192 try { await Promise.race([cleanup, new Promise<void>(resolve => { timer = setTimeout(resolve, 100); })]); }
193 finally {
194 clearTimeout(timer);
195 // Cleanup may still be using the target's objects. Release borrowed
196 // sessions only after those commands finish, even when our waiter expires.
197 void cleanup.finally(() => {
198 for (const lease of this.borrowed.values()) lease.release(this.failed || Boolean(this.signal?.aborted));
199 this.borrowed.clear(); this.release();
200 });
201 this.contexts.clear(); this.sessions.clear();
202 }
203 }
204 }
205
206 export async function runChildFrame(page: GuestPage, frame: GuestFrame, code: string, execute?: Execute): Promise<unknown> {
207 const runtime = new FrameRuntime(page);
208 try { return await runtime.run(frame, code, execute); }
209 finally { await runtime.close(); }
210 }
211
211 lines TYPESCRIPT