diff --git a/src/advisory/osv-sync.ts b/src/advisory/osv-sync.ts index 4d736f5e..0788c48a 100644 --- a/src/advisory/osv-sync.ts +++ b/src/advisory/osv-sync.ts @@ -324,11 +324,33 @@ export async function syncOsvAdvisories( let replacementWarning: string | undefined; let syncResult: SyncOsvAdvisoriesResult | undefined; const startupWarnings: string[] = []; + let onSigint: (() => void) | undefined; + let onSigterm: (() => void) | undefined; try { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); startupWarnings.push(...cleanupAbandonedStagingDirectories(dbPath)); tempDir = fs.mkdtempSync(stagingDirectoryPrefix(dbPath)); + const stagingDirToClean = tempDir; + const cleanupStagingOnSignal = (signal: NodeJS.Signals) => { + try { + fs.rmSync(stagingDirToClean, { recursive: true, force: true }); + } catch { + // best-effort cleanup on abort + } + if (onSigint) process.removeListener("SIGINT", onSigint); + if (onSigterm) process.removeListener("SIGTERM", onSigterm); + if (process.platform === "win32") { + process.exit(signal === "SIGINT" ? 130 : 143); + } else { + process.kill(process.pid, signal); + } + }; + onSigint = () => cleanupStagingOnSignal("SIGINT"); + onSigterm = () => cleanupStagingOnSignal("SIGTERM"); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + recordStagingOwner(tempDir, dbPath); const tempDbPath = path.join(tempDir, path.basename(dbPath)); const zippedBytes = await downloadAdvisoryArchive(sourceUrl, dbPath, options.fetchImpl ?? fetch, onProgress); @@ -345,6 +367,8 @@ export async function syncOsvAdvisories( } catch (error) { refreshError = error; } finally { + if (onSigint) process.removeListener("SIGINT", onSigint); + if (onSigterm) process.removeListener("SIGTERM", onSigterm); if (tempDir !== undefined) cleanupWarning = cleanupTempDirectory(tempDir); } diff --git a/tests/osv-sync.test.ts b/tests/osv-sync.test.ts index 3640ad77..5169eb65 100644 --- a/tests/osv-sync.test.ts +++ b/tests/osv-sync.test.ts @@ -721,4 +721,59 @@ describe("syncOsvAdvisories", () => { expect(advisoryPath).toBe(path.join(os.homedir(), ".cache", "cve-lite", "advisories.db")); } }); + + it("removes SIGINT and SIGTERM listeners after sync completes or throws", async () => { + const tempDir = createTempDir(); + const dbPath = path.join(tempDir, "advisories.db"); + const sigintBefore = process.listenerCount("SIGINT"); + const sigtermBefore = process.listenerCount("SIGTERM"); + + try { + await syncOsvAdvisories({ + outputPath: dbPath, + fetchImpl: async () => { + throw new Error("aborted"); + }, + }).catch(() => {}); + + expect(process.listenerCount("SIGINT")).toBe(sigintBefore); + expect(process.listenerCount("SIGTERM")).toBe(sigtermBefore); + } finally { + removeDir(tempDir); + } + }); + + it("cleans up temporary staging directory if SIGINT is received during sync", async () => { + const tempDir = createTempDir(); + const dbPath = path.join(tempDir, "advisories.db"); + let capturedTempDir: string | undefined; + const killSpy = jest.spyOn(process, "kill").mockImplementation((() => true) as any); + const exitSpy = jest.spyOn(process, "exit").mockImplementation((() => {}) as any); + + try { + const syncPromise = syncOsvAdvisories({ + outputPath: dbPath, + fetchImpl: async () => { + const entries = fs.readdirSync(tempDir); + const staging = entries.find((e) => e.includes(".advisories.db.tmp-")); + if (staging) { + capturedTempDir = path.join(tempDir, staging); + expect(fs.existsSync(capturedTempDir)).toBe(true); + process.emit("SIGINT"); + } + throw new Error("aborted"); + }, + }); + + await syncPromise.catch(() => {}); + + if (capturedTempDir) { + expect(fs.existsSync(capturedTempDir)).toBe(false); + } + } finally { + killSpy.mockRestore(); + exitSpy.mockRestore(); + removeDir(tempDir); + } + }); });