From 9455bb5611c277bf7a53d51de692f0ceacfcc50b Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 16 Jul 2026 16:57:23 +0100 Subject: [PATCH 1/4] fix: Address verified Warden findings Correct scaffold settings, isolate LLDB command output, and make device lookup asynchronous. Also harden xcuserstate parsing, template extraction, and xcodemake installation. Fixes #459 --- CHANGELOG.md | 2 +- .../__tests__/ios-scaffold-settings.test.ts | 22 ++++ .../__tests__/scaffold_ios_project.test.ts | 32 ++++- .../ios-scaffold-settings.ts | 35 ++++++ .../scaffold_ios_project.ts | 52 ++------ .../__tests__/device-name-resolver.test.ts | 80 ++++++++++++ .../__tests__/nskeyedarchiver-parser.test.ts | 39 +++++- src/utils/__tests__/xcodemake.test.ts | 102 ++++++++++++++- .../__tests__/lldb-cli-backend.test.ts | 92 ++++++++++++++ .../debugger/backends/lldb-cli-backend.ts | 31 ++++- src/utils/device-name-resolver.ts | 117 +++++++++++++----- src/utils/nskeyedarchiver-parser.ts | 7 +- src/utils/template-manager.ts | 24 ++-- src/utils/xcodemake.ts | 77 +++++++++--- 14 files changed, 597 insertions(+), 115 deletions(-) create mode 100644 src/mcp/tools/project-scaffolding/__tests__/ios-scaffold-settings.test.ts create mode 100644 src/mcp/tools/project-scaffolding/ios-scaffold-settings.ts create mode 100644 src/utils/__tests__/device-name-resolver.test.ts create mode 100644 src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9194236bd..88ef11639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Fixed `suppressWarnings` being ignored in settled build, build-run, and test output. The flag was honored only while streaming, so warnings still reached the final MCP tool response ([#447](https://github.com/getsentry/XcodeBuildMCP/issues/447)). +- Fixed iOS scaffold orientation and device-family settings, LLDB command isolation and argument escaping, run-destination parsing without an active scheme, concurrent working-directory mutations, blocking physical-device name lookup, and unverified `xcodemake` downloads ([#459](https://github.com/getsentry/XcodeBuildMCP/issues/459)). ## [2.6.2] @@ -693,4 +694,3 @@ Please note that the UI automation features are an early preview and currently i - Initial release of XcodeBuildMCP - Basic support for building iOS and macOS applications - diff --git a/src/mcp/tools/project-scaffolding/__tests__/ios-scaffold-settings.test.ts b/src/mcp/tools/project-scaffolding/__tests__/ios-scaffold-settings.test.ts new file mode 100644 index 000000000..66f7ffcb5 --- /dev/null +++ b/src/mcp/tools/project-scaffolding/__tests__/ios-scaffold-settings.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { deviceFamiliesToNumeric, orientationToIOSConstant } from '../ios-scaffold-settings.ts'; + +describe('iOS scaffold settings', () => { + it.each([ + ['portrait', 'UIInterfaceOrientationPortrait'], + ['portrait-upside-down', 'UIInterfaceOrientationPortraitUpsideDown'], + ['landscape-left', 'UIInterfaceOrientationLandscapeLeft'], + ['landscape-right', 'UIInterfaceOrientationLandscapeRight'], + ] as const)('maps %s to its Info.plist constant', (orientation, expected) => { + expect(orientationToIOSConstant(orientation)).toBe(expected); + }); + + it.each([ + [['iphone'], '1'], + [['ipad'], '2'], + [['iphone', 'ipad'], '1,2'], + [['universal'], '1,2'], + ] as const)('maps device families %j to %s', (families, expected) => { + expect(deviceFamiliesToNumeric([...families])).toBe(expected); + }); +}); diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts index 681dbdfde..9737efd42 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts @@ -129,12 +129,16 @@ describe('scaffold_ios_project plugin', () => { await initConfigStoreForTest({ iosTemplatePath: '' }); let capturedCommands: string[][] = []; + let unzipOptions: unknown; const trackingCommandExecutor = createMockExecutor({ success: true, output: 'Command executed successfully', }); const capturingExecutor = async (command: string[], ...args: any[]) => { capturedCommands.push(command); + if (command[0] === 'unzip') { + unzipOptions = args[2]; + } return trackingCommandExecutor(command, ...args); }; @@ -162,6 +166,9 @@ describe('scaffold_ios_project plugin', () => { /https:\/\/github\.com\/getsentry\/XcodeBuildMCP-iOS-Template\/releases\/download\/v\d+\.\d+\.\d+\/XcodeBuildMCP-iOS-Template-\d+\.\d+\.\d+\.zip/, ), ]); + expect(unzipOptions).toEqual({ + cwd: expect.stringMatching(/xcodebuild-mcp-template-/), + }); await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); @@ -242,6 +249,22 @@ describe('scaffold_ios_project plugin', () => { }); it('should return success response with all optional parameters', async () => { + let writtenXCConfig: string | undefined; + const xcconfigFileSystem = createMockFileSystemExecutor({ + existsSync: (path) => path.includes('/mock/template/path'), + readdir: async () => [ + { name: 'Project.xcconfig', isDirectory: () => false, isFile: () => true } as any, + ], + readFile: async () => + [ + 'TARGETED_DEVICE_FAMILY = old', + 'INFOPLIST_KEY_UISupportedInterfaceOrientations = old', + 'INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = old', + ].join('\n'), + writeFile: async (_path, content) => { + writtenXCConfig = content; + }, + }); const result = await runLogic(() => scaffold_ios_projectLogic( { @@ -258,13 +281,20 @@ describe('scaffold_ios_project plugin', () => { supportedOrientationsIpad: ['portrait', 'landscape-left'], }, mockCommandExecutor, - mockFileSystemExecutor, + xcconfigFileSystem, ), ); expect(result.isError).toBeFalsy(); const text = allText(result); expect(text).toContain('Project scaffolded successfully'); + expect(writtenXCConfig).toContain('TARGETED_DEVICE_FAMILY = 1'); + expect(writtenXCConfig).toContain( + 'INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait', + ); + expect(writtenXCConfig).toContain( + 'INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft', + ); expect(result.nextStepParams).toEqual({ build_sim: { workspacePath: '/tmp/test-projects/TestIOSApp.xcworkspace', diff --git a/src/mcp/tools/project-scaffolding/ios-scaffold-settings.ts b/src/mcp/tools/project-scaffolding/ios-scaffold-settings.ts new file mode 100644 index 000000000..a1a86a1cc --- /dev/null +++ b/src/mcp/tools/project-scaffolding/ios-scaffold-settings.ts @@ -0,0 +1,35 @@ +export type IOSDeviceFamily = 'iphone' | 'ipad' | 'universal'; + +export type IOSOrientation = + | 'portrait' + | 'landscape-left' + | 'landscape-right' + | 'portrait-upside-down'; + +const ORIENTATION_CONSTANTS: Record = { + portrait: 'UIInterfaceOrientationPortrait', + 'portrait-upside-down': 'UIInterfaceOrientationPortraitUpsideDown', + 'landscape-left': 'UIInterfaceOrientationLandscapeLeft', + 'landscape-right': 'UIInterfaceOrientationLandscapeRight', +}; + +/** Converts a scaffold orientation token to its Info.plist build-setting constant. */ +export function orientationToIOSConstant(orientation: IOSOrientation): string { + return ORIENTATION_CONSTANTS[orientation]; +} + +/** Converts scaffold device-family tokens to Xcode's numeric build-setting value. */ +export function deviceFamiliesToNumeric(families: IOSDeviceFamily[]): string { + if (families.includes('universal')) { + return '1,2'; + } + + const numericFamilies = new Set(); + if (families.includes('iphone')) { + numericFamilies.add('1'); + } + if (families.includes('ipad')) { + numericFamilies.add('2'); + } + return [...numericFamilies].join(','); +} diff --git a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts index c8a497e5c..61204baea 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts @@ -14,6 +14,12 @@ import { getHandlerContext, } from '../../../utils/typed-tool-factory.ts'; import { createScaffoldDomainResult, setScaffoldStructuredOutput } from './domain-result.ts'; +import { + deviceFamiliesToNumeric, + orientationToIOSConstant, + type IOSDeviceFamily, + type IOSOrientation, +} from './ios-scaffold-settings.ts'; const BaseScaffoldSchema = z.object({ projectName: z.string().min(1), @@ -36,40 +42,6 @@ const ScaffoldiOSProjectSchema = BaseScaffoldSchema.extend({ .optional(), }); -/** - * Convert orientation enum to iOS constant - */ -function orientationToIOSConstant(orientation: string): string { - switch (orientation) { - case 'Portrait': - return 'UIInterfaceOrientationPortrait'; - case 'PortraitUpsideDown': - return 'UIInterfaceOrientationPortraitUpsideDown'; - case 'LandscapeLeft': - return 'UIInterfaceOrientationLandscapeLeft'; - case 'LandscapeRight': - return 'UIInterfaceOrientationLandscapeRight'; - default: - return orientation; - } -} - -/** - * Convert device family enum to numeric value - */ -function deviceFamilyToNumeric(family: string): string { - switch (family) { - case 'iPhone': - return '1'; - case 'iPad': - return '2'; - case 'iPhone+iPad': - return '1,2'; - default: - return '1,2'; - } -} - /** * Update Package.swift file with deployment target */ @@ -114,9 +86,11 @@ function updateXCConfigFile(content: string, params: Record): s const currentProjectVersion = params.currentProjectVersion as string | undefined; const platform = params.platform as string; const deploymentTarget = params.deploymentTarget as string | undefined; - const targetedDeviceFamily = params.targetedDeviceFamily as string | undefined; - const supportedOrientations = params.supportedOrientations as string[] | undefined; - const supportedOrientationsIpad = params.supportedOrientationsIpad as string[] | undefined; + const targetedDeviceFamily = params.targetedDeviceFamily as IOSDeviceFamily[] | undefined; + const supportedOrientations = params.supportedOrientations as IOSOrientation[] | undefined; + const supportedOrientationsIpad = params.supportedOrientationsIpad as + | IOSOrientation[] + | undefined; // Update project identity settings result = result.replace(/PRODUCT_NAME = .+/g, `PRODUCT_NAME = ${projectName}`); @@ -148,8 +122,8 @@ function updateXCConfigFile(content: string, params: Record): s } // Device family - if (targetedDeviceFamily) { - const deviceFamilyValue = deviceFamilyToNumeric(targetedDeviceFamily); + if (targetedDeviceFamily && targetedDeviceFamily.length > 0) { + const deviceFamilyValue = deviceFamiliesToNumeric(targetedDeviceFamily); result = result.replace( /TARGETED_DEVICE_FAMILY = .+/g, `TARGETED_DEVICE_FAMILY = ${deviceFamilyValue}`, diff --git a/src/utils/__tests__/device-name-resolver.test.ts b/src/utils/__tests__/device-name-resolver.test.ts new file mode 100644 index 000000000..5ff48f539 --- /dev/null +++ b/src/utils/__tests__/device-name-resolver.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + __resetDeviceNameCacheForTests, + formatDeviceId, + resolveDeviceName, + type DeviceNameResolverDependencies, +} from '../device-name-resolver.ts'; + +const deviceId = 'device-identifier'; +const udid = '00008110-0012345678901234'; + +function createDependencies( + overrides: Partial = {}, +): DeviceNameResolverDependencies { + return { + runDevicectl: vi.fn().mockResolvedValue(undefined), + readOutput: vi.fn().mockResolvedValue( + JSON.stringify({ + result: { + devices: [ + { + identifier: deviceId, + deviceProperties: { name: 'Cam’s iPhone' }, + hardwareProperties: { udid }, + }, + ], + }, + }), + ), + removeOutput: vi.fn().mockResolvedValue(undefined), + createOutputPath: () => '/tmp/devices.json', + now: () => 1_000, + ...overrides, + }; +} + +describe('device name resolver', () => { + beforeEach(() => { + __resetDeviceNameCacheForTests(); + }); + + it('loads device names asynchronously and resolves identifiers and UDIDs', async () => { + const deps = createDependencies(); + + await expect(resolveDeviceName(deviceId, deps)).resolves.toBe('Cam’s iPhone'); + await expect(resolveDeviceName(udid, deps)).resolves.toBe('Cam’s iPhone'); + + expect(deps.runDevicectl).toHaveBeenCalledOnce(); + expect(deps.runDevicectl).toHaveBeenCalledWith('/tmp/devices.json'); + expect(deps.removeOutput).toHaveBeenCalledWith('/tmp/devices.json'); + }); + + it('returns immediately while an asynchronous cache refresh is running', async () => { + let finishRefresh: (() => void) | undefined; + const runDevicectl = vi.fn( + () => + new Promise((resolve) => { + finishRefresh = resolve; + }), + ); + const deps = createDependencies({ runDevicectl }); + + expect(formatDeviceId(deviceId, deps)).toBe(deviceId); + expect(runDevicectl).toHaveBeenCalledOnce(); + + finishRefresh?.(); + await expect(resolveDeviceName(deviceId, deps)).resolves.toBe('Cam’s iPhone'); + expect(formatDeviceId(deviceId, deps)).toBe(`Cam’s iPhone (${deviceId})`); + }); + + it('falls back to the device ID when devicectl fails', async () => { + const deps = createDependencies({ + runDevicectl: vi.fn().mockRejectedValue(new Error('unavailable')), + }); + + await expect(resolveDeviceName(deviceId, deps)).resolves.toBeUndefined(); + expect(formatDeviceId(deviceId, deps)).toBe(deviceId); + expect(deps.removeOutput).toHaveBeenCalledWith('/tmp/devices.json'); + }); +}); diff --git a/src/utils/__tests__/nskeyedarchiver-parser.test.ts b/src/utils/__tests__/nskeyedarchiver-parser.test.ts index f60b2aaae..e14f9dd41 100644 --- a/src/utils/__tests__/nskeyedarchiver-parser.test.ts +++ b/src/utils/__tests__/nskeyedarchiver-parser.test.ts @@ -1,4 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { parseBuffer as bplistParseBuffer } from 'bplist-parser'; + +vi.mock('bplist-parser', () => ({ + parseBuffer: vi.fn(), +})); import { parseXcuserstate, parseXcuserstateBuffer, @@ -8,6 +13,12 @@ import { } from '../nskeyedarchiver-parser.ts'; describe('NSKeyedArchiver Parser', () => { + beforeEach(() => { + vi.mocked(bplistParseBuffer).mockImplementation(() => { + throw new Error('invalid plist'); + }); + }); + describe('parseXcuserstate (file path)', () => { it('returns empty result for non-existent file', () => { const result = parseXcuserstate('/non/existent/file.xcuserstate'); @@ -97,11 +108,27 @@ describe('NSKeyedArchiver Parser', () => { }); describe('edge cases', () => { - it('handles xcuserstate without ActiveScheme', () => { - // This would require a specially crafted test fixture - // For now, we just verify the function doesn't crash - const result = parseXcuserstateBuffer(Buffer.from('bplist00')); - expect(result).toEqual({}); + it('extracts ActiveRunDestination without ActiveScheme', () => { + const simulatorId = '12345678-1234-1234-1234-123456789ABC'; + vi.mocked(bplistParseBuffer).mockReturnValue([ + { + $archiver: 'NSKeyedArchiver', + $objects: [ + '$null', + 'ActiveRunDestination', + 'targetDeviceLocation', + { 'NS.keys': [{ UID: 1 }], 'NS.objects': [{ UID: 4 }] }, + { 'NS.keys': [{ UID: 2 }], 'NS.objects': [{ UID: 5 }] }, + `dvtdevice-iphonesimulator:${simulatorId}`, + ], + }, + ]); + + expect(parseXcuserstateBuffer(Buffer.from('plist'))).toEqual({ + deviceLocation: `dvtdevice-iphonesimulator:${simulatorId}`, + simulatorId, + simulatorPlatform: 'iphonesimulator', + }); }); it('handles scheme object without IDENameString', () => { diff --git a/src/utils/__tests__/xcodemake.test.ts b/src/utils/__tests__/xcodemake.test.ts index 9ed1bdda7..796c40640 100644 --- a/src/utils/__tests__/xcodemake.test.ts +++ b/src/utils/__tests__/xcodemake.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; const { executorMock } = vi.hoisted(() => ({ executorMock: vi.fn(), @@ -8,7 +9,15 @@ vi.mock('../command.ts', () => ({ getDefaultCommandExecutor: () => executorMock, })); -import { executeXcodemakeCommand } from '../xcodemake.ts'; +import { + XCODEMAKE_COMMIT, + XCODEMAKE_DOWNLOAD_URL, + XCODEMAKE_SHA256, + doesMakeLogFileExist, + executeXcodemakeCommand, + installXcodemake, + verifyXcodemakeScript, +} from '../xcodemake.ts'; describe('executeXcodemakeCommand', () => { beforeEach(() => { @@ -47,3 +56,94 @@ describe('executeXcodemakeCommand', () => { expect(process.cwd()).toBe(originalCwd); }); }); + +describe('doesMakeLogFileExist', () => { + it('reads the project directory without mutating process cwd', () => { + const originalCwd = process.cwd(); + const readDirectory = vi.fn(() => ['xcodemake -scheme App.log']); + + const exists = doesMakeLogFileExist( + '/tmp/project', + ['xcodebuild', '-scheme', 'App'], + readDirectory, + ); + + expect(exists).toBe(true); + expect(readDirectory).toHaveBeenCalledWith('/tmp/project'); + expect(process.cwd()).toBe(originalCwd); + }); +}); + +describe('xcodemake installer integrity', () => { + it('pins the download URL to an exact commit and checksum', () => { + expect(XCODEMAKE_COMMIT).toMatch(/^[a-f0-9]{40}$/); + expect(XCODEMAKE_SHA256).toMatch(/^[a-f0-9]{64}$/); + expect(XCODEMAKE_DOWNLOAD_URL).toBe( + `https://raw.githubusercontent.com/cameroncooke/xcodemake/${XCODEMAKE_COMMIT}/xcodemake`, + ); + }); + + it('accepts matching content and rejects mismatched content', () => { + const content = '#!/bin/sh\necho trusted\n'; + const checksum = createHash('sha256').update(content).digest('hex'); + + expect(() => verifyXcodemakeScript(content, checksum)).not.toThrow(); + expect(() => verifyXcodemakeScript(`${content}echo tampered\n`, checksum)).toThrow( + 'xcodemake checksum mismatch', + ); + }); + + it('does not write or chmod a script that fails integrity verification', async () => { + const mkdir = vi.fn().mockResolvedValue(undefined); + const writeFile = vi.fn().mockResolvedValue(undefined); + const chmod = vi.fn().mockResolvedValue(undefined); + const rename = vi.fn().mockResolvedValue(undefined); + const unlink = vi.fn().mockResolvedValue(undefined); + const fetchMock = vi.fn().mockResolvedValue(new Response('tampered')); + + await expect( + installXcodemake({ + fetch: fetchMock as typeof fetch, + mkdir, + writeFile, + chmod, + rename, + unlink, + }), + ).resolves.toBe(false); + + expect(fetchMock).toHaveBeenCalledWith(XCODEMAKE_DOWNLOAD_URL); + expect(writeFile).not.toHaveBeenCalled(); + expect(chmod).not.toHaveBeenCalled(); + expect(rename).not.toHaveBeenCalled(); + }); + + it('atomically replaces the installed script after verification', async () => { + const content = '#!/bin/sh\necho trusted\n'; + const checksum = createHash('sha256').update(content).digest('hex'); + const mkdir = vi.fn().mockResolvedValue(undefined); + const writeFile = vi.fn().mockResolvedValue(undefined); + const chmod = vi.fn().mockResolvedValue(undefined); + const rename = vi.fn().mockResolvedValue(undefined); + const unlink = vi.fn().mockRejectedValue(new Error('already renamed')); + + await expect( + installXcodemake( + { + fetch: vi.fn().mockResolvedValue(new Response(content)) as typeof fetch, + mkdir, + writeFile, + chmod, + rename, + unlink, + }, + checksum, + ), + ).resolves.toBe(true); + + const stagingPath = expect.stringMatching(/\/xcodemake\.\d+\.[a-f0-9-]+\.tmp$/); + expect(writeFile).toHaveBeenCalledWith(stagingPath, content, 'utf8'); + expect(chmod).toHaveBeenCalledWith(stagingPath, 0o755); + expect(rename).toHaveBeenCalledWith(stagingPath, expect.stringMatching(/\/xcodemake$/)); + }); +}); diff --git a/src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts b/src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts new file mode 100644 index 000000000..05f0c5729 --- /dev/null +++ b/src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { + createMockInteractiveSpawner, + type MockInteractiveSession, +} from '../../../../test-utils/mock-executors.ts'; +import { createLldbCliBackend } from '../lldb-cli-backend.ts'; + +const SENTINEL_COMMAND = 'script print("__XCODEBUILDMCP_DONE__")'; +const SENTINEL_OUTPUT = '\n__XCODEBUILDMCP_DONE__\nXCODEBUILDMCP_LLDB> '; + +function emitSentinel(session: MockInteractiveSession): void { + session.stdout.write(SENTINEL_OUTPUT); +} + +describe('LldbCliBackend', () => { + it('drains continue output before returning the next command output', async () => { + const spawner = createMockInteractiveSpawner({ + onWrite(data, session) { + if (data === 'process continue\n') { + session.stdout.write('Process resumed and later stopped\n'); + } else if (data === 'process status\n') { + session.stdout.write('Process 42 stopped\n'); + } else if (data.includes(SENTINEL_COMMAND)) { + emitSentinel(session); + } + }, + }); + const backend = await createLldbCliBackend(spawner); + + await backend.resume(); + const output = await backend.runCommand('process status'); + + expect(output).toBe('Process 42 stopped'); + await backend.dispose(); + }); + + it('keeps breakpoint values on a single LLDB command line', async () => { + const commands: string[] = []; + const spawner = createMockInteractiveSpawner({ + onWrite(data, session) { + commands.push(data); + if (data.startsWith('breakpoint set')) { + session.stdout.write('Breakpoint 1: resolved\n'); + } else if (data.startsWith('breakpoint modify')) { + session.stdout.write('Breakpoint 1: modified\n'); + } else if (data.includes(SENTINEL_COMMAND)) { + emitSentinel(session); + } + }, + }); + const backend = await createLldbCliBackend(spawner); + + await backend.addBreakpoint( + { + kind: 'file-line', + file: 'Example".swift\nplatform shell echo injected', + line: 12, + }, + { condition: 'value == "ok"\nplatform shell echo injected' }, + ); + + const breakpointCommands = commands.filter((command) => command.startsWith('breakpoint')); + expect(breakpointCommands).toEqual([ + 'breakpoint set --file "Example\\".swift\\nplatform shell echo injected" --line 12\n', + 'breakpoint modify -c "value == \\"ok\\"\\nplatform shell echo injected" 1\n', + ]); + expect(breakpointCommands.every((command) => command.split('\n').length === 2)).toBe(true); + await backend.dispose(); + }); + + it('escapes named breakpoint values', async () => { + const commands: string[] = []; + const spawner = createMockInteractiveSpawner({ + onWrite(data, session) { + commands.push(data); + if (data.startsWith('breakpoint set')) { + session.stdout.write('Breakpoint 1: resolved\n'); + } else if (data.includes(SENTINEL_COMMAND)) { + emitSentinel(session); + } + }, + }); + const backend = await createLldbCliBackend(spawner); + + await backend.addBreakpoint({ kind: 'function', name: 'run"\nprocess detach' }); + + expect(commands.find((command) => command.startsWith('breakpoint set'))).toBe( + 'breakpoint set --name "run\\"\\nprocess detach"\n', + ); + await backend.dispose(); + }); +}); diff --git a/src/utils/debugger/backends/lldb-cli-backend.ts b/src/utils/debugger/backends/lldb-cli-backend.ts index c297054a3..525f1bb37 100644 --- a/src/utils/debugger/backends/lldb-cli-backend.ts +++ b/src/utils/debugger/backends/lldb-cli-backend.ts @@ -24,6 +24,8 @@ class LldbCliBackend implements DebuggerBackend { private queue: Promise = Promise.resolve(); private ready: Promise; private disposed = false; + // The sentinel queued after continue owns all output until the next command drains it. + private resumeOutputPending = false; constructor(spawner: InteractiveSpawner) { this.spawner = spawner; @@ -76,6 +78,7 @@ class LldbCliBackend implements DebuggerBackend { throw new Error('LLDB backend disposed'); } await this.ready; + await this.drainResumeOutput(opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS); this.process.write(`${command}\n`); this.process.write(`script print("${COMMAND_SENTINEL}")\n`); const output = await this.waitForSentinel(opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS); @@ -89,7 +92,10 @@ class LldbCliBackend implements DebuggerBackend { throw new Error('LLDB backend disposed'); } await this.ready; + await this.drainResumeOutput(DEFAULT_COMMAND_TIMEOUT_MS); this.process.write('process continue\n'); + this.process.write(`script print("${COMMAND_SENTINEL}")\n`); + this.resumeOutputPending = true; }); } @@ -99,8 +105,8 @@ class LldbCliBackend implements DebuggerBackend { ): Promise { const command = spec.kind === 'file-line' - ? `breakpoint set --file "${spec.file}" --line ${spec.line}` - : `breakpoint set --name "${spec.name}"`; + ? `breakpoint set --file ${formatLldbString(spec.file)} --line ${spec.line}` + : `breakpoint set --name ${formatLldbString(spec.name)}`; const output = await this.runCommand(command); assertNoLldbError('breakpoint', output); @@ -202,6 +208,15 @@ class LldbCliBackend implements DebuggerBackend { this.checkPending(); } + private async drainResumeOutput(timeoutMs: number): Promise { + if (!this.resumeOutputPending) { + return; + } + + await this.waitForSentinel(timeoutMs); + this.resumeOutputPending = false; + } + private waitForSentinel(timeoutMs: number): Promise { if (this.pending) { return Promise.reject(new Error('LLDB command already pending')); @@ -268,11 +283,19 @@ function sanitizeOutput(output: string, prompt: string): string { return filtered.join('\n'); } -function formatConditionForLldb(condition: string): string { - const escaped = condition.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +function formatLldbString(value: string): string { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n'); return `"${escaped}"`; } +function formatConditionForLldb(condition: string): string { + return formatLldbString(condition); +} + function parseStopReason(output: string): string | undefined { const match = output.match(/stop reason\s*=\s*(.+)/i); if (!match) return undefined; diff --git a/src/utils/device-name-resolver.ts b/src/utils/device-name-resolver.ts index 876607a6c..97aaa2731 100644 --- a/src/utils/device-name-resolver.ts +++ b/src/utils/device-name-resolver.ts @@ -1,12 +1,16 @@ -import { execSync } from 'node:child_process'; -import { readFileSync, unlinkSync } from 'node:fs'; +import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +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 | null = null; let cacheTimestamp = 0; +let refreshPromise: Promise> | null = null; interface DeviceCtlEntry { identifier: string; @@ -14,56 +18,111 @@ interface DeviceCtlEntry { hardwareProperties?: { udid?: string }; } -function loadDeviceNames(): Map { - if (cachedDevices && Date.now() - cacheTimestamp < CACHE_TTL_MS) { - return cachedDevices; - } - - const map = new Map(); - const tmpFile = join(tmpdir(), `devicectl-list-${process.pid}.json`); +export interface DeviceNameResolverDependencies { + runDevicectl(outputPath: string): Promise; + readOutput(outputPath: string): Promise; + removeOutput(outputPath: string): Promise; + createOutputPath(): string; + now(): number; +} - try { - execSync(`xcrun devicectl list devices --json-output ${tmpFile}`, { - encoding: 'utf8', +const defaultDependencies: DeviceNameResolverDependencies = { + async runDevicectl(outputPath) { + await execFileAsync('xcrun', ['devicectl', 'list', 'devices', '--json-output', outputPath], { timeout: 10_000, - stdio: 'pipe', }); + }, + readOutput(outputPath) { + return readFile(outputPath, 'utf8'); + }, + async removeOutput(outputPath) { + await unlink(outputPath); + }, + createOutputPath() { + return join(tmpdir(), `devicectl-list-${process.pid}-${randomUUID()}.json`); + }, + now() { + return Date.now(); + }, +}; - const data = JSON.parse(readFileSync(tmpFile, 'utf8')) as { +function isCacheFresh(now: number): boolean { + return cachedDevices !== null && now - cacheTimestamp < CACHE_TTL_MS; +} + +async function loadDeviceNames(deps: DeviceNameResolverDependencies): Promise> { + const devices = new Map(); + const outputPath = deps.createOutputPath(); + + try { + await deps.runDevicectl(outputPath); + const data = JSON.parse(await deps.readOutput(outputPath)) as { result?: { devices?: DeviceCtlEntry[] }; }; for (const device of data.result?.devices ?? []) { const name = device.deviceProperties.name; - map.set(device.identifier, name); + devices.set(device.identifier, name); if (device.hardwareProperties?.udid) { - map.set(device.hardwareProperties.udid, name); + devices.set(device.hardwareProperties.udid, name); } } } catch { - // Device list unavailable -- return empty map, will fall back to UUID only + // Device list unavailable -- retain an empty cache and fall back to UUIDs. } finally { try { - unlinkSync(tmpFile); + await deps.removeOutput(outputPath); } catch { - // ignore + // The output file may not exist when devicectl fails before creating it. } } - cachedDevices = map; - cacheTimestamp = Date.now(); - return map; + cachedDevices = devices; + cacheTimestamp = deps.now(); + return devices; } -export function resolveDeviceName(deviceId: string): string | undefined { - const names = loadDeviceNames(); +function refreshDeviceNames( + deps: DeviceNameResolverDependencies = defaultDependencies, +): Promise> { + if (isCacheFresh(deps.now())) { + return Promise.resolve(cachedDevices!); + } + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = loadDeviceNames(deps).finally(() => { + refreshPromise = null; + }); + return refreshPromise; +} + +/** Resolves a device name after awaiting cache population. */ +export async function resolveDeviceName( + deviceId: string, + deps: DeviceNameResolverDependencies = defaultDependencies, +): Promise { + const names = await refreshDeviceNames(deps); return names.get(deviceId); } -export function formatDeviceId(deviceId: string): string { - const name = resolveDeviceName(deviceId); - if (name) { - return `${name} (${deviceId})`; +/** Formats a cached device name synchronously while refreshing stale data in the background. */ +export function formatDeviceId( + deviceId: string, + deps: DeviceNameResolverDependencies = defaultDependencies, +): string { + if (!isCacheFresh(deps.now())) { + void refreshDeviceNames(deps); } - return deviceId; + + const name = cachedDevices?.get(deviceId); + return name ? `${name} (${deviceId})` : deviceId; +} + +/** Clears module-level cache state between tests. */ +export function __resetDeviceNameCacheForTests(): void { + cachedDevices = null; + cacheTimestamp = 0; + refreshPromise = null; } diff --git a/src/utils/nskeyedarchiver-parser.ts b/src/utils/nskeyedarchiver-parser.ts index 918c0d9fb..3aa896db2 100644 --- a/src/utils/nskeyedarchiver-parser.ts +++ b/src/utils/nskeyedarchiver-parser.ts @@ -126,8 +126,8 @@ export function parseXcuserstate(xcuserstatePath: string): XcodeStateResult { return result; } - // Find the dictionary containing ActiveScheme key - const parentDict = findDictWithKey(objects, activeSchemeIdx); + const parentKeyIdx = activeSchemeIdx !== -1 ? activeSchemeIdx : activeRunDestIdx; + const parentDict = findDictWithKey(objects, parentKeyIdx); if (!parentDict) { return result; } @@ -198,7 +198,8 @@ export function parseXcuserstateBuffer(buffer: Buffer): XcodeStateResult { return result; } - const parentDict = findDictWithKey(objects, activeSchemeIdx); + const parentKeyIdx = activeSchemeIdx !== -1 ? activeSchemeIdx : activeRunDestIdx; + const parentDict = findDictWithKey(objects, parentKeyIdx); if (!parentDict) { return result; } diff --git a/src/utils/template-manager.ts b/src/utils/template-manager.ts index 55302ea8f..2b8254aec 100644 --- a/src/utils/template-manager.ts +++ b/src/utils/template-manager.ts @@ -98,23 +98,15 @@ export class TemplateManager { throw new Error(`Failed to download template: ${curlResult.error}`); } - // Extract the zip file - // Temporarily change to temp directory for extraction - const originalCwd = process.cwd(); - try { - process.chdir(tempDir); - const unzipResult = await commandExecutor( - ['unzip', '-q', zipPath], - 'Extract Template', - true, - undefined, - ); + const unzipResult = await commandExecutor( + ['unzip', '-q', zipPath], + 'Extract Template', + true, + { cwd: tempDir }, + ); - if (!unzipResult.success) { - throw new Error(`Failed to extract template: ${unzipResult.error}`); - } - } finally { - process.chdir(originalCwd); + if (!unzipResult.success) { + throw new Error(`Failed to extract template: ${unzipResult.error}`); } // Find the extracted directory and return the template subdirectory diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts index cb0c22cb4..7df6d481e 100644 --- a/src/utils/xcodemake.ts +++ b/src/utils/xcodemake.ts @@ -2,6 +2,7 @@ import { log } from './logger.ts'; import type { CommandResponse } from './command.ts'; import { getDefaultCommandExecutor } from './command.ts'; import { existsSync, readdirSync, statSync } from 'fs'; +import { createHash, randomUUID } from 'node:crypto'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs/promises'; @@ -9,6 +10,28 @@ import { getConfig } from './config-store.ts'; let overriddenXcodemakePath: string | null = null; +export const XCODEMAKE_COMMIT = '1749682a99794edc257010b3eaa35c39f0f91c50'; +export const XCODEMAKE_SHA256 = 'b84f2e58326a1c009e5349e28895817d2968f2cb655b6a2a7c7c719971007db7'; +export const XCODEMAKE_DOWNLOAD_URL = `https://raw.githubusercontent.com/cameroncooke/xcodemake/${XCODEMAKE_COMMIT}/xcodemake`; + +interface XcodemakeInstallerDependencies { + fetch: typeof fetch; + mkdir: typeof fs.mkdir; + writeFile: typeof fs.writeFile; + chmod: typeof fs.chmod; + rename: typeof fs.rename; + unlink: typeof fs.unlink; +} + +const defaultInstallerDependencies: XcodemakeInstallerDependencies = { + fetch, + mkdir: fs.mkdir, + writeFile: fs.writeFile, + chmod: fs.chmod, + rename: fs.rename, + unlink: fs.unlink, +}; + export function isXcodemakeEnabled(): boolean { return getConfig().incrementalBuildsEnabled; } @@ -48,29 +71,47 @@ function overrideXcodemakeCommand(path: string): void { log('info', `Using overridden xcodemake path: ${path}`); } -async function installXcodemake(): Promise { +/** Verifies downloaded xcodemake bytes against the pinned SHA-256 digest. */ +export function verifyXcodemakeScript( + scriptContent: string, + expectedSha256 = XCODEMAKE_SHA256, +): void { + const actualSha256 = createHash('sha256').update(scriptContent).digest('hex'); + if (actualSha256 !== expectedSha256) { + throw new Error( + `xcodemake checksum mismatch: expected ${expectedSha256}, received ${actualSha256}`, + ); + } +} + +/** Installs the pinned, verified xcodemake script and reports whether installation succeeded. */ +export async function installXcodemake( + deps: XcodemakeInstallerDependencies = defaultInstallerDependencies, + expectedSha256 = XCODEMAKE_SHA256, +): Promise { const tempDir = os.tmpdir(); const xcodemakeDir = path.join(tempDir, 'xcodebuildmcp'); const xcodemakePath = path.join(xcodemakeDir, 'xcodemake'); + const stagingPath = `${xcodemakePath}.${process.pid}.${randomUUID()}.tmp`; log('info', `Attempting to install xcodemake to ${xcodemakePath}`); try { - await fs.mkdir(xcodemakeDir, { recursive: true }); + await deps.mkdir(xcodemakeDir, { recursive: true }); log('info', 'Downloading xcodemake from GitHub...'); - const response = await fetch( - 'https://raw.githubusercontent.com/cameroncooke/xcodemake/main/xcodemake', - ); + const response = await deps.fetch(XCODEMAKE_DOWNLOAD_URL); if (!response.ok) { throw new Error(`Failed to download xcodemake: ${response.status} ${response.statusText}`); } const scriptContent = await response.text(); - await fs.writeFile(xcodemakePath, scriptContent, 'utf8'); + verifyXcodemakeScript(scriptContent, expectedSha256); + await deps.writeFile(stagingPath, scriptContent, 'utf8'); - await fs.chmod(xcodemakePath, 0o755); + await deps.chmod(stagingPath, 0o755); + await deps.rename(stagingPath, xcodemakePath); log('info', 'Made xcodemake executable'); overrideXcodemakeCommand(xcodemakePath); @@ -82,6 +123,12 @@ async function installXcodemake(): Promise { `Error installing xcodemake: ${error instanceof Error ? error.message : String(error)}`, ); return false; + } finally { + try { + await deps.unlink(stagingPath); + } catch { + // A successful atomic rename consumes the staged path. + } } } @@ -125,12 +172,14 @@ export function doesMakefileExist(projectDir: string): boolean { return existsSync(`${projectDir}/Makefile`); } -export function doesMakeLogFileExist(projectDir: string, command: string[]): boolean { - const originalDir = process.cwd(); +type ReadDirectory = (path: string) => string[]; +export function doesMakeLogFileExist( + projectDir: string, + command: string[], + readDirectory: ReadDirectory = (directory) => readdirSync(directory), +): boolean { try { - process.chdir(projectDir); - const xcodemakeCommand = ['xcodemake', ...command.slice(1)]; const escapedCommand = xcodemakeCommand.map((arg) => { // Remove projectDir from arguments if present at the start @@ -142,9 +191,9 @@ export function doesMakeLogFileExist(projectDir: string, command: string[]): boo }); const commandString = escapedCommand.join(' '); const logFileName = `${commandString}.log`; - log('debug', `Checking for Makefile log: ${logFileName} in directory: ${process.cwd()}`); + log('debug', `Checking for Makefile log: ${logFileName} in directory: ${projectDir}`); - const files = readdirSync('.'); + const files = readDirectory(projectDir); const exists = files.includes(logFileName); log('debug', `Makefile log ${exists ? 'exists' : 'does not exist'}: ${logFileName}`); return exists; @@ -154,8 +203,6 @@ export function doesMakeLogFileExist(projectDir: string, command: string[]): boo `Error checking for Makefile log: ${error instanceof Error ? error.message : String(error)}`, ); return false; - } finally { - process.chdir(originalDir); } } From d01f6ca4209179ce9000ce0af291c7393df6075f Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 16 Jul 2026 17:46:50 +0100 Subject: [PATCH 2/4] fix(debugger): Report running state after resume --- .../__tests__/lldb-cli-backend.test.ts | 22 +++++++++++++++++++ .../debugger/backends/lldb-cli-backend.ts | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts b/src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts index 05f0c5729..d6306e60d 100644 --- a/src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts +++ b/src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts @@ -13,6 +13,28 @@ function emitSentinel(session: MockInteractiveSession): void { } describe('LldbCliBackend', () => { + it('reports a resumed process as running without waiting for it to stop', async () => { + const commands: string[] = []; + let sentinelCount = 0; + const spawner = createMockInteractiveSpawner({ + onWrite(data, session) { + commands.push(data); + if (data.includes(SENTINEL_COMMAND) && sentinelCount++ === 0) { + emitSentinel(session); + } + }, + }); + const backend = await createLldbCliBackend(spawner); + + await backend.resume(); + await expect(backend.getExecutionState({ timeoutMs: 10 })).resolves.toEqual({ + status: 'running', + description: 'Process is running', + }); + expect(commands).not.toContain('process status\n'); + await backend.dispose(); + }); + it('drains continue output before returning the next command output', async () => { const spawner = createMockInteractiveSpawner({ onWrite(data, session) { diff --git a/src/utils/debugger/backends/lldb-cli-backend.ts b/src/utils/debugger/backends/lldb-cli-backend.ts index 525f1bb37..c8ba01807 100644 --- a/src/utils/debugger/backends/lldb-cli-backend.ts +++ b/src/utils/debugger/backends/lldb-cli-backend.ts @@ -155,6 +155,10 @@ class LldbCliBackend implements DebuggerBackend { } async getExecutionState(opts?: { timeoutMs?: number }): Promise { + if (this.resumeOutputPending && !COMMAND_SENTINEL_REGEX.test(this.buffer)) { + return { status: 'running', description: 'Process is running' }; + } + try { const output = await this.runCommand('process status', { timeoutMs: opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS, From 3f77e1bbde9994c91d6d4784288a567bb0812603 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 16 Jul 2026 17:47:17 +0100 Subject: [PATCH 3/4] fix(device): Preserve names after refresh failure --- src/utils/__tests__/device-name-resolver.test.ts | 15 +++++++++++++++ src/utils/device-name-resolver.ts | 11 ++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/utils/__tests__/device-name-resolver.test.ts b/src/utils/__tests__/device-name-resolver.test.ts index 5ff48f539..081596bf3 100644 --- a/src/utils/__tests__/device-name-resolver.test.ts +++ b/src/utils/__tests__/device-name-resolver.test.ts @@ -77,4 +77,19 @@ describe('device name resolver', () => { expect(formatDeviceId(deviceId, deps)).toBe(deviceId); expect(deps.removeOutput).toHaveBeenCalledWith('/tmp/devices.json'); }); + + it('retains stale device names when a background refresh fails', async () => { + let now = 1_000; + const deps = createDependencies({ now: () => now }); + + await expect(resolveDeviceName(deviceId, deps)).resolves.toBe('Cam’s iPhone'); + now = 31_001; + vi.mocked(deps.runDevicectl).mockRejectedValueOnce(new Error('unavailable')); + + expect(formatDeviceId(deviceId, deps)).toBe(`Cam’s iPhone (${deviceId})`); + await expect(resolveDeviceName(deviceId, deps)).resolves.toBe('Cam’s iPhone'); + await expect(resolveDeviceName(deviceId, deps)).resolves.toBe('Cam’s iPhone'); + expect(formatDeviceId(deviceId, deps)).toBe(`Cam’s iPhone (${deviceId})`); + expect(deps.runDevicectl).toHaveBeenCalledTimes(3); + }); }); diff --git a/src/utils/device-name-resolver.ts b/src/utils/device-name-resolver.ts index 97aaa2731..43c529364 100644 --- a/src/utils/device-name-resolver.ts +++ b/src/utils/device-name-resolver.ts @@ -67,8 +67,13 @@ async function loadDeviceNames(deps: DeviceNameResolverDependencies): Promise Date: Thu, 16 Jul 2026 17:52:30 +0100 Subject: [PATCH 4/4] fix(debugger): Clear running state after termination --- .../__tests__/lldb-cli-backend.test.ts | 29 +++++++++++++++++++ .../debugger/backends/lldb-cli-backend.ts | 12 +++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts b/src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts index d6306e60d..b2ef04b5a 100644 --- a/src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts +++ b/src/utils/debugger/backends/__tests__/lldb-cli-backend.test.ts @@ -35,6 +35,35 @@ describe('LldbCliBackend', () => { await backend.dispose(); }); + it('does not report stale running state after LLDB exits', async () => { + let session: MockInteractiveSession | undefined; + let sentinelCount = 0; + const spawner = createMockInteractiveSpawner({ + onSpawn(spawnedSession) { + session = spawnedSession; + }, + onWrite(data, spawnedSession) { + if (data.includes(SENTINEL_COMMAND) && sentinelCount++ === 0) { + emitSentinel(spawnedSession); + } + }, + }); + const backend = await createLldbCliBackend(spawner); + + await backend.resume(); + session?.emitExit(9); + + await expect(backend.getExecutionState()).resolves.toEqual({ + status: 'terminated', + description: 'LLDB process exited (code 9)', + }); + await backend.dispose(); + await expect(backend.getExecutionState()).resolves.toEqual({ + status: 'unknown', + description: 'LLDB backend disposed', + }); + }); + it('drains continue output before returning the next command output', async () => { const spawner = createMockInteractiveSpawner({ onWrite(data, session) { diff --git a/src/utils/debugger/backends/lldb-cli-backend.ts b/src/utils/debugger/backends/lldb-cli-backend.ts index c8ba01807..875924668 100644 --- a/src/utils/debugger/backends/lldb-cli-backend.ts +++ b/src/utils/debugger/backends/lldb-cli-backend.ts @@ -24,6 +24,7 @@ class LldbCliBackend implements DebuggerBackend { private queue: Promise = Promise.resolve(); private ready: Promise; private disposed = false; + private processExitDescription: string | null = null; // The sentinel queued after continue owns all output until the next command drains it. private resumeOutputPending = false; @@ -43,7 +44,9 @@ class LldbCliBackend implements DebuggerBackend { this.process.process.stderr?.on('data', (data: Buffer) => this.handleData(data)); this.process.process.on('exit', (code, signal) => { const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`; - this.failPending(new Error(`LLDB process exited (${detail})`)); + this.processExitDescription = `LLDB process exited (${detail})`; + this.resumeOutputPending = false; + this.failPending(new Error(this.processExitDescription)); }); this.ready = this.initialize(); @@ -155,6 +158,12 @@ class LldbCliBackend implements DebuggerBackend { } async getExecutionState(opts?: { timeoutMs?: number }): Promise { + if (this.disposed) { + return { status: 'unknown', description: 'LLDB backend disposed' }; + } + if (this.processExitDescription) { + return { status: 'terminated', description: this.processExitDescription }; + } if (this.resumeOutputPending && !COMMAND_SENTINEL_REGEX.test(this.buffer)) { return { status: 'running', description: 'Process is running' }; } @@ -194,6 +203,7 @@ class LldbCliBackend implements DebuggerBackend { async dispose(): Promise { if (this.disposed) return; this.disposed = true; + this.resumeOutputPending = false; this.failPending(new Error('LLDB backend disposed')); this.process.dispose(); }