Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ When reading issues:
- When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
- After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
-
## Multi-process filesystem state
- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces.
- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe.
- Use workspace-key scoped directories for workspace-owned state.
- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration.
- Use shared lock and atomic-write helpers for mutable shared files.
- Prefer one-record-per-file registries over shared aggregate files.
- Cleanup must verify ownership before deleting shared artifacts.

## Style
- Keep answers short and concise
- No emojis in commits, issues, PR comments, or code
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
- Fixed simulator OSLog helper cleanup so server and daemon startup reconcile same-workspace orphaned log streams without stopping helpers owned by live sessions in other workspaces ([#382](https://github.com/getsentry/XcodeBuildMCP/issues/382)).
- Fixed Weather example test discovery and made CLI test progress visible while tests are running instead of leaving the last build phase displayed.

### Changed

- Centralized workspace log retention and startup/shutdown filesystem cleanup so XcodeBuildMCP-owned logs are pruned consistently while preserving active daemon and simulator OSLog outputs.

## [2.5.0-beta.1]

### Breaking
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ When reading issues:
- When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
- After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
-
## Multi-process filesystem state
- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces.
- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe.
- Use workspace-key scoped directories for workspace-owned state.
- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration.
- Use shared lock and atomic-write helpers for mutable shared files.
- Prefer one-record-per-file registries over shared aggregate files.
- Cleanup must verify ownership before deleting shared artifacts.

## Style
- Keep answers short and concise
- No emojis in commits, issues, PR comments, or code
Expand Down
15 changes: 2 additions & 13 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@
import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
import { buildCliToolCatalog } from './cli/cli-tool-catalog.ts';
import { buildYargsApp } from './cli/yargs-app.ts';
import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/socket-path.ts';
import { getSocketPath } from './daemon/socket-path.ts';
import { startMcpServer } from './server/start-mcp-server.ts';
import { listCliWorkflowIdsFromManifest } from './runtime/tool-catalog.ts';
import { flushAndCloseSentry, initSentry, recordBootstrapDurationMetric } from './utils/sentry.ts';
import { coerceLogLevel, setLogLevel, type LogLevel } from './utils/logger.ts';
import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';
import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts';

function findTopLevelCommand(argv: string[]): string | undefined {
const flagsWithValue = new Set(['--socket', '--log-level', '--style']);
Expand Down Expand Up @@ -119,23 +118,13 @@ async function main(): Promise<void> {
},
});

// Compute workspace context for daemon routing
const workspaceRoot = resolveWorkspaceRoot({
cwd: result.runtime.cwd,
projectConfigPath: result.configPath,
});
const { workspaceRoot, workspaceKey } = result;

const defaultSocketPath = getSocketPath({
cwd: result.runtime.cwd,
projectConfigPath: result.configPath,
});

const workspaceKey = getWorkspaceKey({
cwd: result.runtime.cwd,
projectConfigPath: result.configPath,
});
configureRuntimeWorkspaceKey(workspaceKey);

const cliExposedWorkflowIds = await listCliWorkflowIdsFromManifest({
excludeWorkflows: ['session-management', 'workflow-discovery'],
});
Expand Down
94 changes: 94 additions & 0 deletions src/cli/__tests__/daemon-control-race.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DaemonRegistryEntry } from '../../daemon/daemon-registry.ts';

const originalEntry: DaemonRegistryEntry = {
workspaceKey: 'workspace-a',
workspaceRoot: '/workspaces/workspace-a',
socketPath: '/tmp/xcodebuildmcp-daemon.sock',
pid: 123_456,
startedAt: '2026-05-05T00:00:00.000Z',
enabledWorkflows: ['build'],
version: '1.0.0',
instanceId: 'daemon-instance-a',
};

const changedEntry: DaemonRegistryEntry = {
...originalEntry,
instanceId: 'daemon-instance-b',
};

const registryMocks = vi.hoisted(() => ({
cleanupWorkspaceDaemonFiles: vi.fn(),
findDaemonRegistryEntryBySocketPath: vi.fn(),
isPidAlive: vi.fn(),
readDaemonRegistryEntry: vi.fn(),
release: vi.fn(),
}));

vi.mock('../../daemon/daemon-registry.ts', () => ({
acquireDaemonRegistryMutationLock: vi.fn(() => ({
workspaceKey: 'workspace-a',
release: registryMocks.release,
})),
cleanupWorkspaceDaemonFiles: registryMocks.cleanupWorkspaceDaemonFiles,
findDaemonRegistryEntryBySocketPath: registryMocks.findDaemonRegistryEntryBySocketPath,
readDaemonRegistryEntry: registryMocks.readDaemonRegistryEntry,
}));

vi.mock('../../utils/process-liveness.ts', () => ({
isPidAlive: registryMocks.isPidAlive,
}));

import { forceStopDaemon } from '../daemon-control.ts';

describe('daemon control force-stop registry races', () => {
beforeEach(() => {
registryMocks.cleanupWorkspaceDaemonFiles.mockReset();
registryMocks.findDaemonRegistryEntryBySocketPath.mockReset();
registryMocks.isPidAlive.mockReset();
registryMocks.readDaemonRegistryEntry.mockReset();
registryMocks.release.mockReset();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('sends the initial signal before releasing the registry mutation lock', async () => {
registryMocks.findDaemonRegistryEntryBySocketPath.mockReturnValue(originalEntry);
registryMocks.readDaemonRegistryEntry.mockReturnValue(originalEntry);
registryMocks.isPidAlive.mockReturnValue(false);
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
expect(registryMocks.release).not.toHaveBeenCalled();
return true;
});

await forceStopDaemon(originalEntry.socketPath);

expect(kill).toHaveBeenCalledWith(originalEntry.pid, 'SIGTERM');
expect(registryMocks.release).toHaveBeenCalledOnce();
expect(registryMocks.cleanupWorkspaceDaemonFiles).toHaveBeenCalledWith(
originalEntry.workspaceKey,
{
pid: originalEntry.pid,
socketPath: originalEntry.socketPath,
instanceId: originalEntry.instanceId,
allowLiveOwner: true,
},
);
});

it('does not signal when daemon metadata changes before the initial signal', async () => {
registryMocks.findDaemonRegistryEntryBySocketPath.mockReturnValue(originalEntry);
registryMocks.readDaemonRegistryEntry.mockReturnValue(changedEntry);
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);

await expect(forceStopDaemon(originalEntry.socketPath)).rejects.toThrow(
'daemon registry metadata changed',
);

expect(kill).not.toHaveBeenCalled();
expect(registryMocks.cleanupWorkspaceDaemonFiles).not.toHaveBeenCalled();
expect(registryMocks.release).toHaveBeenCalledOnce();
});
});
177 changes: 177 additions & 0 deletions src/cli/__tests__/daemon-control.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { forceStopDaemon } from '../daemon-control.ts';
import {
readDaemonRegistryEntry,
type DaemonRegistryEntry,
writeDaemonRegistryEntry,
} from '../../daemon/daemon-registry.ts';
import {
daemonDirForWorkspaceKey,
setDaemonRunDirOverrideForTests,
} from '../../daemon/socket-path.ts';
import { setXcodeBuildMCPAppDirOverrideForTests } from '../../utils/log-paths.ts';

const daemonPid = 123_456;

function createMissingPidError(): NodeJS.ErrnoException {
const error = new Error('no such process') as NodeJS.ErrnoException;
error.code = 'ESRCH';
return error;
}

function createEntry(overrides: Partial<DaemonRegistryEntry> = {}): DaemonRegistryEntry {
const workspaceKey = overrides.workspaceKey ?? 'workspace-a';
return {
workspaceKey,
workspaceRoot: `/workspaces/${workspaceKey}`,
socketPath: path.join(daemonDirForWorkspaceKey(workspaceKey), 'd.sock'),
pid: daemonPid,
startedAt: '2026-05-05T00:00:00.000Z',
enabledWorkflows: ['build'],
version: '1.0.0',
instanceId: 'daemon-instance-a',
...overrides,
};
}

describe('daemon control', () => {
let appDir: string;
let daemonRunDir: string;

beforeEach(() => {
appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-daemon-control-app-'));
daemonRunDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-daemon-control-run-'));
setXcodeBuildMCPAppDirOverrideForTests(appDir);
setDaemonRunDirOverrideForTests(daemonRunDir);
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
setXcodeBuildMCPAppDirOverrideForTests(null);
setDaemonRunDirOverrideForTests(null);
rmSync(appDir, { recursive: true, force: true });
rmSync(daemonRunDir, { recursive: true, force: true });
});

it('does not unlink sockets without registry metadata', async () => {
const socketPath = path.join(daemonRunDir, 'missing-registry.sock');
mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
writeFileSync(socketPath, 'socket placeholder');

await expect(forceStopDaemon(socketPath)).rejects.toThrow('registry metadata is missing');

expect(existsSync(socketPath)).toBe(true);
});

it('unregisters daemon files only after SIGTERM stops the process', async () => {
const entry = createEntry();
writeDaemonRegistryEntry(entry);
mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 });
writeFileSync(entry.socketPath, 'socket placeholder');
let alive = true;
const kill = vi.spyOn(process, 'kill').mockImplementation(((
_pid: number,
signal?: string | number,
) => {
if (signal === 0) {
if (alive) {
return true;
}
throw createMissingPidError();
}
if (signal === 'SIGTERM') {
alive = false;
}
return true;
}) as typeof process.kill);

await forceStopDaemon(entry.socketPath);

expect(kill).toHaveBeenCalledWith(entry.pid, 'SIGTERM');
expect(readDaemonRegistryEntry(entry.workspaceKey)).toBeNull();
expect(existsSync(entry.socketPath)).toBe(false);
});

it('cleans daemon files when the stopped PID is reused before cleanup', async () => {
const entry = createEntry();
writeDaemonRegistryEntry(entry);
mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 });
writeFileSync(entry.socketPath, 'socket placeholder');
let zeroSignalChecksAfterTerm = 0;
const kill = vi.spyOn(process, 'kill').mockImplementation(((
_pid: number,
signal?: string | number,
) => {
if (signal === 0) {
zeroSignalChecksAfterTerm += 1;
if (zeroSignalChecksAfterTerm === 1) {
throw createMissingPidError();
}
return true;
}
return true;
}) as typeof process.kill);

await forceStopDaemon(entry.socketPath);

expect(kill).toHaveBeenCalledWith(entry.pid, 'SIGTERM');
expect(readDaemonRegistryEntry(entry.workspaceKey)).toBeNull();
expect(existsSync(entry.socketPath)).toBe(false);
});

it('uses SIGKILL when the process stays alive after SIGTERM', async () => {
vi.useFakeTimers();
const entry = createEntry();
writeDaemonRegistryEntry(entry);
mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 });
writeFileSync(entry.socketPath, 'socket placeholder');
let alive = true;
const kill = vi.spyOn(process, 'kill').mockImplementation(((
_pid: number,
signal?: string | number,
) => {
if (signal === 0) {
if (alive) {
return true;
}
throw createMissingPidError();
}
if (signal === 'SIGKILL') {
alive = false;
}
return true;
}) as typeof process.kill);

const stopped = forceStopDaemon(entry.socketPath);
await vi.advanceTimersByTimeAsync(1500);
await stopped;

expect(kill).toHaveBeenCalledWith(entry.pid, 'SIGTERM');
expect(kill).toHaveBeenCalledWith(entry.pid, 'SIGKILL');
expect(readDaemonRegistryEntry(entry.workspaceKey)).toBeNull();
expect(existsSync(entry.socketPath)).toBe(false);
});

it('preserves registry metadata and socket when the process remains alive', async () => {
vi.useFakeTimers();
const entry = createEntry();
writeDaemonRegistryEntry(entry);
mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 });
writeFileSync(entry.socketPath, 'socket placeholder');
vi.spyOn(process, 'kill').mockImplementation((() => true) as typeof process.kill);

const stopped = forceStopDaemon(entry.socketPath);
const stoppedExpectation = expect(stopped).rejects.toThrow(
`Daemon PID ${entry.pid} did not exit after SIGKILL`,
);
await vi.advanceTimersByTimeAsync(3000);

await stoppedExpectation;
expect(readDaemonRegistryEntry(entry.workspaceKey)).toEqual(entry);
expect(existsSync(entry.socketPath)).toBe(true);
});
});
Loading
Loading