fix(daemon): preserve replacement socket ownership
Some checks are pending
CI / build (${{ matrix.os }}) (windows-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (macos-15) (push) Waiting to run
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Waiting to run

This commit is contained in:
Peter Steinberger 2026-06-25 13:46:43 -07:00
parent 782e028abe
commit fe87142d89
No known key found for this signature in database
2 changed files with 57 additions and 11 deletions

View File

@ -452,18 +452,24 @@ async function prepareSocket(socketPath: string): Promise<void> {
}
async function cleanupArtifacts(options: DaemonHostOptions): Promise<void> {
await cleanupDaemonArtifactsIfOwned(options, process.pid);
}
export async function cleanupDaemonArtifactsIfOwned(
paths: Pick<DaemonHostOptions, 'metadataPath' | 'socketPath'>,
ownerPid: number
): Promise<void> {
// 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(

View File

@ -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<Runtime, 'callTool' | 'listTools'> {
return {
callTool: vi.fn().mockResolvedValue({ ok: true }),