Skip to content

Commit 5b1c324

Browse files
authored
fix(host-service): record the descriptor table when git cannot be spawned (#6828)
* fix(host-service): record the descriptor table when git cannot be spawned git.getStatus polls every couple of seconds, and on two machines the spawn of the git subprocess started failing with EBADF before git ran. Once a machine enters that state it never leaves it: 14.7k events across seven days from two machines, the single largest error source in this service. The captured stack is entirely inside simple-git's executor, and nothing in the event says why the descriptor was bad. Attach the process's open descriptor count and RLIMIT_NOFILE soft limit to spawn-syscall failures on this path, so the next occurrence separates exhaustion (count at the limit — we leak) from corruption (count nowhere near it). Classification is unchanged: these keep reporting as 500s. Refs HOST-SERVICE-4E Refs HOST-SERVICE-1R Claude-Session: https://claude.ai/code/session_019K18zjoDbeUSqdmxtm9GgR * test(host-service): accept an unlimited descriptor cap in the diagnostics test The soft limit is typed `number | string | undefined` because a container with no cap reports it as "unlimited", but the test asserted `toBeGreaterThan(0)`, which that value fails. Flagged by CodeRabbit. Relaxed only that assertion, and only to what the type actually promises. The descriptor count stays strict: both shipped platforms always let a process list its own table, so anything but a positive number there means the counter has stopped working, and weakening both would leave the test asserting almost nothing. Claude-Session: https://claude.ai/code/session_013MCFhBn5QrGso7qtPidC9N
1 parent 5813a90 commit 5b1c324

5 files changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* A side channel for state a throw site measured at the moment of failure and
3+
* the Sentry middleware should report alongside the event.
4+
*
5+
* The middleware decides what to report from the status code alone and never
6+
* reads error text; this is how a throw site hands it evidence without
7+
* changing the error, its message, or its classification. Attaching is not a
8+
* capture — an error carrying diagnostics is reported exactly when it would
9+
* have been anyway.
10+
*
11+
* Keyed by a module-private symbol: the error travels on to tRPC's error
12+
* formatter and out to the client, and only enumerable string keys go with it.
13+
*/
14+
const DIAGNOSTICS = Symbol("errorDiagnostics");
15+
16+
/** Undefined values are kept: "we looked and could not read it" is a fact
17+
* worth seeing in the event, and it is not the same as never having looked. */
18+
export type ErrorDiagnostics = Record<string, number | string | undefined>;
19+
20+
export function attachErrorDiagnostics(
21+
error: unknown,
22+
diagnostics: ErrorDiagnostics,
23+
): void {
24+
if (!(error instanceof Error)) return;
25+
(error as unknown as Record<symbol, ErrorDiagnostics>)[DIAGNOSTICS] =
26+
diagnostics;
27+
}
28+
29+
export function readErrorDiagnostics(
30+
error: unknown,
31+
): ErrorDiagnostics | undefined {
32+
if (!(error instanceof Error)) return undefined;
33+
return (error as unknown as Record<symbol, ErrorDiagnostics | undefined>)[
34+
DIAGNOSTICS
35+
];
36+
}

packages/host-service/src/trpc/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as Sentry from "@sentry/node";
22
import { initTRPC, TRPCError } from "@trpc/server";
33
import superjson from "superjson";
44
import type { HostServiceContext } from "../types";
5+
import { readErrorDiagnostics } from "./error-diagnostics";
56
import {
67
type DeleteInProgressCause,
78
isDeleteInProgressCause,
@@ -88,6 +89,9 @@ const sentryMiddleware = t.middleware(async ({ next, path, type }) => {
8889
},
8990
extra: {
9091
trpc_message: error.message,
92+
// State a throw site measured at the moment of failure. Reporting
93+
// is unchanged: this only fills in an event that is already going.
94+
...readErrorDiagnostics(originalError),
9195
},
9296
});
9397
}

packages/host-service/src/trpc/router/git/git.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
REVIEW_THREADS_QUERY,
4646
} from "./utils/graphql";
4747
import { resolveWorktreePath } from "./utils/resolve-worktree";
48+
import { attachSpawnFailureDiagnostics } from "./utils/spawn-failure-diagnostics";
4849

4950
// Front-door cap for commit-file diffs. Statuses are admitted by
5051
// gitStatusRefreshLimiter; without a cap here, a burst of distinct-commit
@@ -243,6 +244,10 @@ export const gitRouter = router({
243244
// worktree can vanish between resolveWorktreePath's existsSync
244245
// check and the git spawn.
245246
rethrowEnvironmentalGitError(error);
247+
// A spawn that never produced a process reports with no
248+
// first-party frame and no reason; record the descriptor table
249+
// while we are still standing in the failure.
250+
attachSpawnFailureDiagnostics(error);
246251
throw error;
247252
}
248253
}),
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { readErrorDiagnostics } from "../../../error-diagnostics";
3+
import { attachSpawnFailureDiagnostics } from "./spawn-failure-diagnostics";
4+
5+
function diagnose(message: string) {
6+
const error = new Error(message);
7+
attachSpawnFailureDiagnostics(error);
8+
return { error, diagnostics: readErrorDiagnostics(error) };
9+
}
10+
11+
describe("attachSpawnFailureDiagnostics", () => {
12+
test("spawn refused outright → descriptor table recorded", () => {
13+
// Verbatim from HOST-SERVICE-4E / HOST-SERVICE-1R: child_process throws
14+
// synchronously for EBADF, and simple-git keeps only String(err).
15+
const { diagnostics } = diagnose("Error: spawn EBADF");
16+
expect(diagnostics).toBeDefined();
17+
// The count stays strict: both shipped platforms always let a process
18+
// list its own descriptor table, so anything but a positive number here
19+
// means the counter stopped working.
20+
expect(diagnostics?.open_file_descriptors).toBeGreaterThan(0);
21+
// The limit is the one that genuinely varies — a container with no cap
22+
// reports it as "unlimited", which is a fact worth recording, not a
23+
// failure. Anything else would be.
24+
const limit = diagnostics?.file_descriptor_soft_limit;
25+
expect(
26+
limit === "unlimited" || (typeof limit === "number" && limit > 0),
27+
).toBe(true);
28+
});
29+
30+
test("spawn refused via the child's error event → recorded too", () => {
31+
// EACCES/EAGAIN/EMFILE/ENFILE/ENOENT reach the child's error event
32+
// instead of throwing, and simple-git puts err.stack on stderr. First
33+
// line verbatim from the same trpc_path (releases 1.22.0 and 1.23.0);
34+
// frames reproduced locally against a git binary that does not exist.
35+
// EMFILE arrives this way, so the exhaustion case must not be excluded
36+
// by the shape the message happens to take.
37+
const { diagnostics } = diagnose(
38+
"Error: spawn git EAGAIN\n" +
39+
" at ChildProcess._handle.onexit (node:internal/child_process:285:19)\n" +
40+
" at onErrorNT (node:internal/child_process:483:16)\n" +
41+
" at process.processTicksAndRejections (node:internal/process/task_queues:90:21)",
42+
);
43+
expect(diagnostics).toBeDefined();
44+
expect(diagnostics?.open_file_descriptors).toBeGreaterThan(0);
45+
});
46+
47+
test("git ran and exited non-zero → nothing attached", () => {
48+
// Real traffic from git.getStatus in the same week as the spawn groups.
49+
// git started, did its work and refused: the descriptor table says
50+
// nothing about a damaged object store, and attaching it here would
51+
// turn the diagnostics into noise on the most common failures we see.
52+
expect(diagnose("fatal: bad object HEAD\n").diagnostics).toBeUndefined();
53+
expect(
54+
diagnose(
55+
"error: file .git/objects/pack/pack-816a419ea2792b300adb04c1f8bc739065981ebe.pack is far too short to be a packfile\n",
56+
).diagnostics,
57+
).toBeUndefined();
58+
expect(
59+
diagnose(
60+
"fatal: not a git repository (or any of the parent directories): .git\n",
61+
).diagnostics,
62+
).toBeUndefined();
63+
});
64+
65+
test("a spawn failure inside git's output is not our spawn failing", () => {
66+
// The over-match this branch has to refuse: a clean filter that is
67+
// itself a Node program crashes on its own spawn, and git relays the
68+
// crash dump. Our spawn succeeded — git ran, and its stderr is
69+
// quoting someone else's failure. Modelled on the filter failures in
70+
// classify-git-error.test.ts, which are real traffic from this path.
71+
expect(
72+
diagnose(
73+
"node:internal/child_process:421\n" +
74+
" throw errnoException(err, 'spawn');\n" +
75+
" ^\n\n" +
76+
"Error: spawn image-optimiser ENOENT\n" +
77+
" at ChildProcess.spawn (node:internal/child_process:421:11)\n" +
78+
"error: external filter 'media-filter' failed\n" +
79+
"fatal: assets/img/hash.png: clean filter 'media' failed\n",
80+
).diagnostics,
81+
).toBeUndefined();
82+
});
83+
84+
test("leaves the error itself alone", () => {
85+
const { error } = diagnose("Error: spawn EBADF");
86+
expect(error.message).toBe("Error: spawn EBADF");
87+
expect(Object.keys(error)).toEqual([]);
88+
});
89+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { readdirSync } from "node:fs";
2+
import { attachErrorDiagnostics } from "../../../error-diagnostics";
3+
4+
// Node's own text for a `spawn` that never produced a process. `errnoException`
5+
// builds it, so the syscall name and the errno are the whole line: `spawn
6+
// EBADF` when child_process throws synchronously, `spawn git EAGAIN` when it
7+
// defers to the child's error event (EACCES/EAGAIN/EMFILE/ENFILE/ENOENT take
8+
// that route). simple-git surfaces the first as `String(err)` and the second as
9+
// `err.stack` — one line, or that line followed by ` at ` frames.
10+
//
11+
// This is matched on text rather than on `err.code` because the errno is gone
12+
// as structure long before this seam: simple-git's onFatalException replaces
13+
// the SystemError with `new GitError(task, String(e))`, keeping only the
14+
// sentence, and the worker boundary then keeps only name/message/stack/code.
15+
// The syscall name and errno in that sentence are what survive.
16+
const SPAWN_SYSCALL_FAILURE_PATTERN = /^(?:Error: )?spawn (?:.+ )?E[A-Z0-9]+$/;
17+
18+
/**
19+
* Whether a git failure is the spawn syscall failing rather than git running
20+
* and exiting non-zero.
21+
*
22+
* Anchored to the start of the message, which is what separates our own spawn
23+
* from one mentioned inside git's output: when the spawn fails there is no
24+
* process and so no git stderr, and the error text is the whole message. A
25+
* hook or filter that is itself a Node program can print the same sentence,
26+
* but it arrives mid-stream, behind the program's own output and ahead of the
27+
* `fatal:` line git adds.
28+
*/
29+
export function isSpawnSyscallFailure(message: string): boolean {
30+
const firstLine = message.split("\n", 1)[0] ?? "";
31+
return SPAWN_SYSCALL_FAILURE_PATTERN.test(firstLine);
32+
}
33+
34+
/**
35+
* How many descriptors this process holds. `/proc/self/fd` on Linux, `/dev/fd`
36+
* on macOS; both list the caller's own table, and worker threads share it with
37+
* the main thread, so this is the table the failed spawn drew from. Counts the
38+
* descriptor the listing itself holds.
39+
*/
40+
function countOpenFileDescriptors(): number | undefined {
41+
const dir = process.platform === "linux" ? "/proc/self/fd" : "/dev/fd";
42+
try {
43+
return readdirSync(dir).length;
44+
} catch {
45+
return undefined;
46+
}
47+
}
48+
49+
// RLIMIT_NOFILE is inherited at exec and nothing in this process changes it,
50+
// so it is read once: process.report.getReport() is the only core API that
51+
// exposes it and it costs ~12ms, which this path would otherwise pay on every
52+
// poll — the failure repeats every couple of seconds for hours.
53+
let softLimit: number | string | undefined;
54+
let softLimitRead = false;
55+
56+
function fileDescriptorSoftLimit(): number | string | undefined {
57+
if (softLimitRead) return softLimit;
58+
softLimitRead = true;
59+
try {
60+
const report = process.report?.getReport() as
61+
| { userLimits?: { open_files?: { soft?: unknown } } }
62+
| undefined;
63+
const soft = report?.userLimits?.open_files?.soft;
64+
// `soft` is a number, or "unlimited" where the platform reports no cap.
65+
if (typeof soft === "number" || typeof soft === "string") softLimit = soft;
66+
} catch {
67+
// Diagnostics must never replace the failure they describe.
68+
}
69+
return softLimit;
70+
}
71+
72+
/**
73+
* Record the descriptor table on a git failure that never got as far as
74+
* running git.
75+
*
76+
* These failures report with no first-party frame — the captured stack is
77+
* entirely simple-git's executor — and nothing in the event says why the
78+
* spawn was refused. The count against the soft limit separates the two
79+
* candidates: at the limit is exhaustion, and the leak is ours to find;
80+
* nowhere near it is a descriptor that went bad while we still held it. See
81+
* HOST-SERVICE-4E and HOST-SERVICE-1R, where a machine enters this state and
82+
* every subsequent poll fails the same way for hours.
83+
*
84+
* No-op for anything else, and no-op on the error itself: the message,
85+
* classification and 500 are exactly what they were.
86+
*/
87+
export function attachSpawnFailureDiagnostics(error: unknown): void {
88+
if (!(error instanceof Error)) return;
89+
if (!isSpawnSyscallFailure(error.message)) return;
90+
attachErrorDiagnostics(error, {
91+
open_file_descriptors: countOpenFileDescriptors(),
92+
file_descriptor_soft_limit: fileDescriptorSoftLimit(),
93+
});
94+
}

0 commit comments

Comments
 (0)