diff --git a/packages/agent-cdp/src/__tests__/console.test.ts b/packages/agent-cdp/src/__tests__/console.test.ts index 39d2177..48ae468 100644 --- a/packages/agent-cdp/src/__tests__/console.test.ts +++ b/packages/agent-cdp/src/__tests__/console.test.ts @@ -47,6 +47,17 @@ function createSession(transport: CdpTransport): RuntimeSession { sourceUrl: "http://example.test", } satisfies TargetDescriptor, transport, + metadata: { + connectedAt: 0, + clockCalibration: { + state: "unavailable", + hostRequestTimeMs: 0, + hostResponseTimeMs: 0, + hostMidpointTimeMs: 0, + roundTripTimeMs: 0, + reason: "not needed in test", + }, + }, ensureConnected: () => Promise.resolve(), close: () => Promise.resolve(), }; diff --git a/packages/agent-cdp/src/__tests__/formatters.test.ts b/packages/agent-cdp/src/__tests__/formatters.test.ts index f478c73..25c4042 100644 --- a/packages/agent-cdp/src/__tests__/formatters.test.ts +++ b/packages/agent-cdp/src/__tests__/formatters.test.ts @@ -13,4 +13,31 @@ describe("formatStatus", () => { }), ).toContain("session:disconnected"); }); + + it("renders session calibration details in verbose mode", () => { + expect( + formatStatus( + { + daemonRunning: true, + uptime: 2300, + providerCount: 2, + sessionState: "connected", + selectedTarget: null, + tracingActive: false, + sessionDetails: { + connectedAt: Date.UTC(2026, 0, 2, 3, 4, 5), + clockCalibration: { + state: "unavailable", + hostRequestTimeMs: 1, + hostResponseTimeMs: 3, + hostMidpointTimeMs: 2, + roundTripTimeMs: 2, + reason: "Runtime evaluation failed", + }, + }, + }, + true, + ), + ).toContain("Session clock: unavailable (Runtime evaluation failed)"); + }); }); diff --git a/packages/agent-cdp/src/__tests__/network.test.ts b/packages/agent-cdp/src/__tests__/network.test.ts index 1dda212..5f413a8 100644 --- a/packages/agent-cdp/src/__tests__/network.test.ts +++ b/packages/agent-cdp/src/__tests__/network.test.ts @@ -75,6 +75,17 @@ function createSession(transport: CdpTransport): RuntimeSession { sourceUrl: "http://example.test", } satisfies TargetDescriptor, transport, + metadata: { + connectedAt: 0, + clockCalibration: { + state: "unavailable", + hostRequestTimeMs: 0, + hostResponseTimeMs: 0, + hostMidpointTimeMs: 0, + roundTripTimeMs: 0, + reason: "not needed in test", + }, + }, ensureConnected: () => Promise.resolve(), close: () => Promise.resolve(), }; diff --git a/packages/agent-cdp/src/__tests__/session-manager.test.ts b/packages/agent-cdp/src/__tests__/session-manager.test.ts index 2b35310..0a161b5 100644 --- a/packages/agent-cdp/src/__tests__/session-manager.test.ts +++ b/packages/agent-cdp/src/__tests__/session-manager.test.ts @@ -1,11 +1,21 @@ import { SessionManager } from "../session-manager.js"; -import type { CdpEventMessage, CdpTransport, TargetDescriptor, TargetProvider } from "../types.js"; +import type { CdpEventMessage, CdpTransport, TargetProvider } from "../types.js"; const CHROME_TEST_ID = "chrome:ZXhhbXBsZS50ZXN0:page-1"; const REACT_NATIVE_TEST_ID = "react-native:ZXhhbXBsZS50ZXN0:page-1"; class FakeTransport implements CdpTransport { connected = false; + calibrationResult: unknown = { + result: { + value: { + monotonic: 123.45, + timeOrigin: 1_700_000_000_000, + wall: 1_700_000_000_123.45, + }, + }, + }; + readonly sentMethods: string[] = []; connect(): Promise { this.connected = true; @@ -21,7 +31,11 @@ class FakeTransport implements CdpTransport { return this.connected; } - send(): Promise { + send(method: string): Promise { + this.sentMethods.push(method); + if (method === "Runtime.evaluate") { + return Promise.resolve(this.calibrationResult); + } return Promise.resolve(undefined); } @@ -32,9 +46,12 @@ class FakeTransport implements CdpTransport { class FakeProvider implements TargetProvider { readonly kind = "chrome" as const; + readonly transports: FakeTransport[] = []; createTransport(): CdpTransport { - return new FakeTransport(); + const transport = new FakeTransport(); + this.transports.push(transport); + return transport; } } @@ -72,11 +89,52 @@ describe("SessionManager", () => { title: "Example", }); expect(manager.getSessionState()).toBe("connected"); + expect(manager.getSession()?.metadata.clockCalibration).toMatchObject({ + state: "calibrated", + targetMonotonicTimeMs: 123.45, + targetTimeOriginMs: 1_700_000_000_000, + targetWallTimeMs: 1_700_000_000_123.45, + }); await manager.clearTarget(); expect(manager.getSelectedTarget()).toBeNull(); expect(manager.getSessionState()).toBe("disconnected"); }); + it("records explicit unavailable calibration when the target runtime cannot provide one", async () => { + const targets = [ + { + id: CHROME_TEST_ID, + rawId: "page-1", + title: "Example", + kind: "chrome" as const, + description: "Test page", + webSocketDebuggerUrl: "ws://example.test/devtools/page/1", + sourceUrl: "http://example.test", + }, + ]; + const provider = new FakeProvider(); + const manager = new SessionManager([provider], () => Promise.resolve(targets)); + provider.createTransport = () => { + const transport = new FakeTransport(); + transport.calibrationResult = { + result: { + value: { + monotonic: null, + }, + }, + }; + provider.transports.push(transport); + return transport; + }; + + await manager.selectTarget(CHROME_TEST_ID, {}); + + expect(manager.getSession()?.metadata.clockCalibration).toMatchObject({ + state: "unavailable", + reason: "Target runtime did not provide performance.now()", + }); + }); + it("rejects mismatched explicit urls when selecting a target", async () => { const targets = [ { @@ -99,32 +157,20 @@ describe("SessionManager", () => { it("reconnects react native targets by logical device id", async () => { class FakeReactNativeProvider implements TargetProvider { readonly kind = "react-native" as const; - private attempt = 0; + private transportAttempt = 0; - async listTargets(): Promise { - this.attempt += 1; - return [ - { - id: `react-native:ZXhhbXBsZS50ZXN0:page-${this.attempt}`, - rawId: `page-${this.attempt}`, - title: "React Native Experimental", - kind: "react-native", - description: "RN target", - appId: "com.example.app", - webSocketDebuggerUrl: `ws://example.test/inspector/debug?page=${this.attempt}`, - sourceUrl: "http://example.test", - reactNative: { - logicalDeviceId: "device-1", - capabilities: { - nativePageReloads: true, - }, + createTransport(): CdpTransport { + this.transportAttempt += 1; + const transport = new FakeTransport(); + transport.calibrationResult = { + result: { + value: { + monotonic: this.transportAttempt * 100, + timeOrigin: 1_700_000_000_000, }, }, - ]; - } - - createTransport(): CdpTransport { - return new FakeTransport(); + }; + return transport; } } @@ -166,5 +212,10 @@ describe("SessionManager", () => { rawId: "page-2", }); expect(manager.getSessionState()).toBe("connected"); + expect(manager.getSession()?.metadata.clockCalibration).toMatchObject({ + state: "calibrated", + targetMonotonicTimeMs: 200, + targetWallTimeMs: 1_700_000_000_200, + }); }); }); diff --git a/packages/agent-cdp/src/__tests__/trace-analysis.test.ts b/packages/agent-cdp/src/__tests__/trace-analysis.test.ts index 2df973c..4506b8b 100644 --- a/packages/agent-cdp/src/__tests__/trace-analysis.test.ts +++ b/packages/agent-cdp/src/__tests__/trace-analysis.test.ts @@ -44,6 +44,17 @@ function createTraceSession(transport: CdpTransport): RuntimeSession { sourceUrl: "http://example.test", } satisfies TargetDescriptor, transport, + metadata: { + connectedAt: 0, + clockCalibration: { + state: "unavailable", + hostRequestTimeMs: 0, + hostResponseTimeMs: 0, + hostMidpointTimeMs: 0, + roundTripTimeMs: 0, + reason: "not needed in test", + }, + }, ensureConnected: () => Promise.resolve(), close: () => Promise.resolve(), }; diff --git a/packages/agent-cdp/src/__tests__/trace.test.ts b/packages/agent-cdp/src/__tests__/trace.test.ts index 27bd2fb..d536b45 100644 --- a/packages/agent-cdp/src/__tests__/trace.test.ts +++ b/packages/agent-cdp/src/__tests__/trace.test.ts @@ -50,6 +50,17 @@ function createTraceSession(transport: CdpTransport): RuntimeSession { sourceUrl: "http://example.test", } satisfies TargetDescriptor, transport, + metadata: { + connectedAt: 0, + clockCalibration: { + state: "unavailable", + hostRequestTimeMs: 0, + hostResponseTimeMs: 0, + hostMidpointTimeMs: 0, + roundTripTimeMs: 0, + reason: "not needed in test", + }, + }, ensureConnected: () => Promise.resolve(), close: () => Promise.resolve(), }; diff --git a/packages/agent-cdp/src/daemon.ts b/packages/agent-cdp/src/daemon.ts index 1a85cc8..9c216a7 100644 --- a/packages/agent-cdp/src/daemon.ts +++ b/packages/agent-cdp/src/daemon.ts @@ -691,6 +691,7 @@ class Daemon { providerCount: this.providers.length, sessionState: this.sessionManager.getSessionState(), tracingActive: this.traceManager.isActive(), + sessionDetails: this.sessionManager.getSession()?.metadata ?? null, }; return { ok: true, data: status }; diff --git a/packages/agent-cdp/src/formatters.ts b/packages/agent-cdp/src/formatters.ts index 0e19acc..fcf7bc9 100644 --- a/packages/agent-cdp/src/formatters.ts +++ b/packages/agent-cdp/src/formatters.ts @@ -14,6 +14,14 @@ export function formatStatus(info: StatusInfo, verbose = false): string { } else { lines.push("Target: none selected"); } + if (info.sessionDetails) { + lines.push(`Connected at: ${new Date(info.sessionDetails.connectedAt).toISOString()}`); + lines.push( + info.sessionDetails.clockCalibration.state === "calibrated" + ? `Session clock: calibrated target=${Math.round(info.sessionDetails.clockCalibration.targetMonotonicTimeMs)}ms rtt=${Math.round(info.sessionDetails.clockCalibration.roundTripTimeMs)}ms` + : `Session clock: unavailable (${info.sessionDetails.clockCalibration.reason})`, + ); + } return lines.join("\n"); } diff --git a/packages/agent-cdp/src/session-manager.ts b/packages/agent-cdp/src/session-manager.ts index f3b6654..33497d8 100644 --- a/packages/agent-cdp/src/session-manager.ts +++ b/packages/agent-cdp/src/session-manager.ts @@ -2,20 +2,56 @@ import type { CdpTransport, DiscoveryOptions, RuntimeSession, + RuntimeSessionMetadata, SessionState, TargetDescriptor, + TargetSessionClockCalibration, TargetProvider, } from "./types.js"; import { discoverTargets, normalizeDiscoveryUrl, parseTargetId } from "./discovery.js"; +interface RuntimeEvaluateResponse { + result?: { + value?: { + monotonic?: unknown; + timeOrigin?: unknown; + wall?: unknown; + }; + }; + exceptionDetails?: { + text?: string; + exception?: { + description?: string; + }; + }; +} + +const CLOCK_CALIBRATION_EXPRESSION = `(() => { + const perf = globalThis.performance; + const monotonic = typeof perf?.now === "function" ? perf.now() : null; + const timeOrigin = typeof perf?.timeOrigin === "number" ? perf.timeOrigin : null; + const wall = typeof Date.now === "function" ? Date.now() : null; + return { monotonic, timeOrigin, wall }; +})()`; + export class PersistentRuntimeSession implements RuntimeSession { + metadata: RuntimeSessionMetadata = { + connectedAt: 0, + clockCalibration: createUnavailableCalibration(0, 0, "Session has not connected yet"), + }; + constructor( readonly target: TargetDescriptor, readonly transport: CdpTransport, ) {} - ensureConnected(): Promise { - return this.transport.connect(); + async ensureConnected(): Promise { + await this.transport.connect(); + const clockCalibration = await calibrateTargetSessionClock(this.transport); + this.metadata = { + connectedAt: clockCalibration.hostResponseTimeMs, + clockCalibration, + }; } close(): Promise { @@ -167,3 +203,80 @@ export class SessionManager { return { url: normalizedOptionUrl }; } } + +function createUnavailableCalibration( + hostRequestTimeMs: number, + hostResponseTimeMs: number, + reason: string, +): TargetSessionClockCalibration { + const hostMidpointTimeMs = Math.round((hostRequestTimeMs + hostResponseTimeMs) / 2); + return { + state: "unavailable", + hostRequestTimeMs, + hostResponseTimeMs, + hostMidpointTimeMs, + roundTripTimeMs: Math.max(0, hostResponseTimeMs - hostRequestTimeMs), + reason, + }; +} + +async function calibrateTargetSessionClock(transport: CdpTransport): Promise { + const hostRequestTimeMs = Date.now(); + + try { + const response = (await transport.send("Runtime.evaluate", { + expression: CLOCK_CALIBRATION_EXPRESSION, + returnByValue: true, + silent: true, + })) as RuntimeEvaluateResponse; + const hostResponseTimeMs = Date.now(); + + if (response.exceptionDetails) { + return createUnavailableCalibration( + hostRequestTimeMs, + hostResponseTimeMs, + response.exceptionDetails.exception?.description || response.exceptionDetails.text || "Runtime evaluation failed", + ); + } + + const value = response.result?.value; + const targetMonotonicTimeMs = readFiniteNumber(value?.monotonic); + if (targetMonotonicTimeMs === null) { + return createUnavailableCalibration( + hostRequestTimeMs, + hostResponseTimeMs, + "Target runtime did not provide performance.now()", + ); + } + + const targetTimeOriginMs = readFiniteNumber(value?.timeOrigin); + const targetWallTimeMs = readFiniteNumber(value?.wall) ?? deriveTargetWallTime(targetTimeOriginMs, targetMonotonicTimeMs); + const hostMidpointTimeMs = Math.round((hostRequestTimeMs + hostResponseTimeMs) / 2); + + return { + state: "calibrated", + hostRequestTimeMs, + hostResponseTimeMs, + hostMidpointTimeMs, + roundTripTimeMs: Math.max(0, hostResponseTimeMs - hostRequestTimeMs), + targetMonotonicTimeMs, + targetTimeOriginMs: targetTimeOriginMs ?? undefined, + targetWallTimeMs: targetWallTimeMs ?? undefined, + }; + } catch (error) { + const hostResponseTimeMs = Date.now(); + return createUnavailableCalibration( + hostRequestTimeMs, + hostResponseTimeMs, + error instanceof Error ? error.message : String(error), + ); + } +} + +function deriveTargetWallTime(targetTimeOriginMs: number | null, targetMonotonicTimeMs: number): number | null { + return targetTimeOriginMs === null ? null : targetTimeOriginMs + targetMonotonicTimeMs; +} + +function readFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/packages/agent-cdp/src/types.ts b/packages/agent-cdp/src/types.ts index d5d37b5..9a5bfec 100644 --- a/packages/agent-cdp/src/types.ts +++ b/packages/agent-cdp/src/types.ts @@ -40,9 +40,37 @@ export interface CdpTransport { onEvent(listener: (message: CdpEventMessage) => void): () => void; } +export interface CalibratedTargetSessionClock { + readonly state: "calibrated"; + readonly hostRequestTimeMs: number; + readonly hostResponseTimeMs: number; + readonly hostMidpointTimeMs: number; + readonly roundTripTimeMs: number; + readonly targetMonotonicTimeMs: number; + readonly targetTimeOriginMs?: number; + readonly targetWallTimeMs?: number; +} + +export interface UnavailableTargetSessionClock { + readonly state: "unavailable"; + readonly hostRequestTimeMs: number; + readonly hostResponseTimeMs: number; + readonly hostMidpointTimeMs: number; + readonly roundTripTimeMs: number; + readonly reason: string; +} + +export type TargetSessionClockCalibration = CalibratedTargetSessionClock | UnavailableTargetSessionClock; + +export interface RuntimeSessionMetadata { + readonly connectedAt: number; + readonly clockCalibration: TargetSessionClockCalibration; +} + export interface RuntimeSession { readonly target: TargetDescriptor; readonly transport: CdpTransport; + readonly metadata: RuntimeSessionMetadata; ensureConnected(): Promise; close(): Promise; } @@ -80,6 +108,7 @@ export interface StatusInfo { providerCount: number; sessionState: SessionState; tracingActive: boolean; + sessionDetails?: RuntimeSessionMetadata | null; } export type IpcCommand =