mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
f0026ce6c8
## Problem
The per-UDID native-devtools unix listener binds with a bare
`server.listen(socketPath)` and **no `error` listener**
(`native-devtools.ts`, the `else` branch of the transport check). A bind
failure — `EADDRINUSE` from a live/concurrent server on the same UDID,
or `EEXIST` from a socket re-created in the window after the pre-unlink
— fires an unhandled `'error'` event, which Node throws as an **uncaught
exception that crashes the whole tool-server at startup**.
The TCP branch right above it already guards its bind with
`server.once("error", reject)`; `ax-service.startListener` does too. The
unix branch was the one that didn't.
## Why it matters — 0.16.0 crash telemetry
The crash-diagnostics shipped in 0.16.0 (`crash_fingerprint` /
`crash_phase` / `error_syscall`) make this the clearly-dominant crash
source. Scoped to `toolserver:stop`, `reason=crash`,
`cli_version=0.16.0`:
- **100% of crashes are socket/fs E-codes**, none are logic bugs.
EADDRINUSE (95%), EEXIST (4%), ENOSPC/EPIPE tail.
- Fingerprint **`dc063fe2`** — the native-devtools socket bind site —
throws both **EADDRINUSE (65)** and **EEXIST (30)** at
**`crash_phase=startup`**, across **~14 distinct users**. That's the
widest-reach source by user count. (A separate single-user
`toolserver_bind` restart loop accounts for most raw *volume*, but only
one machine.)
EEXIST at this site is the known concurrent-agent contention on
`/tmp/argent-nd-<UDID>.sock` — a stale/racing socket file left by
another server.
## Fix
Extract the unix bind into `bindNativeDevtoolsUnixSocket(server,
socketPath)`, mirroring `ax-service.startListener`:
- Attaches an `error` listener so a bind failure **rejects** instead of
throwing uncaught → routes into the factory's existing retry +
failure-telemetry path, failing only this one service.
- On `EADDRINUSE`/`EEXIST`, **unlink the stale entry and retry the bind
once** — self-heals the unlink→listen race with a concurrent same-UDID
server.
- On any other failure (or a second failure), rejects with a coded
`FailureError` (`NATIVE_DEVTOOLS_SOCKET_BIND_FAILED`,
`failure_stage=native_devtools_socket_bind`) so it's cleanly
classifiable in telemetry going forward.
## Testing
- **Unit** (`native-devtools-socket-bind.test.ts`, 4 cases): free-path
bind, stale-file self-heal, coded-FailureError on unbindable path,
no-uncaught-throw when contending a live-held path.
- **Full telemetry + registry suites**: 368 pass (new failure code
accepted by the sanitizer allowlist).
- **Live A/B against built output** — a live server holds the socket,
then two child processes contend for it:
- OLD (pre-fix `server.listen(path)`): **exit 1, uncaught EADDRINUSE
crash** ← bug reproduced
- NEW (compiled `bindNativeDevtoolsUnixSocket`): **exit 0, self-healed
and bound** ← fix works
- **Bundled-artifact check**: ran the esbuild `bundle-tools` step; the
fix's retry logic and coded failure survive bundling intact in
`dist/tool-server.cjs` (no dead-code-elimination surprise).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
89 lines
3.6 KiB
TypeScript
89 lines
3.6 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import net from "node:net";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { bindNativeDevtoolsUnixSocket } from "../src/blueprints/native-devtools.js";
|
|
import { FailureError, FAILURE_CODES, getFailureSignal } from "@argent/registry";
|
|
|
|
// Regression coverage for the 0.16.0 dominant crash source: the native-devtools
|
|
// unix `server.listen(socketPath)` had no "error" listener, so a bind failure
|
|
// (EADDRINUSE from a live/concurrent per-UDID server, or EEXIST from a
|
|
// re-created stale socket) fired an unhandled "error" event → uncaught
|
|
// exception → whole tool-server crashed at startup. bindNativeDevtoolsUnixSocket
|
|
// must instead reject with a coded FailureError, and self-heal a stale path.
|
|
|
|
const servers: net.Server[] = [];
|
|
const track = (s: net.Server) => {
|
|
servers.push(s);
|
|
return s;
|
|
};
|
|
|
|
function tmpSock(name: string): string {
|
|
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "argent-nd-test-")), name);
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const s of servers.splice(0)) {
|
|
try {
|
|
s.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
}
|
|
});
|
|
|
|
describe("bindNativeDevtoolsUnixSocket", () => {
|
|
it("binds cleanly on a free path", async () => {
|
|
const socketPath = tmpSock("free.sock");
|
|
const server = track(net.createServer());
|
|
|
|
await expect(bindNativeDevtoolsUnixSocket(server, socketPath)).resolves.toBeUndefined();
|
|
expect(server.listening).toBe(true);
|
|
expect(fs.existsSync(socketPath)).toBe(true);
|
|
});
|
|
|
|
it("self-heals a stale (dead) socket file and binds", async () => {
|
|
const socketPath = tmpSock("stale.sock");
|
|
// A leftover regular file at the path (a crashed server's stale socket
|
|
// entry). listen() rejects it with EADDRINUSE/EEXIST; the helper must
|
|
// unlink and retry rather than crash.
|
|
fs.writeFileSync(socketPath, "");
|
|
expect(fs.existsSync(socketPath)).toBe(true);
|
|
|
|
const server = track(net.createServer());
|
|
await expect(bindNativeDevtoolsUnixSocket(server, socketPath)).resolves.toBeUndefined();
|
|
expect(server.listening).toBe(true);
|
|
});
|
|
|
|
it("rejects with a coded FailureError when the path stays unbindable", async () => {
|
|
// Point at a path under a non-existent directory: listen() fails with
|
|
// ENOENT, which is not the self-heal case, so it must reject (not throw
|
|
// uncaught) with our coded shape.
|
|
const socketPath = path.join(os.tmpdir(), "argent-nd-nope", "does", "not", "exist.sock");
|
|
const server = track(net.createServer());
|
|
|
|
const err = await bindNativeDevtoolsUnixSocket(server, socketPath).catch((e) => e);
|
|
expect(err).toBeInstanceOf(FailureError);
|
|
const signal = getFailureSignal(err as FailureError);
|
|
expect(signal?.error_code).toBe(FAILURE_CODES.NATIVE_DEVTOOLS_SOCKET_BIND_FAILED);
|
|
expect(signal?.failure_stage).toBe("native_devtools_socket_bind");
|
|
});
|
|
|
|
it("does not throw uncaught when binding a path already held by a live server", async () => {
|
|
const socketPath = tmpSock("live.sock");
|
|
const first = track(net.createServer());
|
|
await bindNativeDevtoolsUnixSocket(first, socketPath);
|
|
expect(first.listening).toBe(true);
|
|
|
|
// A second server contends for the same live path. The helper unlinks +
|
|
// retries once; whichever way it resolves, it must NOT throw uncaught.
|
|
const second = track(net.createServer());
|
|
await bindNativeDevtoolsUnixSocket(second, socketPath).catch(() => {
|
|
/* rejection is an acceptable outcome; an uncaught throw is not */
|
|
});
|
|
expect(second.listening === true || second.listening === false).toBe(true);
|
|
});
|
|
});
|