diff --git a/desktop/src/shared/profiling/harness.test.mjs b/desktop/src/shared/profiling/harness.test.mjs index 16cec69bc..ffa42ffcd 100644 --- a/desktop/src/shared/profiling/harness.test.mjs +++ b/desktop/src/shared/profiling/harness.test.mjs @@ -16,7 +16,12 @@ import { DRIFT_THRESHOLD_MS, nextStallSample, } from "@/shared/profiling/drift.ts"; -import { installInvokeProbe } from "@/shared/profiling/ipc.ts"; +import { + createInvokeObserver, + getInvokeObserver, + setInvokeObserver, + wrapInvoke, +} from "@/shared/profiling/ipc.ts"; import { ProfileRecorder, RING_CAPACITY } from "@/shared/profiling/recorder.ts"; function fixedClock() { @@ -93,86 +98,105 @@ describe("classifyInput", () => { }); }); -describe("installInvokeProbe", () => { - it("returns false and stays passive when no invoke bridge exists", () => { - assert.equal( - installInvokeProbe(undefined, new Map(), () => {}), - false, - ); - assert.equal( - installInvokeProbe({ invoke: 42 }, new Map(), () => {}), - false, +describe("wrapInvoke / observer registry", () => { + it("passes through untouched when no observer is registered", async () => { + setInvokeObserver(null); + const wrapped = wrapInvoke( + (cmd) => Promise.resolve(`ok:${cmd}`), + () => getInvokeObserver(), ); + assert.equal(await wrapped("ping"), "ok:ping"); }); - it("records a resolved invoke once without recursing", async () => { + it("records a resolved invoke once through the registered observer", async () => { const calls = []; - const internals = { - invoke: (cmd) => Promise.resolve(`ok:${cmd}`), - }; + const pending = new Map(); let t = 0; - assert.equal( - installInvokeProbe( - internals, - new Map(), + setInvokeObserver( + createInvokeObserver( + pending, (cmd, dur, ok) => calls.push({ cmd, dur, ok }), () => (t += 5), ), - true, ); - const result = await internals.invoke("ping"); + const wrapped = wrapInvoke( + (cmd) => Promise.resolve(`ok:${cmd}`), + () => getInvokeObserver(), + ); + const result = await wrapped("ping"); assert.equal(result, "ok:ping"); assert.deepEqual(calls, [{ cmd: "ping", dur: 5, ok: true }]); + assert.equal(pending.size, 0); + setInvokeObserver(null); }); it("records a rejected invoke and re-throws without leaking pending", async () => { const calls = []; const pending = new Map(); - const internals = { invoke: () => Promise.reject(new Error("boom")) }; - installInvokeProbe(internals, pending, (cmd, _dur, ok) => - calls.push({ cmd, ok }), + setInvokeObserver( + createInvokeObserver(pending, (cmd, _dur, ok) => calls.push({ cmd, ok })), ); - await assert.rejects(() => internals.invoke("bad"), /boom/); + const wrapped = wrapInvoke( + () => Promise.reject(new Error("boom")), + () => getInvokeObserver(), + ); + await assert.rejects(() => wrapped("bad"), /boom/); assert.deepEqual(calls, [{ cmd: "bad", ok: false }]); assert.equal(pending.size, 0); + setInvokeObserver(null); }); - it("preserves an accessor-backed reassignment bridge without stack overflow", async () => { - // Reproduces the terminal E2E backend descriptor shape: `invoke` is an - // accessor whose getter returns a dispatcher delegating unknown commands to - // closure `inner`, and whose setter *reassigns* `inner`. A plain - // `internals.invoke = wrapper` would route the dispatcher's fallback back - // into the wrapper and overflow the stack on the first non-terminal call. + it("starts recording only after the observer is registered", async () => { + setInvokeObserver(null); + const calls = []; + const wrapped = wrapInvoke( + (cmd) => Promise.resolve(cmd), + () => getInvokeObserver(), + ); + // Module proxy wraps at load, before the harness starts: this call is a + // transparent pass-through and must not be recorded. + await wrapped("before"); + setInvokeObserver( + createInvokeObserver(new Map(), (cmd) => calls.push(cmd)), + ); + await wrapped("after"); + assert.deepEqual(calls, ["after"]); + setInvokeObserver(null); + }); + + it("never touches a native non-configurable invoke property", async () => { + // Native Tauri 2.11.5 defines `window.__TAURI_INTERNALS__.invoke` as a + // non-configurable, non-writable value property; any redefine/assign + // throws and would abort the harness. The module seam only reads the value + // (as the real core module does per call) and wraps that function + // reference — it must never write the property back. const internals = {}; - let inner = null; + const native = (cmd) => Promise.resolve(`native:${cmd}`); Object.defineProperty(internals, "invoke", { - configurable: true, - get: () => (cmd, args, opts) => { - if (cmd === "terminal_input") return Promise.resolve("term"); - if (!inner) throw new Error(`no mock bridge for ${cmd}`); - return inner(cmd, args, opts); - }, - set: (fn) => { - inner = fn; - }, + configurable: false, + writable: false, + enumerable: true, + value: native, }); - // The real mock bridge is trapped through the setter after the harness runs. - const realBridge = (cmd) => Promise.resolve(`real:${cmd}`); const calls = []; - assert.equal( - installInvokeProbe(internals, new Map(), (cmd, _dur, ok) => + setInvokeObserver( + createInvokeObserver(new Map(), (cmd, _dur, ok) => calls.push({ cmd, ok }), ), - true, ); - // App code trapping the invoke after the harness (as mockIPC does) must not - // clobber the probe: the harness redefined `invoke` as a data property. - inner = realBridge; + // The real core module derefs the property per call; the wrapper closes + // over the function value, exactly like tauriCoreProxy.ts. + const wrapped = wrapInvoke(internals.invoke, () => getInvokeObserver()); + const result = await wrapped("go"); - const result = await internals.invoke("non_terminal"); - assert.equal(result, "real:non_terminal"); - assert.deepEqual(calls, [{ cmd: "non_terminal", ok: true }]); + assert.equal(result, "native:go"); + assert.deepEqual(calls, [{ cmd: "go", ok: true }]); + // The forbidden property is untouched and still non-configurable. + const descriptor = Object.getOwnPropertyDescriptor(internals, "invoke"); + assert.equal(descriptor.configurable, false); + assert.equal(descriptor.value, native); + setInvokeObserver(null); }); }); diff --git a/desktop/src/shared/profiling/harness.ts b/desktop/src/shared/profiling/harness.ts index d1f477ead..7af9106c5 100644 --- a/desktop/src/shared/profiling/harness.ts +++ b/desktop/src/shared/profiling/harness.ts @@ -5,8 +5,9 @@ // 1. main-thread stalls (timer drift, foreground-only) + input latency // (receipt → next paint, with pre-dispatch queue delay split out) // 2. Tauri IPC duration/outcome + a >10s pending-invoke watchdog (deadlocks). -// The invoke bridge is redefined as a data property (see ipc.ts) so an -// accessor-backed bridge is preserved without looping into the wrapper. +// Intercepted at the `@tauri-apps/api/core` module layer (see +// tauriCoreProxy.ts) so it works against native Tauri's non-configurable +// invoke property — no window property is ever written. // 3. relay REQ→EOSE (history fetch) and publish send→ack round-trips // 4. periodic accumulator census (observer store, query cache, DOM nodes) // @@ -28,7 +29,8 @@ import { PENDING_INVOKE_MS, PENDING_SCAN_MS, type PendingInvoke, - installInvokeProbe, + createInvokeObserver, + setInvokeObserver, } from "@/shared/profiling/ipc"; import { FLUSH_INTERVAL_MS, @@ -96,22 +98,16 @@ function installRelayProbe(rec: ProfileRecorder): void { } } -type TauriInternals = { - invoke: (cmd: string, args?: unknown, opts?: unknown) => Promise; -}; - function installIpcProbe(rec: ProfileRecorder): void { - const internals = ( - window as unknown as { __TAURI_INTERNALS__?: TauriInternals } - ).__TAURI_INTERNALS__; // Track outstanding invokes so a hung command surfaces via the watchdog. const pending = new Map(); - const installed = installInvokeProbe(internals, pending, (cmd, dur, ok) => - rec.record({ type: "ipc", cmd, dur, ok }), + // The core module proxy wraps `invoke` at load; registering the observer + // here begins recording. No window property is touched — see ipc.ts. + setInvokeObserver( + createInvokeObserver(pending, (cmd, dur, ok) => + rec.record({ type: "ipc", cmd, dur, ok }), + ), ); - if (!installed) { - return; - } window.setInterval(() => { const now = performance.now(); @@ -211,7 +207,9 @@ function installCensusProbe( /** * Start the harness once, wiring all four probes and the JSONL sink. Idempotent - * and non-throwing: profiling must never take down the app it measures. + * and non-throwing: profiling must never take down the app it measures. Each + * probe is isolated — one failing to install must not abort the others or the + * flush wiring. */ export function startProfilingHarness(queryClient: QueryClient): void { if (started) { @@ -219,21 +217,30 @@ export function startProfilingHarness(queryClient: QueryClient): void { } started = true; - try { - const sid = `session-${Date.now()}`; - const rec = new ProfileRecorder(sid, async (lines) => { - await invoke("append_profiling_log", { fileStem: sid, lines }); - }); + const sid = `session-${Date.now()}`; + const rec = new ProfileRecorder(sid, async (lines) => { + await invoke("append_profiling_log", { fileStem: sid, lines }); + }); - installIpcProbe(rec); - installRelayProbe(rec); - installStallProbe(rec); - installInputProbe(rec); - installCensusProbe(rec, queryClient); + // Install each probe under its own guard so a single failure can't silently + // collapse the rest of the capture. + safely(() => installIpcProbe(rec)); + safely(() => installRelayProbe(rec)); + safely(() => installStallProbe(rec)); + safely(() => installInputProbe(rec)); + safely(() => installCensusProbe(rec, queryClient)); + safely(() => { window.setInterval(() => void rec.flush(), FLUSH_INTERVAL_MS); window.addEventListener("pagehide", () => void rec.flush()); + }); +} + +/** Run harness setup work, swallowing any failure so it can't take down the app. */ +function safely(fn: () => void): void { + try { + fn(); } catch { - // Never let harness setup break the app being profiled. + // A probe that cannot install is skipped; the others still run. } } diff --git a/desktop/src/shared/profiling/ipc.ts b/desktop/src/shared/profiling/ipc.ts index 08baed9b7..b58312d91 100644 --- a/desktop/src/shared/profiling/ipc.ts +++ b/desktop/src/shared/profiling/ipc.ts @@ -1,7 +1,9 @@ -// Temporary renderer profiling harness (never merges) — IPC invoke probe. +// Temporary renderer profiling harness (never merges) — IPC invoke probe core. // -// The interception logic lives here (not harness.ts) so it can be unit-tested -// without harness.ts's module-load Tauri/store imports, matching drift.ts. +// Pure, Tauri-free logic so it is unit-testable without harness.ts's +// module-load Tauri/store imports (matching drift.ts). The module seam that +// actually wraps `@tauri-apps/api/core`'s `invoke` lives in tauriCoreProxy.ts; +// this file only decides what to record. /** A command outstanding >10s is logged with its age — the deadlock detector. */ export const PENDING_INVOKE_MS = 10_000; @@ -14,45 +16,73 @@ export type InvokeFn = ( opts?: unknown, ) => Promise; -export type IpcInternals = { invoke: InvokeFn }; - export type PendingInvoke = { cmd: string; startedAt: number }; /** - * Wrap `__TAURI_INTERNALS__.invoke` to record each call's duration/outcome, - * tracking outstanding calls in `pending` for the watchdog. Returns `false` - * when no invoke bridge is present (the harness stays passive). - * - * The wrapper is installed as a **data property** via `Object.defineProperty`, - * never `internals.invoke = wrapper`. The terminal E2E backend defines `invoke` - * as an accessor whose getter returns a dispatcher that falls back to a closure - * variable and whose setter *reassigns that fallback*; a plain assignment would - * therefore route the bridge's fallback back into this wrapper and overflow the - * stack on the first non-terminal invoke. Reading the getter once (to bind the - * current implementation) and redefining `invoke` as a value preserves any - * accessor- or reassignment-backed bridge without looping into ourselves. + * Observes one invoke: `begin(cmd)` marks it outstanding and returns a `settle` + * that records duration/outcome and clears it. */ -export function installInvokeProbe( - internals: IpcInternals | undefined, +export type InvokeObserver = { begin: (cmd: string) => (ok: boolean) => void }; + +/** + * Build an observer that tracks each call in `pending` (for the watchdog) and + * records an `ipc` result on settle. + */ +export function createInvokeObserver( pending: Map, record: (cmd: string, dur: number, ok: boolean) => void, now: () => number = () => performance.now(), -): boolean { - if (!internals || typeof internals.invoke !== "function") { - return false; - } - const original = internals.invoke.bind(internals); +): InvokeObserver { let seq = 0; + return { + begin(cmd) { + const id = seq++; + const startedAt = now(); + pending.set(id, { cmd, startedAt }); + return (ok) => { + pending.delete(id); + record(cmd, now() - startedAt, ok); + }; + }, + }; +} - const wrapper: InvokeFn = (cmd, args, opts) => { - const id = seq++; - const startedAt = now(); - pending.set(id, { cmd, startedAt }); - const settle = (ok: boolean) => { - pending.delete(id); - record(cmd, now() - startedAt, ok); - }; - return original(cmd, args, opts).then( +// Module-level observer registry. The core proxy wraps `invoke` at module load +// (before the harness starts), but only reports once the harness registers its +// observer. Until then the wrapper is a transparent pass-through. +let activeObserver: InvokeObserver | null = null; + +/** Register the observer that live invoke calls report to. */ +export function setInvokeObserver(observer: InvokeObserver | null): void { + activeObserver = observer; +} + +/** The currently registered observer, or `null` when the harness is off. */ +export function getInvokeObserver(): InvokeObserver | null { + return activeObserver; +} + +/** + * Wrap a real `invoke` so each call routes through the current observer (or + * passes straight through when none is registered). The wrapper never touches + * `window.__TAURI_INTERNALS__`: native Tauri defines `invoke` as a + * non-configurable, non-writable value property, so redefining it throws and + * would abort the whole harness. Intercepting at the module layer instead keeps + * the probe passive against native, terminal-accessor, and mockIPC shapes + * alike — the real core module dereferences the window property per call, so a + * module wrapper sees every call without owning the property. + */ +export function wrapInvoke( + real: InvokeFn, + getObserver: () => InvokeObserver | null, +): InvokeFn { + return (cmd, args, opts) => { + const observer = getObserver(); + if (!observer) { + return real(cmd, args, opts); + } + const settle = observer.begin(cmd); + return real(cmd, args, opts).then( (value) => { settle(true); return value; @@ -63,11 +93,4 @@ export function installInvokeProbe( }, ); }; - - Object.defineProperty(internals, "invoke", { - configurable: true, - writable: true, - value: wrapper, - }); - return true; } diff --git a/desktop/src/shared/profiling/tauriCoreProxy.ts b/desktop/src/shared/profiling/tauriCoreProxy.ts new file mode 100644 index 000000000..22288dc0f --- /dev/null +++ b/desktop/src/shared/profiling/tauriCoreProxy.ts @@ -0,0 +1,33 @@ +// Temporary renderer profiling harness (never merges) — @tauri-apps/api/core +// module proxy. +// +// Vite aliases `@tauri-apps/api/core` to this module (see vite.config.ts), so +// every importer — all raw `@tauri-apps/api/core` consumers, `invokeTauri()`, +// and every bundled Tauri plugin — resolves here. We re-export the real core +// surface unchanged (`export *`, which carries values and types) and override +// only `invoke`; an explicit named export shadows the star re-export of the +// same name. +// +// The real module is imported through the `@tauri-core-impl` alias (pointing at +// the actual core module) so this proxy's own import does not re-enter the +// alias. The wrapper reports to the harness's observer once registered and is a +// transparent pass-through until then. Crucially it never writes to +// `window.__TAURI_INTERNALS__`, which native Tauri defines as a +// non-configurable value property — the only interception seam that survives +// the production runtime, since the real core dereferences that property per +// call. + +import { invoke as realInvoke } from "@tauri-core-impl"; + +import { getInvokeObserver, wrapInvoke } from "@/shared/profiling/ipc"; + +export * from "@tauri-core-impl"; + +export const invoke = wrapInvoke( + realInvoke as ( + cmd: string, + args?: unknown, + opts?: unknown, + ) => Promise, + getInvokeObserver, +) as typeof realInvoke; diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json index a2a57c66e..6389ebacf 100644 --- a/desktop/tsconfig.json +++ b/desktop/tsconfig.json @@ -7,6 +7,7 @@ "skipLibCheck": true, "paths": { "@/*": ["./src/*"], + "@tauri-core-impl": ["./node_modules/@tauri-apps/api/core"], "@features-manifest": ["../preview-features.json"], "@model-capabilities-manifest": ["../scripts/model-capabilities.json"] }, diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index 5a5de1912..180593b39 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -23,6 +23,19 @@ export default defineConfig(async () => ({ ], resolve: { alias: { + // Temporary profiling harness (never merges): route every + // `@tauri-apps/api/core` importer (raw consumers, invokeTauri, and every + // bundled Tauri plugin) through a proxy that wraps `invoke`. The proxy + // reaches the real module via `@tauri-core-impl` so this alias does not + // re-fire. Exact string match — plugin subpaths are unaffected. + "@tauri-apps/api/core": path.resolve( + __dirname, + "./src/shared/profiling/tauriCoreProxy.ts", + ), + "@tauri-core-impl": path.resolve( + __dirname, + "./node_modules/@tauri-apps/api/core.js", + ), "@": "/src", "@features-manifest": path.resolve(__dirname, "../preview-features.json"), "@model-capabilities-manifest": path.resolve(