diff --git a/src/daemon/host.ts b/src/daemon/host.ts index 433fbe8..57c6e03 100644 --- a/src/daemon/host.ts +++ b/src/daemon/host.ts @@ -452,18 +452,24 @@ async function prepareSocket(socketPath: string): Promise { } async function cleanupArtifacts(options: DaemonHostOptions): Promise { + await cleanupDaemonArtifactsIfOwned(options, process.pid); +} + +export async function cleanupDaemonArtifactsIfOwned( + paths: Pick, + ownerPid: number +): Promise { + // A superseded daemon may finish shutting down after its replacement has + // already rebound the same paths. Never let that old process unlink the + // replacement daemon's live socket and metadata. + const metadata = await readJsonFile<{ pid?: number; socketPath?: string }>(paths.metadataPath).catch(() => undefined); + if (metadata?.pid !== ownerPid || metadata.socketPath !== paths.socketPath) { + return; + } if (process.platform !== 'win32') { - try { - await fs.unlink(options.socketPath); - } catch { - // ignore - } - } - try { - await fs.unlink(options.metadataPath); - } catch { - // ignore + await fs.unlink(paths.socketPath).catch(() => {}); } + await fs.unlink(paths.metadataPath).catch(() => {}); } async function handleSocketRequest( diff --git a/tests/daemon-host.test.ts b/tests/daemon-host.test.ts index 93cf0c6..f40f524 100644 --- a/tests/daemon-host.test.ts +++ b/tests/daemon-host.test.ts @@ -5,7 +5,12 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ServerDefinition } from '../src/config.js'; -import { __testProcessRequest, isDaemonResponding, metadataMatches } from '../src/daemon/host.js'; +import { + __testProcessRequest, + cleanupDaemonArtifactsIfOwned, + isDaemonResponding, + metadataMatches, +} from '../src/daemon/host.js'; import type { DaemonRequest } from '../src/daemon/protocol.js'; import type { Runtime } from '../src/runtime.js'; @@ -246,6 +251,41 @@ describe('metadataMatches', () => { }); }); +describe('daemon artifact cleanup', () => { + let dir: string; + let metadataPath: string; + let socketPath: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cleanup-')); + metadataPath = path.join(dir, 'daemon.json'); + socketPath = path.join(dir, 'daemon.sock'); + await fs.writeFile(socketPath, 'socket', 'utf8'); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('removes artifacts still owned by the stopping daemon', async () => { + await fs.writeFile(metadataPath, JSON.stringify({ pid: 4321, socketPath }), 'utf8'); + + await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321); + + await expect(fs.access(metadataPath)).rejects.toThrow(); + await expect(fs.access(socketPath)).rejects.toThrow(); + }); + + it('preserves artifacts replaced by a newer daemon', async () => { + await fs.writeFile(metadataPath, JSON.stringify({ pid: 9876, socketPath }), 'utf8'); + + await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321); + + await expect(fs.access(metadataPath)).resolves.toBeUndefined(); + await expect(fs.access(socketPath)).resolves.toBeUndefined(); + }); +}); + function createRuntimeDouble(): Pick { return { callTool: vi.fn().mockResolvedValue({ ok: true }),