Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- Added `toggle_connect_hardware_keyboard` tool to toggle the iOS Simulator hardware keyboard connection ([#346](https://github.com/getsentry/XcodeBuildMCP/issues/346)).
- Fixed `xcode_tools_bridge_disconnect` immediately re-syncing proxied tools after a manual disconnect ([#343](https://github.com/getsentry/XcodeBuildMCP/issues/343)).
- Stopped suggesting an unsupported `--device-id`/`deviceId` argument in the `device list` next-step hint for `device build`/`build_device`; device targeting flows through session defaults ([#350](https://github.com/getsentry/XcodeBuildMCP/pull/350) by [@MukundaKatta](https://github.com/MukundaKatta)).
- Replaced blocking `execSync`/`readFileSync`/`unlinkSync` in the device name resolver with async process/file operations and background cache refresh, avoiding event-loop blocking during device name formatting ([#333](https://github.com/getsentry/XcodeBuildMCP/issues/333)).

## [2.3.2]

Expand Down
108 changes: 108 additions & 0 deletions src/utils/__tests__/device-name-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { execFileMock, readFileMock, unlinkMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
readFileMock: vi.fn(),
unlinkMock: vi.fn(),
}));

vi.mock('node:child_process', () => ({
execFile: execFileMock,
}));

vi.mock('node:fs/promises', () => ({
readFile: readFileMock,
unlink: unlinkMock,
}));

Check warning on line 16 in src/utils/__tests__/device-name-resolver.test.ts

View check run for this annotation

@sentry/warden / warden: xcodebuildmcp-test-boundary-review

Test mocks node:child_process and node:fs/promises directly instead of injecting executors

The test uses `vi.mock('node:child_process')` and `vi.mock('node:fs/promises')` to intercept `execFile`, `readFile`, and `unlink` rather than injecting a `CommandExecutor`/`FileSystemExecutor` as the project's test guardrails require. A repo-wide grep shows this is the only test file mocking `node:child_process` this way — every other unit test goes through `createMockExecutor` from `src/test-utils/mock-executors.ts`. The underlying cause is that `src/utils/device-name-resolver.ts` imports `execFile`/`readFile`/`unlink` directly at module scope and offers no injection seam, so the test cannot follow the standard pattern; this couples the suite to Node internals and bypasses the executor mock helpers the guardrails mandate.
Comment on lines +9 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test mocks node:child_process and node:fs/promises directly instead of injecting executors

The test uses vi.mock('node:child_process') and vi.mock('node:fs/promises') to intercept execFile, readFile, and unlink rather than injecting a CommandExecutor/FileSystemExecutor as the project's test guardrails require. A repo-wide grep shows this is the only test file mocking node:child_process this way — every other unit test goes through createMockExecutor from src/test-utils/mock-executors.ts. The underlying cause is that src/utils/device-name-resolver.ts imports execFile/readFile/unlink directly at module scope and offers no injection seam, so the test cannot follow the standard pattern; this couples the suite to Node internals and bypasses the executor mock helpers the guardrails mandate.

Verification

Read src/utils/device-name-resolver.ts and confirmed it directly imports execFile from node:child_process and readFile/unlink from node:fs/promises with no executor parameter. Read src/test-utils/mock-executors.ts and confirmed createMockExecutor / FileSystemExecutor are the project's standard injection helpers. Ran a grep for vi.mock('node:child_process') across src/ — this new test is the only match, confirming it deviates from the established pattern.

Identified by Warden xcodebuildmcp-test-boundary-review · TZF-HCM


async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}

describe('device-name-resolver', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});

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

it('loads device names asynchronously and caches resolved names', async () => {
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, '', '');
return {};
},
);

readFileMock.mockResolvedValue(
JSON.stringify({
result: {
devices: [
{
identifier: 'device-1',
deviceProperties: { name: 'iPhone 15 Pro' },
hardwareProperties: { udid: 'udid-1' },
},
],
},
}),
);
unlinkMock.mockResolvedValue(undefined);

const { resolveDeviceName, formatDeviceId } = await import('../device-name-resolver.ts');

expect(resolveDeviceName('device-1')).toBeUndefined();
expect(execFileMock).toHaveBeenCalledTimes(1);

await flushAsyncWork();

expect(resolveDeviceName('device-1')).toBe('iPhone 15 Pro');
expect(resolveDeviceName('udid-1')).toBe('iPhone 15 Pro');
expect(formatDeviceId('device-1')).toBe('iPhone 15 Pro (device-1)');
expect(readFileMock).toHaveBeenCalledTimes(1);
expect(unlinkMock).toHaveBeenCalledTimes(1);
});

it('does not spawn duplicate refreshes while one is in flight', async () => {
let callback: ((error: Error | null, stdout: string, stderr: string) => void) | undefined;

execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
cb: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback = cb;
return {};
},
);

readFileMock.mockResolvedValue(
JSON.stringify({
result: { devices: [{ identifier: 'device-2', deviceProperties: { name: 'iPad' } }] },
}),
);
unlinkMock.mockResolvedValue(undefined);

const { resolveDeviceName } = await import('../device-name-resolver.ts');

expect(resolveDeviceName('device-2')).toBeUndefined();
expect(resolveDeviceName('device-2')).toBeUndefined();
expect(execFileMock).toHaveBeenCalledTimes(1);

callback?.(null, '', '');
await flushAsyncWork();

expect(resolveDeviceName('device-2')).toBe('iPad');
});
});
102 changes: 66 additions & 36 deletions src/utils/device-name-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,93 @@
import { execSync } from 'node:child_process';
import { readFileSync, unlinkSync } from 'node:fs';
import { execFile } from 'node:child_process';
import { readFile, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';

const CACHE_TTL_MS = 30_000;
const execFileAsync = promisify(execFile);

let cachedDevices: Map<string, string> | null = null;
let cacheTimestamp = 0;
let loadPromise: Promise<void> | null = null;

interface DeviceCtlEntry {
identifier: string;
deviceProperties: { name: string };
hardwareProperties?: { udid?: string };
}

function loadDeviceNames(): Map<string, string> {
if (cachedDevices && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
return cachedDevices;
}
function cacheIsFresh(): boolean {
return cachedDevices !== null && Date.now() - cacheTimestamp < CACHE_TTL_MS;
}

function createDeviceMap(data: { result?: { devices?: DeviceCtlEntry[] } }): Map<string, string> {
const map = new Map<string, string>();
const tmpFile = join(tmpdir(), `devicectl-list-${process.pid}.json`);

try {
execSync(`xcrun devicectl list devices --json-output ${tmpFile}`, {
encoding: 'utf8',
timeout: 10_000,
stdio: 'pipe',
});

const data = JSON.parse(readFileSync(tmpFile, 'utf8')) as {
result?: { devices?: DeviceCtlEntry[] };
};

for (const device of data.result?.devices ?? []) {
const name = device.deviceProperties.name;
map.set(device.identifier, name);
if (device.hardwareProperties?.udid) {
map.set(device.hardwareProperties.udid, name);
}

for (const device of data.result?.devices ?? []) {
const name = device.deviceProperties.name;
map.set(device.identifier, name);
if (device.hardwareProperties?.udid) {
map.set(device.hardwareProperties.udid, name);
}
} catch {
// Device list unavailable -- return empty map, will fall back to UUID only
} finally {
}

return map;
}

async function refreshDeviceNames(): Promise<void> {
if (cacheIsFresh()) {
return;
}

if (loadPromise) {
return loadPromise;
}

const tmpFile = join(tmpdir(), `devicectl-list-${process.pid}-${Date.now()}.json`);

loadPromise = (async () => {
try {
unlinkSync(tmpFile);
await execFileAsync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmpFile], {
encoding: 'utf8',
timeout: 10_000,
});

const data = JSON.parse(await readFile(tmpFile, 'utf8')) as {
result?: { devices?: DeviceCtlEntry[] };
};

cachedDevices = createDeviceMap(data);
cacheTimestamp = Date.now();
} catch {
// ignore
// Device list unavailable -- keep existing cache and fall back to UUID only
if (cachedDevices === null) {
cachedDevices = new Map();
cacheTimestamp = Date.now();
}
} finally {
loadPromise = null;
try {
await unlink(tmpFile);
} catch {

Check warning on line 72 in src/utils/device-name-resolver.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

loadPromise cleared before cache update creates race window

Inside the IIFE, `loadPromise = null` runs in the `finally` block before the `await unlink(tmpFile)` completes, but more importantly the assignment to `loadPromise = null` happens at function-end while concurrent callers checking `if (loadPromise)` may have already passed that check. More critically, the dedup check `if (loadPromise) return loadPromise` only works if `loadPromise` is assigned *before* awaiting. Since assignment happens synchronously before the IIFE awaits, dedup works — however, after the promise resolves, `loadPromise` is set to null in `finally`, but `cachedDevices` was just set. A new caller arriving immediately after will see `cacheIsFresh()` true and skip refresh, which is correct. The real issue: on the error path when `cachedDevices` was previously populated (stale cache), the catch block intentionally keeps the old cache but does NOT update `cacheTimestamp`. This means every subsequent call to `resolveDeviceName` will see `cacheIsFresh()` as false and trigger a new background refresh on every call, causing repeated failed `xcrun` invocations with no backoff.
// ignore
}
}

Check warning on line 75 in src/utils/device-name-resolver.ts

View workflow job for this annotation

GitHub Actions / warden: find-bugs

Race condition clears loadPromise before unlink completes, allowing duplicate xcrun invocations

In refreshDeviceNames, the IIFE sets `loadPromise = null` in the finally block before awaiting `unlink(tmpFile)`. Because the assignment happens synchronously at the start of the finally, a concurrent caller invoking refreshDeviceNames during the unlink await will see loadPromise as null and a fresh cache, but if the cache check fails for any reason (e.g., the catch branch did not populate it on a TTL boundary), a second xcrun process can be spawned while the first is still cleaning up. More importantly, the deduplication guarantee is weaker than intended — `loadPromise` should remain set for the entire lifetime of the returned promise so awaiters all observe the same in-flight work.
Comment on lines +68 to 75
Copy link
Copy Markdown

@github-actions github-actions Bot Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition clears loadPromise before unlink completes, allowing duplicate xcrun invocations

In refreshDeviceNames, the IIFE sets loadPromise = null in the finally block before awaiting unlink(tmpFile). Because the assignment happens synchronously at the start of the finally, a concurrent caller invoking refreshDeviceNames during the unlink await will see loadPromise as null and a fresh cache, but if the cache check fails for any reason (e.g., the catch branch did not populate it on a TTL boundary), a second xcrun process can be spawned while the first is still cleaning up. More importantly, the deduplication guarantee is weaker than intended — loadPromise should remain set for the entire lifetime of the returned promise so awaiters all observe the same in-flight work.

Verification

Traced the IIFE assigned to loadPromise: the outer loadPromise = (async () => { ... })() resolves only after the finally block finishes, but inside the finally loadPromise = null runs before await unlink(...). Confirmed there is no other synchronization preventing a new caller from entering the if (loadPromise) check during the unlink await window.

Identified by Warden find-bugs, code-review · XGV-259

}
})();

Check warning on line 76 in src/utils/device-name-resolver.ts

View workflow job for this annotation

GitHub Actions / warden: code-review

Race condition: loadPromise cleared before unlink completes allows concurrent refreshes to collide

Inside the IIFE's finally block, `loadPromise = null` is set before `await unlink(tmpFile)` runs. Because the tmpFile path includes `Date.now()`, this is mostly safe, but more importantly: setting `loadPromise = null` inside the async finally means any caller that awaits `refreshDeviceNames()` returns before unlink finishes, and a subsequent call entering while unlink is still pending will start a second xcrun invocation rather than dedup. This undermines the stated 'in-flight deduplication' goal and can spawn overlapping subprocesses under load.

cachedDevices = map;
cacheTimestamp = Date.now();
return map;
return loadPromise;
}

Check warning on line 79 in src/utils/device-name-resolver.ts

View check run for this annotation

@sentry/warden / warden: code-review

loadPromise cleared before unlink completes allows concurrent refreshes to race on tmpFile

Inside the async IIFE, `loadPromise = null` is set in `finally` before `await unlink(tmpFile)` runs. A second caller invoking `refreshDeviceNames()` at that moment will see `loadPromise === null` and `cacheIsFresh()` may also be false briefly, starting a new refresh. Although each refresh uses a unique tmpFile (pid+Date.now()), clearing the in-flight guard before cleanup defeats the deduplication intent and may allow overlapping `xcrun` invocations under load, partially negating the fix for issue #333.

function ensureDeviceNamesRefresh(): void {
void refreshDeviceNames();
}

export function resolveDeviceName(deviceId: string): string | undefined {
const names = loadDeviceNames();
return names.get(deviceId);
if (!cacheIsFresh()) {

Check warning on line 86 in src/utils/device-name-resolver.ts

View workflow job for this annotation

GitHub Actions / warden: code-review

Fire-and-forget refresh via `void refreshDeviceNames()` may surface unhandled promise rejections

`ensureDeviceNamesRefresh` discards the returned promise with `void`. While `refreshDeviceNames` catches errors from execFile/readFile internally, any unexpected synchronous throw or future code change inside the IIFE that escapes the try/catch would become an unhandled rejection, which Node may treat as fatal in newer versions. Since `resolveDeviceName` is called from rendering/pipeline code, an unhandled rejection here could crash the process.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
ensureDeviceNamesRefresh();
}

return cachedDevices?.get(deviceId);
}

export function formatDeviceId(deviceId: string): string {
Expand Down
Loading