diff --git a/src/infra/gateway-lock.test.ts b/src/infra/gateway-lock.test.ts index b4250647efe6..85cb777e8b5c 100644 --- a/src/infra/gateway-lock.test.ts +++ b/src/infra/gateway-lock.test.ts @@ -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>().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(); diff --git a/src/infra/gateway-lock.ts b/src/infra/gateway-lock.ts index 6454dbac0026..acfab7030f64 100644 --- a/src/infra/gateway-lock.ts +++ b/src/infra/gateway-lock.ts @@ -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,