From 065586a9857ba5b66c2e86b3743dfdfd074f6558 Mon Sep 17 00:00:00 2001 From: Matteo Boetti Date: Mon, 28 Sep 2026 14:56:28 +0200 Subject: [PATCH] Reload the application once so first-run breakpoints are hit A launch installs and starts the app, which finishes evaluating its bundle well before the CDP proxy manages to attach. Hermes replays scriptParsed on Debugger.enable, so breakpoints registered for that first run resolve and look bound, but the code they belong to has already executed and they are never reached. Reloading by hand makes them work, which is what users hit today. Hermes has no pause-on-load to wait for: it answers Runtime.runIfWaitingForDebugger with "Unsupported method" (-32601), so the only lever is to run the bundle again once the debugger is ready. Send a single Page.reload after the debugger goes quiet following its initial breakpoint requests. Reloads are driven by the device (the nativePageReloads capability), so the debugger stays attached and the breakpoints re-bind. The request is sent out of band with an id the debugger never issued, so it discards the reply as an unknown response id. Co-Authored-By: Claude Opus 5 (1M context) --- .../CDPMessageHandlers/CDPAPINames.ts | 1 + .../hermesCDPMessageHandler.ts | 66 ++++++++++++ .../cdp-proxy/hermesCDPMessageHandler.test.ts | 101 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 test/cdp-proxy/hermesCDPMessageHandler.test.ts diff --git a/src/cdp-proxy/CDPMessageHandlers/CDPAPINames.ts b/src/cdp-proxy/CDPMessageHandlers/CDPAPINames.ts index e005635f9..710553244 100644 --- a/src/cdp-proxy/CDPMessageHandlers/CDPAPINames.ts +++ b/src/cdp-proxy/CDPMessageHandlers/CDPAPINames.ts @@ -12,4 +12,5 @@ export const CDP_API_NAMES = { CLOSE: "close", DEBUGGER_SCRIPT_PARSED: "Debugger.scriptParsed", DEBUGGER_SET_BREAKPOINT_BY_URL: "Debugger.setBreakpointByUrl", + PAGE_RELOAD: "Page.reload", }; diff --git a/src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler.ts b/src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler.ts index 82314e4ec..02b5f748d 100644 --- a/src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler.ts +++ b/src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler.ts @@ -19,8 +19,34 @@ export class HermesCDPMessageHandler extends BaseCDPMessageHandler { */ private readonly HERMES_NATIVE_FUNCTION_SCRIPT_ID: string = "4294967295"; + /** + * @description Quiet period after the last breakpoint request before the application + * is reloaded, so that a burst of breakpoint requests causes a single reload + * @type {number} + */ + private readonly RELOAD_DEBOUNCE_MS: number = 300; + + /** + * @description Id of the out-of-band Page.reload request. The debugger never sent it, + * so it discards the reply as an unknown response id + * @type {number} + */ + private readonly RELOAD_REQUEST_ID: number = 2147483646; + + private isFirstRun: boolean = true; + private reloadTimer: NodeJS.Timeout | null = null; + public processDebuggerCDPMessage(event: any): ProcessedCDPMessage { let sendBack = false; + if (event.method === CDP_API_NAMES.CLOSE) { + this.cancelFirstRunReload(); + } else if ( + event.method === CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT || + event.method === CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL + ) { + this.scheduleFirstRunReload(); + } + if (event.method === CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT) { event = this.handleBreakpointSetting(event); } else if (event.method === CDP_API_NAMES.RUNTIME_CALL_FUNCTION_ON) { @@ -100,6 +126,46 @@ export class HermesCDPMessageHandler extends BaseCDPMessageHandler { return event; } + /** + * The application finishes executing its bundle before the debugger manages to attach, + * so breakpoints registered for the first run resolve but are never reached. Reload the + * application once the debugger has finished sending its initial breakpoints, so that the + * bundle is evaluated again while they are in place. Reloading is driven by the device + * (the "nativePageReloads" capability), so the debugger stays attached across it. + */ + private scheduleFirstRunReload(): void { + if (!this.isFirstRun) { + return; + } + + if (this.reloadTimer) { + clearTimeout(this.reloadTimer); + } + + this.reloadTimer = setTimeout(() => { + this.reloadTimer = null; + + if (!this.isFirstRun) { + return; + } + this.isFirstRun = false; + + this.applicationTarget?.send({ + id: this.RELOAD_REQUEST_ID, + method: CDP_API_NAMES.PAGE_RELOAD, + params: {}, + }); + }, this.RELOAD_DEBOUNCE_MS); + } + + private cancelFirstRunReload(): void { + if (this.reloadTimer) { + clearTimeout(this.reloadTimer); + this.reloadTimer = null; + } + this.isFirstRun = true; + } + private handleBreakpointSetting(event: any): any { if (event.params) { delete event.params.location.columnNumber; diff --git a/test/cdp-proxy/hermesCDPMessageHandler.test.ts b/test/cdp-proxy/hermesCDPMessageHandler.test.ts new file mode 100644 index 000000000..91a630af6 --- /dev/null +++ b/test/cdp-proxy/hermesCDPMessageHandler.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +import { Connection } from "vscode-cdp-proxy"; +import { HermesCDPMessageHandler } from "../../src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler"; +import { CDP_API_NAMES } from "../../src/cdp-proxy/CDPMessageHandlers/CDPAPINames"; +import { PromiseUtil } from "../../src/common/node/promise"; +import assert = require("assert"); + +suite("hermesCDPMessageHandler", function () { + // Must outlast RELOAD_DEBOUNCE_MS (300) in the handler + const afterDebounce = 500; + + function createHandler(): { + handler: HermesCDPMessageHandler; + sent: Array>; + } { + const handler = new HermesCDPMessageHandler(); + const sent: Array> = []; + handler.setApplicationTarget({ + send: (message: Record) => sent.push(message), + } as unknown as Connection); + return { handler, sent }; + } + + suite("first run reload", function () { + test("reloads the application once the debugger has sent its breakpoints", async () => { + const { handler, sent } = createHandler(); + + handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL, + params: {}, + }); + await PromiseUtil.delay(afterDebounce); + + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0].method, CDP_API_NAMES.PAGE_RELOAD); + }); + + test("coalesces a burst of breakpoints into a single reload", async () => { + const { handler, sent } = createHandler(); + + for (let i = 0; i < 5; i++) { + handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL, + params: {}, + }); + } + await PromiseUtil.delay(afterDebounce); + + assert.strictEqual(sent.length, 1); + }); + + test("does not reload again for later breakpoints in the same session", async () => { + const { handler, sent } = createHandler(); + + handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL, + params: {}, + }); + await PromiseUtil.delay(afterDebounce); + + handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL, + params: {}, + }); + await PromiseUtil.delay(afterDebounce); + + assert.strictEqual(sent.length, 1); + }); + + test("does not reload after the debugger disconnects", async () => { + const { handler, sent } = createHandler(); + + handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL, + params: {}, + }); + handler.processDebuggerCDPMessage({ method: CDP_API_NAMES.CLOSE }); + await PromiseUtil.delay(afterDebounce); + + assert.strictEqual(sent.length, 0); + }); + + test("leaves breakpoint messages on their way to the application", function () { + const { handler } = createHandler(); + + const processed = handler.processDebuggerCDPMessage({ + method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT, + params: { location: { lineNumber: 1, columnNumber: 2 } }, + }); + + const event = processed.event as Record; + + assert.strictEqual(processed.sendBack, false); + assert.strictEqual(event.method, CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT); + // Hermes rejects breakpoints carrying a column number + assert.strictEqual(event.params.location.columnNumber, undefined); + }); + }); +});