Fix/issue 98958 gateway lock fd leak (#99291)

* fix(infra): close fd and remove lock file on writeFile failure (#98958)

When fs.open(lockPath, "wx") succeeds but handle.writeFile() fails
(e.g. disk full / ENOSPC), close the file handle and remove the
partially-written lock file before re-throwing to avoid a file
descriptor leak and stale lock artifact.

Changes:
- src/infra/gateway-lock.ts: nested try-catch around writeFile
- src/infra/gateway-lock.test.ts: test for fd close + lock cleanup
- scripts/verify-gateway-lock-fd-leak*.mjs: fault-injection proof

Co-Authored-By: Claude <noreply@anthropic.com>

* test(infra): strengthen gateway lock cleanup proof

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
(cherry picked from commit 36dd9ee3c3)
This commit is contained in:
chenyangjun-xy
2026-07-03 17:23:41 +08:00
committed by Dallin Romney
parent 3faf92d6c7
commit c510b7623f
2 changed files with 47 additions and 9 deletions
+30
View File
@@ -377,6 +377,36 @@ describe("gateway lock", () => {
openSpy.mockRestore();
});
it("closes handle and removes lock file when writeFile fails after open succeeds", async () => {
vi.useRealTimers();
const env = await makeEnv();
const { lockPath } = resolveLockPath(env);
const writeError = Object.assign(new Error("ENOSPC: no space left on device"), {
code: "ENOSPC",
});
const close = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const mockHandle = {
writeFile: vi.fn().mockImplementation(async () => {
await fs.writeFile(lockPath, "partial", "utf8");
throw writeError;
}),
close,
};
const openSpy = vi.spyOn(fs, "open").mockResolvedValueOnce(mockHandle as never);
await expect(acquireForTest(env)).rejects.toMatchObject({
name: "GatewayLockError",
cause: writeError,
});
expect(close).toHaveBeenCalledTimes(1);
await expect(fs.access(lockPath)).rejects.toMatchObject({ code: "ENOENT" });
openSpy.mockRestore();
});
it("clears stale lock on win32 when process cmdline is not a gateway", async () => {
vi.useRealTimers();
const env = await makeEnv();
+17 -9
View File
@@ -262,16 +262,24 @@ export async function acquireGatewayLock(
while (now() - startedAt < timeoutMs) {
try {
const handle = await fs.open(lockPath, "wx");
const startTime = platform === "linux" ? readLinuxStartTime(process.pid) : null;
const payload: LockPayload = {
pid: process.pid,
createdAt: resolveTimestampMsToIsoString(now()),
configPath,
};
if (typeof startTime === "number" && Number.isFinite(startTime)) {
payload.startTime = startTime;
try {
const startTime = platform === "linux" ? readLinuxStartTime(process.pid) : null;
const payload: LockPayload = {
pid: process.pid,
createdAt: resolveTimestampMsToIsoString(now()),
configPath,
};
if (typeof startTime === "number" && Number.isFinite(startTime)) {
payload.startTime = startTime;
}
await handle.writeFile(JSON.stringify(payload), "utf8");
} catch (error) {
// Acquisition owns both resources until the release callback exists.
// Unwind them if payload preparation fails before ownership transfers.
await handle.close().catch(() => undefined);
await fs.rm(lockPath, { force: true }).catch(() => undefined);
throw error;
}
await handle.writeFile(JSON.stringify(payload), "utf8");
return {
lockPath,
configPath,