Skip to content

Commit 36dd9ee

Browse files
chenyangjun-xyclaudesteipete
authored
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 <[email protected]> * test(infra): strengthen gateway lock cleanup proof --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent a9051b4 commit 36dd9ee

2 files changed

Lines changed: 47 additions & 9 deletions

File tree

src/infra/gateway-lock.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,36 @@ describe("gateway lock", () => {
377377
openSpy.mockRestore();
378378
});
379379

380+
it("closes handle and removes lock file when writeFile fails after open succeeds", async () => {
381+
vi.useRealTimers();
382+
const env = await makeEnv();
383+
const { lockPath } = resolveLockPath(env);
384+
385+
const writeError = Object.assign(new Error("ENOSPC: no space left on device"), {
386+
code: "ENOSPC",
387+
});
388+
const close = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
389+
const mockHandle = {
390+
writeFile: vi.fn().mockImplementation(async () => {
391+
await fs.writeFile(lockPath, "partial", "utf8");
392+
throw writeError;
393+
}),
394+
close,
395+
};
396+
397+
const openSpy = vi.spyOn(fs, "open").mockResolvedValueOnce(mockHandle as never);
398+
399+
await expect(acquireForTest(env)).rejects.toMatchObject({
400+
name: "GatewayLockError",
401+
cause: writeError,
402+
});
403+
404+
expect(close).toHaveBeenCalledTimes(1);
405+
await expect(fs.access(lockPath)).rejects.toMatchObject({ code: "ENOENT" });
406+
407+
openSpy.mockRestore();
408+
});
409+
380410
it("clears stale lock on win32 when process cmdline is not a gateway", async () => {
381411
vi.useRealTimers();
382412
const env = await makeEnv();

src/infra/gateway-lock.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -262,16 +262,24 @@ export async function acquireGatewayLock(
262262
while (now() - startedAt < timeoutMs) {
263263
try {
264264
const handle = await fs.open(lockPath, "wx");
265-
const startTime = platform === "linux" ? readLinuxStartTime(process.pid) : null;
266-
const payload: LockPayload = {
267-
pid: process.pid,
268-
createdAt: resolveTimestampMsToIsoString(now()),
269-
configPath,
270-
};
271-
if (typeof startTime === "number" && Number.isFinite(startTime)) {
272-
payload.startTime = startTime;
265+
try {
266+
const startTime = platform === "linux" ? readLinuxStartTime(process.pid) : null;
267+
const payload: LockPayload = {
268+
pid: process.pid,
269+
createdAt: resolveTimestampMsToIsoString(now()),
270+
configPath,
271+
};
272+
if (typeof startTime === "number" && Number.isFinite(startTime)) {
273+
payload.startTime = startTime;
274+
}
275+
await handle.writeFile(JSON.stringify(payload), "utf8");
276+
} catch (error) {
277+
// Acquisition owns both resources until the release callback exists.
278+
// Unwind them if payload preparation fails before ownership transfers.
279+
await handle.close().catch(() => undefined);
280+
await fs.rm(lockPath, { force: true }).catch(() => undefined);
281+
throw error;
273282
}
274-
await handle.writeFile(JSON.stringify(payload), "utf8");
275283
return {
276284
lockPath,
277285
configPath,

0 commit comments

Comments
 (0)