Skip to content

Commit 87c486d

Browse files
committed
feat: Workspace filesystem cleanup
Centralize workspace-scoped filesystem cleanup so log retention, daemon files, and simulator OSLog helpers are managed through multi-process-safe paths and locks. This keeps active workspace artifacts protected while pruning stale XcodeBuildMCP-owned files consistently.
1 parent b390867 commit 87c486d

159 files changed

Lines changed: 3817 additions & 558 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ When reading issues:
8080
- When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
8181
- After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
8282
-
83+
## Multi-process filesystem state
84+
- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces.
85+
- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe.
86+
- Use workspace-key scoped directories for workspace-owned state.
87+
- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration.
88+
- Use shared lock and atomic-write helpers for mutable shared files.
89+
- Prefer one-record-per-file registries over shared aggregate files.
90+
- Cleanup must verify ownership before deleting shared artifacts.
91+
8392
## Style
8493
- Keep answers short and concise
8594
- No emojis in commits, issues, PR comments, or code

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
- 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)).
1212
- Fixed Weather example test discovery and made CLI test progress visible while tests are running instead of leaving the last build phase displayed.
1313

14+
### Changed
15+
16+
- Centralized workspace log retention and startup/shutdown filesystem cleanup so XcodeBuildMCP-owned logs are pruned consistently while preserving active daemon and simulator OSLog outputs.
17+
1418
## [2.5.0-beta.1]
1519

1620
### Breaking

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ When reading issues:
2121
- When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
2222
- After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
2323
-
24+
## Multi-process filesystem state
25+
- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces.
26+
- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe.
27+
- Use workspace-key scoped directories for workspace-owned state.
28+
- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration.
29+
- Use shared lock and atomic-write helpers for mutable shared files.
30+
- Prefer one-record-per-file registries over shared aggregate files.
31+
- Cleanup must verify ownership before deleting shared artifacts.
32+
2433
## Style
2534
- Keep answers short and concise
2635
- No emojis in commits, issues, PR comments, or code

src/cli.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22
import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
33
import { buildCliToolCatalog } from './cli/cli-tool-catalog.ts';
44
import { buildYargsApp } from './cli/yargs-app.ts';
5-
import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/socket-path.ts';
5+
import { getSocketPath } from './daemon/socket-path.ts';
66
import { startMcpServer } from './server/start-mcp-server.ts';
77
import { listCliWorkflowIdsFromManifest } from './runtime/tool-catalog.ts';
88
import { flushAndCloseSentry, initSentry, recordBootstrapDurationMetric } from './utils/sentry.ts';
99
import { coerceLogLevel, setLogLevel, type LogLevel } from './utils/logger.ts';
1010
import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';
11-
import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts';
1211

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

122-
// Compute workspace context for daemon routing
123-
const workspaceRoot = resolveWorkspaceRoot({
124-
cwd: result.runtime.cwd,
125-
projectConfigPath: result.configPath,
126-
});
121+
const { workspaceRoot, workspaceKey } = result;
127122

128123
const defaultSocketPath = getSocketPath({
129124
cwd: result.runtime.cwd,
130125
projectConfigPath: result.configPath,
131126
});
132127

133-
const workspaceKey = getWorkspaceKey({
134-
cwd: result.runtime.cwd,
135-
projectConfigPath: result.configPath,
136-
});
137-
configureRuntimeWorkspaceKey(workspaceKey);
138-
139128
const cliExposedWorkflowIds = await listCliWorkflowIdsFromManifest({
140129
excludeWorkflows: ['session-management', 'workflow-discovery'],
141130
});

src/cli/daemon-control.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import { fileURLToPath } from 'node:url';
33
import { dirname, resolve, basename } from 'node:path';
44
import { existsSync } from 'node:fs';
55
import { DaemonClient, DaemonVersionMismatchError } from './daemon-client.ts';
6-
import { readDaemonRegistryEntry } from '../daemon/daemon-registry.ts';
7-
import { removeStaleSocket } from '../daemon/socket-path.ts';
6+
import {
7+
cleanupWorkspaceDaemonFiles,
8+
findDaemonRegistryEntryBySocketPath,
9+
readDaemonRegistryEntry,
10+
} from '../daemon/daemon-registry.ts';
811

912
/**
1013
* Default timeout for daemon startup in milliseconds.
@@ -38,8 +41,9 @@ export function getDaemonExecutablePath(): string {
3841
* sends SIGTERM, and removes the stale socket.
3942
*/
4043
export async function forceStopDaemon(socketPath: string): Promise<void> {
41-
const workspaceKey = basename(dirname(socketPath));
42-
const entry = readDaemonRegistryEntry(workspaceKey);
44+
const matchingEntry = findDaemonRegistryEntryBySocketPath(socketPath);
45+
const workspaceKey = matchingEntry?.workspaceKey ?? basename(dirname(socketPath));
46+
const entry = matchingEntry ?? readDaemonRegistryEntry(workspaceKey);
4347
if (entry?.pid) {
4448
try {
4549
process.kill(entry.pid, 'SIGTERM');
@@ -49,7 +53,10 @@ export async function forceStopDaemon(socketPath: string): Promise<void> {
4953
// Brief wait for the process to exit.
5054
await new Promise((resolve) => setTimeout(resolve, 500));
5155
}
52-
removeStaleSocket(socketPath);
56+
cleanupWorkspaceDaemonFiles(
57+
workspaceKey,
58+
entry ? { pid: entry.pid, socketPath } : { socketPath },
59+
);
5360
}
5461

5562
export interface StartDaemonBackgroundOptions {

src/daemon.ts

Lines changed: 91 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,13 @@ import {
99
ensureSocketDir,
1010
removeStaleSocket,
1111
getSocketPath,
12-
getWorkspaceKey,
13-
resolveWorkspaceRoot,
1412
logPathForWorkspaceKey,
1513
} from './daemon/socket-path.ts';
1614
import { startDaemonServer } from './daemon/daemon-server.ts';
1715
import {
16+
acquireDaemonRegistryMutationLock,
1817
writeDaemonRegistryEntry,
19-
removeDaemonRegistryEntry,
20-
cleanupWorkspaceDaemonFiles,
18+
type DaemonRegistryMutationLock,
2119
} from './daemon/daemon-registry.ts';
2220
import { log, normalizeLogLevel, setLogFile, setLogLevel } from './utils/logger.ts';
2321
import { version } from './version.ts';
@@ -42,11 +40,11 @@ import {
4240
} from './utils/sentry.ts';
4341
import { isXcodemakeBinaryAvailable, isXcodemakeEnabled } from './utils/xcodemake/index.ts';
4442
import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';
45-
import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts';
4643
import {
47-
reconcileSimulatorLaunchOsLogOrphansForWorkspace,
48-
terminateLiveSimulatorLaunchOsLogSessionsSync,
49-
} from './utils/log-capture/index.ts';
44+
cleanupOwnedWorkspaceFilesystemArtifacts,
45+
runWorkspaceFilesystemLifecycleSweep,
46+
terminateOwnedWorkspaceFilesystemArtifactsSync,
47+
} from './utils/workspace-filesystem-lifecycle.ts';
5048

5149
async function checkExistingDaemon(socketPath: string): Promise<boolean> {
5250
return new Promise<boolean>((resolve) => {
@@ -124,16 +122,7 @@ async function main(): Promise<void> {
124122
},
125123
});
126124

127-
const workspaceRoot = resolveWorkspaceRoot({
128-
cwd: result.runtime.cwd,
129-
projectConfigPath: result.configPath,
130-
});
131-
132-
const workspaceKey = getWorkspaceKey({
133-
cwd: result.runtime.cwd,
134-
projectConfigPath: result.configPath,
135-
});
136-
configureRuntimeWorkspaceKey(workspaceKey);
125+
const { workspaceRoot, workspaceKey } = result;
137126

138127
const logPath = resolveDaemonLogPath(workspaceKey);
139128
if (logPath) {
@@ -159,20 +148,27 @@ async function main(): Promise<void> {
159148

160149
log('info', `[Daemon] Workspace: ${workspaceRoot}`);
161150
log('info', `[Daemon] Socket: ${socketPath}`);
162-
try {
163-
const reconciliation = await reconcileSimulatorLaunchOsLogOrphansForWorkspace(workspaceKey);
164-
if (reconciliation.stoppedSessionCount > 0 || reconciliation.errorCount > 0) {
151+
152+
const runStartupLifecycleSweep = async (): Promise<void> => {
153+
try {
154+
const lifecycle = await runWorkspaceFilesystemLifecycleSweep({
155+
workspaceKey,
156+
trigger: 'startup',
157+
});
158+
if (lifecycle.stopped > 0 || lifecycle.deleted > 0 || lifecycle.errors.length > 0) {
159+
log(
160+
lifecycle.errors.length > 0 ? 'warn' : 'info',
161+
`[Daemon] Filesystem lifecycle: ${JSON.stringify(lifecycle)}`,
162+
);
163+
}
164+
} catch (error) {
165165
log(
166-
reconciliation.errorCount > 0 ? 'warn' : 'info',
167-
`[Daemon] Simulator OSLog reconciliation: ${JSON.stringify(reconciliation)}`,
166+
'warn',
167+
`[Daemon] Filesystem lifecycle failed: ${error instanceof Error ? error.message : String(error)}`,
168168
);
169169
}
170-
} catch (error) {
171-
log(
172-
'warn',
173-
`[Daemon] Simulator OSLog reconciliation failed: ${error instanceof Error ? error.message : String(error)}`,
174-
);
175-
}
170+
};
171+
176172
if (logPath) {
177173
log('info', `[Daemon] Logs: ${logPath}`);
178174
}
@@ -187,6 +183,28 @@ async function main(): Promise<void> {
187183
process.exit(1);
188184
}
189185

186+
const startupRegistryLock = acquireDaemonRegistryMutationLock(workspaceKey);
187+
if (!startupRegistryLock) {
188+
log('error', '[Daemon] Unable to acquire daemon registry lock');
189+
console.error('Error: Unable to acquire daemon registry lock');
190+
await flushAndCloseSentry(1000);
191+
process.exit(1);
192+
}
193+
let pendingStartupRegistryLock: DaemonRegistryMutationLock | null = startupRegistryLock;
194+
const releaseStartupRegistryLock = (): void => {
195+
pendingStartupRegistryLock?.release();
196+
pendingStartupRegistryLock = null;
197+
};
198+
199+
const isRunningAfterLock = await checkExistingDaemon(socketPath);
200+
if (isRunningAfterLock) {
201+
releaseStartupRegistryLock();
202+
log('error', '[Daemon] Another daemon is already running for this workspace');
203+
console.error('Error: Daemon is already running for this workspace');
204+
await flushAndCloseSentry(1000);
205+
process.exit(1);
206+
}
207+
190208
removeStaleSocket(socketPath);
191209

192210
const excludedWorkflows = ['session-management', 'workflow-discovery'];
@@ -302,26 +320,33 @@ async function main(): Promise<void> {
302320
recordDaemonLifecycleMetric('shutdown');
303321
log('info', '[Daemon] Shutting down...');
304322

305-
// Close the server
323+
const cleanupArtifacts = (): Promise<unknown> =>
324+
cleanupOwnedWorkspaceFilesystemArtifacts({
325+
workspaceKey,
326+
trigger: 'shutdown',
327+
daemonCleanup: {
328+
pid: process.pid,
329+
socketPath,
330+
allowLiveOwner: true,
331+
},
332+
});
333+
306334
server.close(() => {
307335
log('info', '[Daemon] Server closed');
308-
309-
// Remove registry entry and socket
310-
removeDaemonRegistryEntry(workspaceKey);
311-
removeStaleSocket(socketPath);
312-
313-
log('info', '[Daemon] Cleanup complete');
314-
void flushAndCloseSentry(2000).finally(() => {
315-
process.exit(exitCode);
336+
void cleanupArtifacts().finally(() => {
337+
log('info', '[Daemon] Cleanup complete');
338+
void flushAndCloseSentry(2000).finally(() => {
339+
process.exit(exitCode);
340+
});
316341
});
317342
});
318343

319-
// Force exit if server doesn't close in time
320344
setTimeout(() => {
321345
log('warn', '[Daemon] Forced shutdown after timeout');
322-
cleanupWorkspaceDaemonFiles(workspaceKey);
323-
void flushAndCloseSentry(1000).finally(() => {
324-
process.exit(1);
346+
void cleanupArtifacts().finally(() => {
347+
void flushAndCloseSentry(1000).finally(() => {
348+
process.exit(1);
349+
});
325350
});
326351
}, 5000);
327352
};
@@ -384,32 +409,45 @@ async function main(): Promise<void> {
384409
idleCheckTimer.unref?.();
385410
}
386411

412+
server.on('error', releaseStartupRegistryLock);
413+
387414
server.listen(socketPath, () => {
388415
log('info', `[Daemon] Listening on ${socketPath}`);
389416

390417
// Write registry entry after successful listen
391-
writeDaemonRegistryEntry({
392-
workspaceKey,
393-
workspaceRoot,
394-
socketPath,
395-
logPath: logPath ?? undefined,
396-
pid: process.pid,
397-
startedAt,
398-
enabledWorkflows: daemonWorkflows,
399-
version: String(version),
400-
});
418+
try {
419+
writeDaemonRegistryEntry(
420+
{
421+
workspaceKey,
422+
workspaceRoot,
423+
socketPath,
424+
logPath: logPath ?? undefined,
425+
pid: process.pid,
426+
startedAt,
427+
enabledWorkflows: daemonWorkflows,
428+
version: String(version),
429+
},
430+
{ lock: startupRegistryLock },
431+
);
432+
} finally {
433+
releaseStartupRegistryLock();
434+
}
401435

402436
writeLine(`Daemon started (PID: ${process.pid})`);
403437
writeLine(`Workspace: ${workspaceRoot}`);
404438
writeLine(`Socket: ${socketPath}`);
405439
writeLine(`Tools: ${catalog.tools.length}`);
406440
recordBootstrapDurationMetric('cli-daemon', Date.now() - daemonBootstrapStart);
407441

442+
// Filesystem orphan reconciliation and log retention run fire-and-forget after listen so
443+
// a slow sweep cannot delay request serving. Request handlers must not assume orphans
444+
// have been cleaned at startup.
408445
setImmediate(() => {
409446
void enrichSentryMetadata().catch((error) => {
410447
const message = error instanceof Error ? error.message : String(error);
411448
log('warn', `[Daemon] Failed to enrich Sentry metadata: ${message}`);
412449
});
450+
void runStartupLifecycleSweep();
413451
});
414452
});
415453

@@ -421,7 +459,7 @@ async function main(): Promise<void> {
421459
};
422460

423461
process.on('exit', () => {
424-
terminateLiveSimulatorLaunchOsLogSessionsSync();
462+
terminateOwnedWorkspaceFilesystemArtifactsSync();
425463
});
426464
process.on('SIGTERM', () => shutdown(0));
427465
process.on('SIGINT', () => shutdown(0));

0 commit comments

Comments
 (0)