Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ When reading issues:
- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!")

## Docs
-
- Do not commit transient investigation notes, prompt exports, or scratch analysis docs after the work is complete.
- If an investigation leaves unresolved follow-up work, move it to a GitHub issue instead of preserving the transient doc in the branch.

### Changelog
Location: `CHANGELOG.md`

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Fixed

- 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)).

## [2.5.0-beta.1]

### Breaking
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ When reading issues:
- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!")

## Docs
-
- Do not commit transient investigation notes, prompt exports, or scratch analysis docs after the work is complete.
- If an investigation leaves unresolved follow-up work, move it to a GitHub issue instead of preserving the transient doc in the branch.

### Changelog
Location: `CHANGELOG.md`

Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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 @@ -133,6 +134,7 @@ async function main(): Promise<void> {
cwd: result.runtime.cwd,
projectConfigPath: result.configPath,
});
configureRuntimeWorkspaceKey(workspaceKey);

const cliExposedWorkflowIds = await listCliWorkflowIdsFromManifest({
excludeWorkflows: ['session-management', 'workflow-discovery'],
Expand Down
40 changes: 36 additions & 4 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ import {
} from './utils/sentry.ts';
import { isXcodemakeBinaryAvailable, isXcodemakeEnabled } from './utils/xcodemake/index.ts';
import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';
import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts';
import {
reconcileSimulatorLaunchOsLogOrphansForWorkspace,
terminateLiveSimulatorLaunchOsLogSessionsSync,
} from './utils/log-capture/index.ts';

async function checkExistingDaemon(socketPath: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
Expand Down Expand Up @@ -128,6 +133,7 @@ async function main(): Promise<void> {
cwd: result.runtime.cwd,
projectConfigPath: result.configPath,
});
configureRuntimeWorkspaceKey(workspaceKey);

const logPath = resolveDaemonLogPath(workspaceKey);
if (logPath) {
Expand All @@ -153,6 +159,20 @@ async function main(): Promise<void> {

log('info', `[Daemon] Workspace: ${workspaceRoot}`);
log('info', `[Daemon] Socket: ${socketPath}`);
try {
const reconciliation = await reconcileSimulatorLaunchOsLogOrphansForWorkspace(workspaceKey);
if (reconciliation.stoppedSessionCount > 0 || reconciliation.errorCount > 0) {
log(
reconciliation.errorCount > 0 ? 'warn' : 'info',
`[Daemon] Simulator OSLog reconciliation: ${JSON.stringify(reconciliation)}`,
);
}
} catch (error) {
log(
'warn',
`[Daemon] Simulator OSLog reconciliation failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (logPath) {
log('info', `[Daemon] Logs: ${logPath}`);
}
Expand Down Expand Up @@ -268,7 +288,7 @@ async function main(): Promise<void> {
};

// Unified shutdown handler
const shutdown = (): void => {
const shutdown = (exitCode = 0): void => {
if (isShuttingDown) {
return;
}
Expand All @@ -292,7 +312,7 @@ async function main(): Promise<void> {

log('info', '[Daemon] Cleanup complete');
void flushAndCloseSentry(2000).finally(() => {
process.exit(0);
process.exit(exitCode);
});
});

Expand Down Expand Up @@ -393,8 +413,20 @@ async function main(): Promise<void> {
});
});

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
const handleCrash = (reason: unknown): void => {
recordDaemonLifecycleMetric('crash');
const message = reason instanceof Error ? reason.message : String(reason);
log('error', `[Daemon] Crash: ${message}`, { sentry: true });
shutdown(1);
};

process.on('exit', () => {
terminateLiveSimulatorLaunchOsLogSessionsSync();
});
process.on('SIGTERM', () => shutdown(0));
process.on('SIGINT', () => shutdown(0));
process.on('uncaughtException', handleCrash);
process.on('unhandledRejection', handleCrash);
}

main().catch(async (err) => {
Expand Down
10 changes: 5 additions & 5 deletions src/mcp/resources/__tests__/session-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type { ChildProcess } from 'node:child_process';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { clearDaemonActivityRegistry } from '../../../daemon/activity-registry.ts';
import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts';
import { activeLogSessions } from '../../../utils/log_capture.ts';
import { activeDeviceLogSessions } from '../../../utils/log-capture/device-log-sessions.ts';
import {
clearAllSimulatorLaunchOsLogSessionsForTests,
Expand All @@ -34,9 +33,12 @@ describe('session-status resource', () => {
beforeEach(async () => {
registryDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-session-status-'));
setSimulatorLaunchOsLogRegistryDirOverrideForTests(registryDir);
setRuntimeInstanceForTests({ instanceId: 'session-status-test', pid: process.pid });
setRuntimeInstanceForTests({
instanceId: 'session-status-test',
pid: process.pid,
workspaceKey: 'workspace-a',
});
setSimulatorLaunchOsLogRecordActiveOverrideForTests(async () => true);
activeLogSessions.clear();
activeDeviceLogSessions.clear();
clearAllProcesses();
await clearAllSimulatorLaunchOsLogSessionsForTests();
Expand All @@ -45,7 +47,6 @@ describe('session-status resource', () => {
});

afterEach(async () => {
activeLogSessions.clear();
activeDeviceLogSessions.clear();
clearAllProcesses();
await clearAllSimulatorLaunchOsLogSessionsForTests();
Expand All @@ -64,7 +65,6 @@ describe('session-status resource', () => {
expect(result.contents).toHaveLength(1);
const parsed = JSON.parse(result.contents[0].text);

expect(parsed.logging.simulator.activeSessionIds).toEqual([]);
expect(parsed.logging.simulator.activeLaunchOsLogSessions).toEqual([]);
expect(parsed.logging.device.activeSessionIds).toEqual([]);
expect(parsed.debug.currentSessionId).toBe(null);
Expand Down
6 changes: 5 additions & 1 deletion src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ describe('stop_app_sim tool', () => {
trackedChildren.clear();
registryDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-stop-app-sim-'));
setSimulatorLaunchOsLogRegistryDirOverrideForTests(registryDir);
setRuntimeInstanceForTests({ instanceId: 'stop-app-sim-test', pid: process.pid });
setRuntimeInstanceForTests({
instanceId: 'stop-app-sim-test',
pid: process.pid,
workspaceKey: 'workspace-a',
});
setSimulatorLaunchOsLogRecordActiveOverrideForTests(async (record) => {
const child = trackedChildren.get(record.helperPid);
return child ? child.exitCode == null : true;
Expand Down
35 changes: 33 additions & 2 deletions src/server/__tests__/mcp-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ describe('mcp lifecycle coordinator', () => {
beforeEach(async () => {
registryDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-mcp-lifecycle-'));
setSimulatorLaunchOsLogRegistryDirOverrideForTests(registryDir);
setRuntimeInstanceForTests({ instanceId: 'mcp-lifecycle-test', pid: process.pid });
setRuntimeInstanceForTests({
instanceId: 'mcp-lifecycle-test',
pid: process.pid,
workspaceKey: 'workspace-a',
});
setSimulatorLaunchOsLogRecordActiveOverrideForTests(async () => true);
await clearAllSimulatorLaunchOsLogSessionsForTests();
vi.restoreAllMocks();
Expand Down Expand Up @@ -130,6 +134,29 @@ describe('mcp lifecycle coordinator', () => {
expect(onShutdown.mock.calls[0]?.[0]?.reason).toBe('unhandled-rejection');
});

it('terminates live local OSLog sessions on process exit', async () => {
const processRef = new TestProcess();
const onShutdown = vi.fn().mockResolvedValue(undefined);
const child = createTrackedChild(555);
await registerSimulatorLaunchOsLogSession({
process: child,
simulatorUuid: 'sim-1',
bundleId: 'io.sentry.app',
logFilePath: '/tmp/app.log',
});
const coordinator = createMcpLifecycleCoordinator({
commandExecutor: createMockExecutor({ output: '' }),
processRef,
onShutdown,
});

coordinator.attachProcessHandlers();
processRef.emit('exit');

expect(child.kill).toHaveBeenCalledWith('SIGTERM');
expect(onShutdown).not.toHaveBeenCalled();
});

it('maps broken stdout pipes to shutdowns', async () => {
const suppressSpy = vi
.spyOn(shutdownState, 'suppressProcessStdioWrites')
Expand Down Expand Up @@ -179,7 +206,11 @@ describe('mcp lifecycle snapshot', () => {
beforeEach(async () => {
registryDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-mcp-lifecycle-'));
setSimulatorLaunchOsLogRegistryDirOverrideForTests(registryDir);
setRuntimeInstanceForTests({ instanceId: 'mcp-lifecycle-test', pid: process.pid });
setRuntimeInstanceForTests({
instanceId: 'mcp-lifecycle-test',
pid: process.pid,
workspaceKey: 'workspace-a',
});
setSimulatorLaunchOsLogRecordActiveOverrideForTests(async () => true);
await clearAllSimulatorLaunchOsLogSessionsForTests();
});
Expand Down
29 changes: 12 additions & 17 deletions src/server/__tests__/mcp-shutdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ const mocks = vi.hoisted(() => ({
stopXcodeStateWatcher: vi.fn(async () => undefined),
shutdownXcodeToolsBridge: vi.fn(async () => undefined),
disposeAll: vi.fn(async () => undefined),
stopAllLogCaptures: vi.fn(async () => ({ stoppedSessionCount: 0, errorCount: 0, errors: [] })),
stopAllDeviceLogCaptures: vi.fn(async () => ({
stoppedSessionCount: 0,
errorCount: 0,
Expand Down Expand Up @@ -39,9 +38,6 @@ vi.mock('../../integrations/xcode-tools-bridge/index.ts', () => ({
vi.mock('../../utils/debugger/index.ts', () => ({
getDefaultDebuggerManager: () => ({ disposeAll: mocks.disposeAll }),
}));
vi.mock('../../utils/log_capture.ts', () => ({
stopAllLogCaptures: mocks.stopAllLogCaptures,
}));
vi.mock('../../utils/log-capture/device-log-sessions.ts', () => ({
stopAllDeviceLogCaptures: mocks.stopAllDeviceLogCaptures,
}));
Expand Down Expand Up @@ -90,7 +86,6 @@ describe('runMcpShutdown', () => {
activeOperationCount: 0,
activeOperationByCategory: {},
debuggerSessionCount: 0,
simulatorLogSessionCount: 0,
simulatorLaunchOsLogSessionCount: 0,
ownedSimulatorLaunchOsLogSessionCount: 0,
deviceLogSessionCount: 0,
Expand All @@ -110,15 +105,14 @@ describe('runMcpShutdown', () => {
expect(mocks.stopXcodeStateWatcher).toHaveBeenCalledTimes(1);
expect(mocks.shutdownXcodeToolsBridge).toHaveBeenCalledTimes(1);
expect(mocks.disposeAll).toHaveBeenCalledTimes(1);
expect(mocks.stopAllLogCaptures).toHaveBeenCalledTimes(1);
expect(mocks.stopAllDeviceLogCaptures).toHaveBeenCalledTimes(1);
expect(mocks.stopOwnedSimulatorLaunchOsLogSessions).toHaveBeenCalledTimes(1);
expect(mocks.stopAllVideoCaptureSessions).toHaveBeenCalledTimes(1);
expect(mocks.stopAllTrackedProcesses).toHaveBeenCalledTimes(1);
});

it('adds outer timeout headroom for one-item bulk cleanup', async () => {
mocks.stopAllLogCaptures.mockImplementationOnce(async () => {
mocks.stopOwnedSimulatorLaunchOsLogSessions.mockImplementationOnce(async () => {
await wait(1050);
return { stoppedSessionCount: 1, errorCount: 0, errors: [] };
});
Expand All @@ -139,9 +133,8 @@ describe('runMcpShutdown', () => {
activeOperationCount: 0,
activeOperationByCategory: {},
debuggerSessionCount: 0,
simulatorLogSessionCount: 1,
simulatorLaunchOsLogSessionCount: 0,
ownedSimulatorLaunchOsLogSessionCount: 0,
simulatorLaunchOsLogSessionCount: 1,
ownedSimulatorLaunchOsLogSessionCount: 1,
deviceLogSessionCount: 0,
videoCaptureSessionCount: 0,
swiftPackageProcessCount: 0,
Expand All @@ -152,12 +145,14 @@ describe('runMcpShutdown', () => {
server: { close: async () => undefined },
});

const simulatorLogsStep = result.steps.find((step) => step.name === 'simulator-logs.stop-all');
const simulatorLogsStep = result.steps.find(
(step) => step.name === 'simulator-launch-oslogs.stop-owned',
);
expect(simulatorLogsStep?.status).toBe('completed');
});

it('uses an expanded timeout budget for sequential multi-item bulk cleanup steps', async () => {
mocks.stopAllLogCaptures.mockImplementationOnce(async () => {
mocks.stopOwnedSimulatorLaunchOsLogSessions.mockImplementationOnce(async () => {
await wait(1100);
return { stoppedSessionCount: 2, errorCount: 0, errors: [] };
});
Expand All @@ -178,9 +173,8 @@ describe('runMcpShutdown', () => {
activeOperationCount: 0,
activeOperationByCategory: {},
debuggerSessionCount: 0,
simulatorLogSessionCount: 2,
simulatorLaunchOsLogSessionCount: 0,
ownedSimulatorLaunchOsLogSessionCount: 0,
simulatorLaunchOsLogSessionCount: 2,
ownedSimulatorLaunchOsLogSessionCount: 2,
deviceLogSessionCount: 0,
videoCaptureSessionCount: 0,
swiftPackageProcessCount: 0,
Expand All @@ -191,7 +185,9 @@ describe('runMcpShutdown', () => {
server: { close: async () => undefined },
});

const simulatorLogsStep = result.steps.find((step) => step.name === 'simulator-logs.stop-all');
const simulatorLogsStep = result.steps.find(
(step) => step.name === 'simulator-launch-oslogs.stop-owned',
);
expect(simulatorLogsStep?.status).toBe('completed');
});

Expand All @@ -216,7 +212,6 @@ describe('runMcpShutdown', () => {
activeOperationCount: 0,
activeOperationByCategory: {},
debuggerSessionCount: 1,
simulatorLogSessionCount: 0,
simulatorLaunchOsLogSessionCount: 0,
ownedSimulatorLaunchOsLogSessionCount: 0,
deviceLogSessionCount: 0,
Expand Down
21 changes: 20 additions & 1 deletion src/server/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ import type { RuntimeConfigOverrides } from '../utils/config-store.ts';
import { getRegisteredWorkflows, registerWorkflowsFromManifest } from '../utils/tool-registry.ts';
import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts';
import { getXcodeToolsBridgeManager } from '../integrations/xcode-tools-bridge/index.ts';
import { resolveWorkspaceRoot } from '../daemon/socket-path.ts';
import { resolveWorkspaceRoot, workspaceKeyForRoot } from '../daemon/socket-path.ts';
import { detectXcodeRuntime } from '../utils/xcode-process.ts';
import { readXcodeIdeState } from '../utils/xcode-state-reader.ts';
import { sessionStore } from '../utils/session-store.ts';
import { startXcodeStateWatcher, lookupBundleId } from '../utils/xcode-state-watcher.ts';
import { getDefaultCommandExecutor } from '../utils/command.ts';
import type { PredicateContext } from '../visibility/predicate-types.ts';
import { createStartupProfiler, getStartupProfileNowMs } from './startup-profiler.ts';
import { configureRuntimeWorkspaceKey } from '../utils/runtime-instance.ts';
import { reconcileSimulatorLaunchOsLogOrphansForWorkspace } from '../utils/log-capture/index.ts';

export interface BootstrapOptions {
enabledWorkflows?: string[];
Expand Down Expand Up @@ -76,6 +78,23 @@ export async function bootstrapServer(
cwd: result.runtime.cwd,
projectConfigPath: result.configPath,
});
const workspaceKey = workspaceKeyForRoot(workspaceRoot);
configureRuntimeWorkspaceKey(workspaceKey);

try {
const reconciliation = await reconcileSimulatorLaunchOsLogOrphansForWorkspace(workspaceKey);
if (reconciliation.stoppedSessionCount > 0 || reconciliation.errorCount > 0) {
log(
reconciliation.errorCount > 0 ? 'warn' : 'info',
`[startup] Simulator OSLog reconciliation: ${JSON.stringify(reconciliation)}`,
);
}
} catch (error) {
log(
'warn',
`[startup] Simulator OSLog reconciliation failed: ${error instanceof Error ? error.message : String(error)}`,
);
}

log('info', `🚀 Initializing server...`);

Expand Down
Loading
Loading