Skip to content

Commit 8f285e0

Browse files
Harden settle listener cleanup against method getter traps
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 40b24c2 commit 8f285e0

3 files changed

Lines changed: 98 additions & 3 deletions

File tree

currentState.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ HyperAgent exposes a TypeScript SDK for browser automation with three primary pa
147147
- Hardened global debug-option storage by normalizing option payloads to plain boolean fields at set-time, preventing trap-prone debug option getters from leaking into runtime reads.
148148
- Hardened page-URL normalization option reads (fallback/maxChars) against trap-prone option objects, ensuring deterministic URL fallback/truncation behavior under malformed option payloads.
149149
- Hardened `waitForSettledDOM` option reads for frame filtering with trap-safe accessors, so malformed/trap-prone option objects no longer break settle flow or frame-manager configuration.
150+
- Hardened wait-listener lifecycle cleanup against trap-prone session listener-method getters, preserving settle completion while emitting sanitized detach diagnostics.
150151
- Hardened prompt base-message materialization with trap-safe array reads so malformed/trap-prone seed message arrays no longer crash message assembly and readable entries are preserved.
151152
- Hardened constructor custom-action ingestion with trap-safe array reads so unreadable custom-action entries are skipped while valid entries continue to register.
152153
- Expanded top-level package exports for key workflow/config types at `@hyperbrowser/agent`.

src/utils/waitForSettledDOM.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,58 @@ describe("waitForSettledDOM diagnostics", () => {
430430
}
431431
});
432432

433+
it("sanitizes and truncates listener detach getter diagnostics", async () => {
434+
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
435+
const { session } = createSessionWithEvents();
436+
const trappedSession = new Proxy(session, {
437+
get: (target, prop, receiver) => {
438+
if (prop === "off") {
439+
throw new Error(`off getter\u0000\n${"x".repeat(10_000)}`);
440+
}
441+
return Reflect.get(target, prop, receiver);
442+
},
443+
}) as CDPSession;
444+
const cdpClient: CDPClient = {
445+
rootSession: trappedSession,
446+
createSession: async () => trappedSession,
447+
acquireSession: async () => trappedSession,
448+
dispose: async () => undefined,
449+
};
450+
getCDPClient.mockResolvedValue(cdpClient);
451+
getOrCreateFrameContextManager.mockReturnValue({
452+
setDebug: jest.fn(),
453+
});
454+
getDebugOptions.mockReturnValue({
455+
enabled: false,
456+
traceWait: false,
457+
});
458+
459+
const page = {
460+
context: () => ({}),
461+
} as never;
462+
463+
try {
464+
const waitPromise = waitForSettledDOM(page, 2000);
465+
await Promise.resolve();
466+
await Promise.resolve();
467+
await jest.advanceTimersByTimeAsync(600);
468+
const stats = await waitPromise;
469+
470+
const detachWarning = String(
471+
warnSpy.mock.calls.find((call) =>
472+
String(call[0] ?? "").includes("Failed to detach listener")
473+
)?.[0] ?? ""
474+
);
475+
expect(detachWarning).toContain("[truncated");
476+
expect(detachWarning).not.toContain("\u0000");
477+
expect(detachWarning).not.toContain("\n");
478+
expect(stats.resolvedByTimeout).toBe(false);
479+
expect(stats.requestsSeen).toBe(0);
480+
} finally {
481+
warnSpy.mockRestore();
482+
}
483+
});
484+
433485
it("sanitizes and truncates listener detach diagnostics", async () => {
434486
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
435487
const { session } = createSessionWithEvents({

src/utils/waitForSettledDOM.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,33 @@ function attachSessionListener<TPayload extends unknown[]>(
8787
event: string,
8888
handler: (...payload: TPayload) => void
8989
): boolean {
90+
let onMethod: unknown;
9091
try {
91-
session.on(event, handler);
92+
onMethod = (session as unknown as { on?: unknown }).on;
93+
} catch (error) {
94+
console.warn(
95+
`[waitForSettledDOM] Failed to attach listener ${formatWaitIdentifier(
96+
event
97+
)}: ${formatWaitDiagnostic(error)}`
98+
);
99+
return false;
100+
}
101+
if (typeof onMethod !== "function") {
102+
console.warn(
103+
`[waitForSettledDOM] Failed to attach listener ${formatWaitIdentifier(
104+
event
105+
)}: listener method unavailable`
106+
);
107+
return false;
108+
}
109+
try {
110+
(
111+
onMethod as (
112+
this: CDPSession,
113+
event: string,
114+
handler: (...payload: unknown[]) => void
115+
) => void
116+
).call(session, event, handler as (...payload: unknown[]) => void);
92117
return true;
93118
} catch (error) {
94119
console.warn(
@@ -105,11 +130,28 @@ function detachSessionListener<TPayload extends unknown[]>(
105130
event: string,
106131
handler: (...payload: TPayload) => void
107132
): void {
108-
if (!session.off) {
133+
let offMethod: unknown;
134+
try {
135+
offMethod = (session as unknown as { off?: unknown }).off;
136+
} catch (error) {
137+
console.warn(
138+
`[waitForSettledDOM] Failed to detach listener ${formatWaitIdentifier(
139+
event
140+
)}: ${formatWaitDiagnostic(error)}`
141+
);
142+
return;
143+
}
144+
if (typeof offMethod !== "function") {
109145
return;
110146
}
111147
try {
112-
session.off(event, handler);
148+
(
149+
offMethod as (
150+
this: CDPSession,
151+
event: string,
152+
handler: (...payload: unknown[]) => void
153+
) => void
154+
).call(session, event, handler as (...payload: unknown[]) => void);
113155
} catch (error) {
114156
console.warn(
115157
`[waitForSettledDOM] Failed to detach listener ${formatWaitIdentifier(

0 commit comments

Comments
 (0)