mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): intercept IPC probe at module layer for native Tauri
Native Tauri 2.11.5 defines window.__TAURI_INTERNALS__.invoke as a non-configurable, non-writable value property, so the prior Object.defineProperty install threw TypeError and the broad catch in startProfilingHarness silently aborted all four probes — a production drive day would capture nothing. Move interception to the @tauri-apps/api/core module layer via a Vite alias proxy that re-exports the real surface and wraps only invoke; the real core derefs the window property per call, so the seam never writes it and stays passive against native, terminal-accessor, and mockIPC shapes alike. Isolate each probe install so one failure cannot abort the rest. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<unknown>;
|
||||
};
|
||||
|
||||
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<number, PendingInvoke>();
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<unknown>;
|
||||
|
||||
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<number, PendingInvoke>,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<unknown>,
|
||||
getInvokeObserver,
|
||||
) as typeof realInvoke;
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user