mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
2196cc18f0
## Why
PostHog shows **~9,785 `toolserver:stop` crashes over 90 days**, but
every one collapses into a single bucket — `error_kind=crash`,
`error_code=TOOLSERVER_UNCAUGHT_EXCEPTION`. We know *that* the tool
server died on a top-level throw, never *which* throw. The population is
also two very different things we can't tell apart today:
- **~99%** crash at **<5s uptime with 0 tool calls** — startup faults,
and heavily concentrated per-user (top 10 users ≈ 31% of all crashes;
one user logged 1,027), i.e. **boot → crash → relaunch loops**.
- **~1,000** crash after **>10 min** of real serving — genuine runtime
bugs.
Without the actual exception, all 9,785 rows are unrankable and
unfixable.
## What
Four **crash-only, provably-anonymous** fields on `toolserver:stop`,
enforced by the existing coded-allowlist sanitizer so **no free text can
ever reach PostHog**:
| field | value | why |
|---|---|---|
| `error_name` | error class name (`TypeError`, …) | "what kind" |
| `error_syscall` | Node's `err.code` (`EADDRINUSE`, `ECONNREFUSED`, …)
| the key discriminator for the startup-loop population (port/socket
conflicts) |
| `crash_fingerprint` | 16-hex hash of the **de-identified** top stack
frames | clusters the same crash across users; input carries no
identity, output is opaque |
| `crash_phase` | `"startup"` \| `"serving"` | splits the two
populations directly |
Turns 9,785 opaque rows into a handful of rankable clusters.
## Anonymity (the hard requirement)
- **No error message, no raw stack.** Those routinely embed
`/Users/<name>/…`, URLs, argv, hostnames, interpolated values — the
highest-PII surface and impossible to reliably scrub. The fingerprint
replaces the message for clustering; `error_name` + `error_syscall` give
the "what kind" without free text.
- **Fingerprint input is de-identified before hashing**
(defense-in-depth on top of the hash): first-party frames → basename
only (drops the home-dir/project prefix), `node_modules` frames →
package-relative tail (drops the user prefix; also collapses the pnpm
`.pnpm/pkg@ver/` layout), `node:` internals kept verbatim, column
dropped. So the *same crash on two machines produces the same
fingerprint*, and no username/path survives even into the hash input.
- **Sanitizer is the final gate.** `error_name`
`^[A-Za-z][A-Za-z0-9_]{0,63}$`, `error_syscall` `^E[A-Z0-9_]{1,31}$`,
`crash_fingerprint` `^[0-9a-f]{16}$`, `crash_phase` a 2-value enum. Any
path/message/lowercase/oversize value is dropped before it can leave the
machine.
## Scope & safety (no leaking crashes out of scope)
- Fields are populated **only** in `crashShutdown` (which forces
`reason="crash"`), so clean idle/signal stops carry none. Verified by
the existing startup-telemetry test (the pre-listener watcher-rejection
crash path stays the bare `{reason,uptime_ms,total_tool_calls}` shape).
- The four keys exist only on `ToolserverStopProps` and only in the
`toolserver:stop` allowlist entry — they can't ride any other event.
- `describeCrash` is **total**: every field extraction is isolated, so a
hostile throwing getter on `.name`/`.code`/`.stack` yields the phase
(and whatever else is readable) instead of turning crash reporting into
a second crash. The crash/exit guarantee — re-entrancy guard, force-exit
timer, `finalExitCode` escalation — is untouched.
## Tests
- New `crash-diagnostics.test.ts`: class-name/constructor-name/primitive
extraction, syscall capture, cross-user determinism (incl. **Windows**
drive-letter paths and **pnpm** layout), node_modules tail,
line-sensitivity, no-stack omission, hostile-getter resilience, and
"never emits a message/stack" confinement.
- `sanitize.test.ts`: crash fields pass when well-formed; dropped for
message-as-name, path/lowercase syscall, wrong-length/uppercase
fingerprint, unknown phase; `ERR_*` accepted.
- Full telemetry suite (278) + tool-server startup test green;
lint/prettier clean.
## Reviewed
Ran an adversarial review pass (anonymity leak-hunt, determinism,
crash-path safety, scope). Ship-ready; all findings were Low/Nit and the
actionable ones (hostile-getter hardening, Windows/pnpm test coverage,
`error_syscall` doc clarity) are folded in.
### Deliberately deferred (documented, not blockers)
- Sanitizer doesn't *cross-gate* the crash fields on `reason==="crash"`
— the per-field validator design has no sibling access; coupling is
enforced in emit code. The fields are non-identifying regardless, so a
hypothetical stray `crash_phase` on an idle stop is a data nit, not a
leak.
- First-party frames reduce to basename; if `dist` ever ships unbundled
with duplicate basenames, two sites at the same line could collide.
Non-issue for the bundled tool-server.
125 lines
4.4 KiB
TypeScript
125 lines
4.4 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
const telemetryMock = vi.hoisted(() => ({
|
|
init: vi.fn(),
|
|
attachRegistryTelemetry: vi.fn(() => ({
|
|
detach: vi.fn(),
|
|
recordInvocation: vi.fn(),
|
|
getTotalToolCalls: vi.fn(() => 0),
|
|
})),
|
|
track: vi.fn(),
|
|
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
warmTelemetryIdentity: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const registryMock = vi.hoisted(() => ({
|
|
dispose: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const httpHandleMock = vi.hoisted(() => ({
|
|
dispose: vi.fn(),
|
|
app: {
|
|
listen: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const updateCheckerMock = vi.hoisted(() => ({
|
|
dispose: vi.fn(),
|
|
}));
|
|
|
|
const watcherMock = vi.hoisted(() => ({
|
|
stop: vi.fn(),
|
|
}));
|
|
|
|
// Stub the telemetry surface for assertions, but keep the REAL describeCrash so
|
|
// the startup `.catch` path produces genuine anonymous diagnostics (error_name /
|
|
// crash_fingerprint / crash_phase) rather than the phase-only catch fallback.
|
|
vi.mock("@argent/telemetry", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@argent/telemetry")>();
|
|
return { ...telemetryMock, describeCrash: actual.describeCrash };
|
|
});
|
|
// Keep the real registry exports (notably TypedEventEmitter, which
|
|
// variant-proposals.ts constructs at module load via index.ts) and override
|
|
// only attachRegistryLogger — wiring the real logger onto the stubbed registry
|
|
// here would call `.events.on` on a registry mock that has no event emitter.
|
|
vi.mock("@argent/registry", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@argent/registry")>();
|
|
return {
|
|
...actual,
|
|
attachRegistryLogger: vi.fn(),
|
|
};
|
|
});
|
|
vi.mock("../src/utils/setup-registry", () => ({
|
|
createRegistry: vi.fn(() => registryMock),
|
|
}));
|
|
vi.mock("../src/http", () => ({
|
|
createHttpApp: vi.fn(() => httpHandleMock),
|
|
}));
|
|
vi.mock("../src/utils/update-checker", () => ({
|
|
startUpdateChecker: vi.fn(() => updateCheckerMock),
|
|
}));
|
|
vi.mock("../src/utils/simulator-watcher", () => ({
|
|
startSimulatorWatcher: vi.fn(() => ({
|
|
stop: watcherMock.stop,
|
|
ready: new Promise<void>((_resolve, reject) => {
|
|
setTimeout(() => reject(new Error("watcher failed")), 0);
|
|
}),
|
|
})),
|
|
}));
|
|
|
|
describe("tool-server startup telemetry", () => {
|
|
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
exitSpy.mockRestore();
|
|
});
|
|
|
|
it("drains telemetry when startup fails before the listener binds", async () => {
|
|
const { start } = await import("../src/index");
|
|
|
|
start();
|
|
|
|
// The identity warm-up is kicked off during startup (before the readiness
|
|
// gate), so the fingerprint resolve happens off the accept path. Regression
|
|
// guard: removing `warmTelemetryIdentity()` from start() must fail here.
|
|
expect(telemetryMock.warmTelemetryIdentity).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(telemetryMock.shutdown).toHaveBeenCalledWith(1500);
|
|
});
|
|
|
|
// This path (watcher-ready rejection) fails the server before the listener
|
|
// binds, so it is a startup-phase crash: the readiness-gate `.catch` attaches
|
|
// the anonymous diagnostics derived from the rejection error. The rejection is
|
|
// a bare `new Error("watcher failed")`, so error_name is "Error", there is no
|
|
// syscall code, and the de-identified stack yields a 16-hex fingerprint.
|
|
expect(telemetryMock.track).toHaveBeenCalledWith("toolserver:stop", {
|
|
reason: "crash",
|
|
uptime_ms: expect.any(Number),
|
|
total_tool_calls: 0,
|
|
error_name: "Error",
|
|
crash_fingerprint: expect.stringMatching(/^[0-9a-f]{16}$/),
|
|
crash_phase: "startup",
|
|
});
|
|
expect(updateCheckerMock.dispose).toHaveBeenCalledOnce();
|
|
expect(watcherMock.stop).toHaveBeenCalledOnce();
|
|
expect(httpHandleMock.dispose).toHaveBeenCalledOnce();
|
|
expect(registryMock.dispose).toHaveBeenCalledOnce();
|
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
|
|
process.emit("SIGTERM");
|
|
|
|
expect(telemetryMock.track).toHaveBeenCalledTimes(1);
|
|
expect(telemetryMock.shutdown).toHaveBeenCalledTimes(1);
|
|
expect(updateCheckerMock.dispose).toHaveBeenCalledOnce();
|
|
expect(watcherMock.stop).toHaveBeenCalledOnce();
|
|
expect(httpHandleMock.dispose).toHaveBeenCalledOnce();
|
|
expect(registryMock.dispose).toHaveBeenCalledOnce();
|
|
});
|
|
});
|