From a804d31822d451335a93b88a6b88b7e74785d102 Mon Sep 17 00:00:00 2001 From: han Date: Tue, 18 Aug 2026 15:43:28 +0800 Subject: [PATCH 1/4] =?UTF-8?q?perf(ios-simulator):=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E4=BC=98=E5=8C=96=EF=BC=88arch=20=E9=A2=84?= =?UTF-8?q?=E6=A3=80=20+=20=E7=BC=93=E5=AD=98=E5=A4=8D=E7=94=A8=20+=20arti?= =?UTF-8?q?fact=20=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一 worktree 反复 build 从分钟级全量降到秒级增量,build 前说清目标 arch。 - arch 预检:build 前 -showBuildSettings 读所有 target 的 ARCHS−EXCLUDED_ARCHS, 定位 .app target,不匹配抛 APP_ARCH_MISMATCH,避免白编译。 - 缓存复用:derivedDataPath/SPM checkout 按 sha256(worktree+arch) 跨 session 复用, 加 -onlyUsePackageVersionsFromResolvedFile(无 resolved 时 fallback resolve)。 - 并发:同 key 进程内互斥 + prune 实时 isSkip 回调 + renameSync 原子分离。 - artifact 生命周期:cp verbatimSymlinks 到 immutable 副本,每 instance 上限 4, install 期间 pin,unpin 归零/dispose 时回收;dispose 等待删除完成。 Refs #2891 Signed-off-by: han --- .../__tests__/ios-simulator.test.ts | 949 +++++++++++++++++- .../main/mcp-integrations/ios-simulator.ts | 435 ++++++-- apps/desktop/src/shared/ipc-errors.ts | 2 + .../src/instance-errors.ts | 1 + .../src/project-adapter.test.ts | 197 ++++ .../src/project-adapter.ts | 129 ++- 6 files changed, 1631 insertions(+), 82 deletions(-) diff --git a/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts b/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts index d2a85f21a9..9a420ab292 100644 --- a/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts +++ b/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts @@ -1,4 +1,14 @@ -import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + stat, + symlink, + utimes, + writeFile, +} from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -49,11 +59,69 @@ import { getIOSSimulatorPluginStatus, getIOSSimulatorMcpDeps, reconcilePersistedIOSSimulatorOwnership, + pruneStaleIOSSimulatorBuildCaches, type IOSSimulatorAppLifecycleAdapter, type IOSSimulatorMediaCaptureAdapter, type IOSSimulatorProjectBuilderAdapter, } from '../ios-simulator'; +describe('pruneStaleIOSSimulatorBuildCaches', () => { + it('removes directories older than the max age and keeps fresh ones', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'cindy-cache-')); + try { + const stale = path.join(root, 'stale'); + const fresh = path.join(root, 'fresh'); + await mkdir(stale); + await mkdir(fresh); + const now = Date.now(); + const staleTime = new Date(now - 8 * 24 * 60 * 60 * 1000); + await utimes(stale, staleTime, staleTime); + await pruneStaleIOSSimulatorBuildCaches( + [root], + 7 * 24 * 60 * 60 * 1000, + () => now, + ); + await expect(stat(stale)).rejects.toThrow(); + await expect(stat(fresh)).resolves.toBeTruthy(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('ignores a missing root', async () => { + await expect( + pruneStaleIOSSimulatorBuildCaches( + [path.join(os.tmpdir(), 'cindy-does-not-exist-xyz')], + 1_000, + ), + ).resolves.toBeUndefined(); + }); + + it('keeps active (skipped) caches even when their mtime is stale', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'cindy-cache-')); + try { + const active = path.join(root, 'active-key'); + const idle = path.join(root, 'idle-key'); + await mkdir(active); + await mkdir(idle); + const now = Date.now(); + const staleTime = new Date(now - 8 * 24 * 60 * 60 * 1000); + await utimes(active, staleTime, staleTime); + await utimes(idle, staleTime, staleTime); + await pruneStaleIOSSimulatorBuildCaches( + [root], + 7 * 24 * 60 * 60 * 1000, + () => now, + (name) => name === 'active-key', + ); + await expect(stat(active)).resolves.toBeTruthy(); + await expect(stat(idle)).rejects.toThrow(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + const READY_REPORT: IOSSimulatorEnvironmentReport = { platform: 'darwin', supported: true, @@ -2994,6 +3062,9 @@ describe('iOS Simulator host', () => { resultBundlePath, buildLogTail: 'build succeeded', })); + // build_app copies the product into an immutable location; `cp` requires + // the source tree to actually exist. + await mkdir('/tmp/force-quit-xcresult-session/Demo.app', { recursive: true }); let readSignal: AbortSignal | undefined; let releaseRead!: () => void; let projectBuilder!: IOSSimulatorProjectBuilderAdapter; @@ -3074,6 +3145,9 @@ describe('iOS Simulator host', () => { errorCode: 'MUTATION_CANCELLED', }); expect(readXcresult).toHaveBeenCalledWith(resultBundlePath, undefined, readSignal); + await rm('/tmp/force-quit-xcresult-session', { recursive: true, force: true }).catch( + () => undefined, + ); }); it('synchronously aborts active builds before updater force-quit', async () => { @@ -7868,6 +7942,9 @@ describe('iOS Simulator host', () => { 'compile /tmp/session-a/secret.swift\\nGH_TOKEN=ghp_1234567890abcdefghijkl\\nwarning: keep this warning', }), ); + // build_app now copies the product into an immutable location; `cp` + // requires the source tree to actually exist. + await mkdir('/tmp/session-a/build/Demo.app', { recursive: true }); const validateLaunch = vi.fn(async () => ({ healthy: true, expectedPort: 8081, @@ -8013,6 +8090,7 @@ describe('iOS Simulator host', () => { ).resolves.toMatchObject({ ok: true }); const mobileArtifact = { ...artifact, artifactId: 'mobile-artifact' }; + await mkdir('/tmp/session-a/apps/mobile/ios/build/Cindy.app', { recursive: true }); buildProject.mockResolvedValueOnce({ kind: 'cindy-mobile', worktreeRoot: '/tmp/session-a', @@ -8109,6 +8187,875 @@ describe('iOS Simulator host', () => { errorCode: 'MUTATION_CANCELLED', }); await expect(stopping).resolves.toMatchObject({ ok: true }); + await rm('/tmp/session-a', { recursive: true, force: true }).catch( + () => undefined, + ); + }); + + it('does not evict an artifact while install_app is reading it', async () => { + const userData = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-pin-ud-')); + const worktree = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-pin-wt-')); + const getPath = vi.spyOn(app, 'getPath').mockImplementation((name) => { + if (name === 'userData') return userData; + return userData; + }); + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle: { + findExact: vi.fn(), + bootExact: vi.fn(async () => ({ ...READY_REPORT.devices[0]!, state: 'Booted' })), + shutdownExact: vi.fn(async () => undefined), + createExact: vi.fn(), + deleteExact: vi.fn(), + }, + }); + const sourceApp = path.join(worktree, 'Demo.app'); + await mkdir(sourceApp, { recursive: true }); + let createdSeq = 0; + const inspectedPaths: string[] = []; + const inspectArtifact = vi.fn( + async (_worktreeRoot, appPath) => { + createdSeq += 1; + inspectedPaths.push(appPath); + return { + artifactId: `artifact-${createdSeq}`, + worktreeRoot: worktree, + authorizedRoot: path.dirname(appPath), + appPath, + bundleId: 'com.example.demo', + createdAt: new Date(Date.UTC(2026, 7, 18, 0, 0, createdSeq)).toISOString(), + }; + }, + ); + let releaseInstall: () => void = () => undefined; + const installGate = new Promise((resolve) => { + releaseInstall = resolve; + }); + const installExact = vi.fn( + async () => { + if (installExact.mock.calls.length === 1) await installGate; + }, + ); + const host = createIOSSimulatorHost({ + actor, + driverManager: { + get: vi.fn(() => null), + start: vi.fn( + async (options) => + ({ + instanceId: options.instanceId, + simulatorUdid: options.simulatorUdid, + pid: 42, + driver: {}, + driverSessionId: 'wda-session', + }) as unknown as Promise, + ), + stop: vi.fn(async () => undefined), + }, + projectBuilder: { + build: vi.fn(async ({ worktreeRoot, derivedDataPath }) => ({ + kind: 'xcode-project' as const, + worktreeRoot, + projectRoot: worktreeRoot, + containerPath: `${worktreeRoot}/Demo.xcodeproj`, + scheme: 'Demo', + appPath: sourceApp, + resultBundlePath: `${derivedDataPath}/CindyBuild.xcresult`, + buildLogTail: '', + })), + }, + appLifecycle: { + inspectArtifact, + installExact, + launchExact: vi.fn(async () => undefined), + terminateExact: vi.fn(async () => undefined), + openUrlExact: vi.fn(async () => undefined), + }, + resourceScheduler: testResourceScheduler(), + runtime: { inspect: vi.fn(async () => READY_REPORT) }, + getSession: vi.fn(async (id) => ({ id, workDir: worktree, remoteHostId: null })), + resolveWorktreeRoot: vi.fn(async () => worktree), + }); + + try { + await host.callTool( + 'attach_device', + { udid: READY_REPORT.devices[0]!.udid }, + { sessionId: 'session-pin', origin: 'user' }, + ); + const instance = actor.list('session-pin')[0]!; + const route = { + instanceId: instance.instanceId, + generation: instance.generation, + leaseId: instance.lease.id, + }; + const artifactIds: string[] = []; + for (let i = 0; i < 4; i += 1) { + const built = await host.callTool('build_app', route, { + sessionId: 'session-pin', + origin: 'user', + }); + expect(built).toMatchObject({ ok: true }); + artifactIds.push( + (built as { ok: true; data: { artifact: { artifactId: string } } }).data.artifact + .artifactId, + ); + } + const oldestPath = inspectedPaths[0]!; + await expect(stat(oldestPath)).resolves.toBeDefined(); + + const installing = host.callTool( + 'install_app', + { ...route, artifactId: artifactIds[0] }, + { sessionId: 'session-pin', origin: 'user' }, + ); + await vi.waitFor(() => expect(installExact).toHaveBeenCalledOnce()); + + const fifth = await host.callTool('build_app', route, { + sessionId: 'session-pin', + origin: 'user', + }); + expect(fifth).toMatchObject({ ok: true }); + await expect(stat(oldestPath)).resolves.toBeDefined(); + + releaseInstall(); + await expect(installing).resolves.toMatchObject({ ok: true }); + + const sixth = await host.callTool('build_app', route, { + sessionId: 'session-pin', + origin: 'user', + }); + expect(sixth).toMatchObject({ ok: true }); + await vi.waitFor(() => expect(stat(oldestPath)).rejects.toMatchObject({ code: 'ENOENT' })); + await expect( + host.callTool( + 'install_app', + { ...route, artifactId: artifactIds[0] }, + { sessionId: 'session-pin', origin: 'user' }, + ), + ).resolves.toMatchObject({ ok: false, errorCode: 'APP_ARTIFACT_INVALID' }); + } finally { + releaseInstall(); + getPath.mockRestore(); + await host.dispose(); + await rm(userData, { recursive: true, force: true }); + await rm(worktree, { recursive: true, force: true }); + } + }); + + it('evicts excess artifacts when the last install pin is released', async () => { + const userData = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-unpin-ud-')); + const worktree = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-unpin-wt-')); + const getPath = vi.spyOn(app, 'getPath').mockImplementation((name) => { + if (name === 'userData') return userData; + return userData; + }); + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle: { + findExact: vi.fn(), + bootExact: vi.fn(async () => ({ ...READY_REPORT.devices[0]!, state: 'Booted' })), + shutdownExact: vi.fn(async () => undefined), + createExact: vi.fn(), + deleteExact: vi.fn(), + }, + }); + const sourceApp = path.join(worktree, 'Demo.app'); + await mkdir(sourceApp, { recursive: true }); + let createdSeq = 0; + const inspectedPaths: string[] = []; + const inspectArtifact = vi.fn( + async (_worktreeRoot, appPath) => { + createdSeq += 1; + inspectedPaths.push(appPath); + return { + artifactId: `artifact-${createdSeq}`, + worktreeRoot: worktree, + authorizedRoot: path.dirname(appPath), + appPath, + bundleId: 'com.example.demo', + createdAt: new Date(Date.UTC(2026, 7, 18, 0, 0, createdSeq)).toISOString(), + }; + }, + ); + let releaseOldestInstall: () => void = () => undefined; + const oldestInstallGate = new Promise((resolve) => { + releaseOldestInstall = resolve; + }); + const installExact = vi.fn( + async (_udid, artifact) => { + if (artifact.artifactId === 'artifact-1') await oldestInstallGate; + }, + ); + const host = createIOSSimulatorHost({ + actor, + driverManager: { + get: vi.fn(() => null), + start: vi.fn( + async (options) => + ({ + instanceId: options.instanceId, + simulatorUdid: options.simulatorUdid, + pid: 42, + driver: {}, + driverSessionId: 'wda-session', + }) as unknown as Promise, + ), + stop: vi.fn(async () => undefined), + }, + projectBuilder: { + build: vi.fn(async ({ worktreeRoot, derivedDataPath }) => ({ + kind: 'xcode-project' as const, + worktreeRoot, + projectRoot: worktreeRoot, + containerPath: `${worktreeRoot}/Demo.xcodeproj`, + scheme: 'Demo', + appPath: sourceApp, + resultBundlePath: `${derivedDataPath}/CindyBuild.xcresult`, + buildLogTail: '', + })), + }, + appLifecycle: { + inspectArtifact, + installExact, + launchExact: vi.fn(async () => undefined), + terminateExact: vi.fn(async () => undefined), + openUrlExact: vi.fn(async () => undefined), + }, + resourceScheduler: testResourceScheduler(), + runtime: { inspect: vi.fn(async () => READY_REPORT) }, + getSession: vi.fn(async (id) => ({ id, workDir: worktree, remoteHostId: null })), + resolveWorktreeRoot: vi.fn(async () => worktree), + }); + + try { + await host.callTool( + 'attach_device', + { udid: READY_REPORT.devices[0]!.udid }, + { sessionId: 'session-unpin', origin: 'user' }, + ); + const instance = actor.list('session-unpin')[0]!; + const route = { + instanceId: instance.instanceId, + generation: instance.generation, + leaseId: instance.lease.id, + }; + const artifactIds: string[] = []; + for (let i = 0; i < 4; i += 1) { + const built = await host.callTool('build_app', route, { + sessionId: 'session-unpin', + origin: 'user', + }); + expect(built).toMatchObject({ ok: true }); + artifactIds.push( + (built as { ok: true; data: { artifact: { artifactId: string } } }).data.artifact + .artifactId, + ); + } + const oldestPath = inspectedPaths[0]!; + const installs = artifactIds.map((artifactId) => + host.callTool( + 'install_app', + { ...route, artifactId }, + { sessionId: 'session-unpin', origin: 'user' }, + ), + ); + await vi.waitFor(() => expect(installExact).toHaveBeenCalledTimes(1)); + + const fifth = await host.callTool('build_app', route, { + sessionId: 'session-unpin', + origin: 'user', + }); + expect(fifth).toMatchObject({ ok: true }); + await expect(stat(oldestPath)).resolves.toBeDefined(); + + releaseOldestInstall(); + await expect(Promise.all(installs)).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ ok: true })]), + ); + await vi.waitFor(() => expect(stat(oldestPath)).rejects.toMatchObject({ code: 'ENOENT' })); + await expect( + host.callTool( + 'install_app', + { ...route, artifactId: artifactIds[0] }, + { sessionId: 'session-unpin', origin: 'user' }, + ), + ).resolves.toMatchObject({ ok: false, errorCode: 'APP_ARTIFACT_INVALID' }); + } finally { + releaseOldestInstall(); + getPath.mockRestore(); + await host.dispose(); + await rm(userData, { recursive: true, force: true }); + await rm(worktree, { recursive: true, force: true }); + } + }); + + it('evicts installed artifacts before uninstalled ones', async () => { + const userData = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-inst-ud-')); + const worktree = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-inst-wt-')); + const getPath = vi.spyOn(app, 'getPath').mockImplementation(() => userData); + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle: { + findExact: vi.fn(), + bootExact: vi.fn(async () => ({ ...READY_REPORT.devices[0]!, state: 'Booted' })), + shutdownExact: vi.fn(async () => undefined), + createExact: vi.fn(), + deleteExact: vi.fn(), + }, + }); + const sourceApp = path.join(worktree, 'Demo.app'); + await mkdir(sourceApp, { recursive: true }); + let createdSeq = 0; + const inspectedPaths: string[] = []; + const inspectArtifact = vi.fn( + async (_worktreeRoot, appPath) => { + createdSeq += 1; + inspectedPaths.push(appPath); + return { + artifactId: `artifact-${createdSeq}`, + worktreeRoot: worktree, + authorizedRoot: path.dirname(appPath), + appPath, + bundleId: 'com.example.demo', + createdAt: new Date(Date.UTC(2026, 7, 18, 0, 0, createdSeq)).toISOString(), + }; + }, + ); + const host = createIOSSimulatorHost({ + actor, + driverManager: { + get: vi.fn(() => null), + start: vi.fn( + async (options) => + ({ + instanceId: options.instanceId, + simulatorUdid: options.simulatorUdid, + pid: 42, + driver: {}, + driverSessionId: 'wda-session', + }) as unknown as Promise, + ), + stop: vi.fn(async () => undefined), + }, + projectBuilder: { + build: vi.fn(async ({ worktreeRoot, derivedDataPath }) => ({ + kind: 'xcode-project' as const, + worktreeRoot, + projectRoot: worktreeRoot, + containerPath: `${worktreeRoot}/Demo.xcodeproj`, + scheme: 'Demo', + appPath: sourceApp, + resultBundlePath: `${derivedDataPath}/CindyBuild.xcresult`, + buildLogTail: '', + })), + }, + appLifecycle: { + inspectArtifact, + installExact: vi.fn(async () => undefined), + launchExact: vi.fn(async () => undefined), + terminateExact: vi.fn(async () => undefined), + openUrlExact: vi.fn(async () => undefined), + }, + resourceScheduler: testResourceScheduler(), + runtime: { inspect: vi.fn(async () => READY_REPORT) }, + getSession: vi.fn(async (id) => ({ id, workDir: worktree, remoteHostId: null })), + resolveWorktreeRoot: vi.fn(async () => worktree), + }); + + try { + await host.callTool( + 'attach_device', + { udid: READY_REPORT.devices[0]!.udid }, + { sessionId: 'session-inst', origin: 'user' }, + ); + const instance = actor.list('session-inst')[0]!; + const route = { + instanceId: instance.instanceId, + generation: instance.generation, + leaseId: instance.lease.id, + }; + const artifactIds: string[] = []; + for (let i = 0; i < 4; i += 1) { + const built = await host.callTool('build_app', route, { + sessionId: 'session-inst', + origin: 'user', + }); + expect(built).toMatchObject({ ok: true }); + artifactIds.push( + (built as { ok: true; data: { artifact: { artifactId: string } } }).data.artifact + .artifactId, + ); + } + // Install the oldest artifact: it becomes "consumed" and should be the + // one evicted on the fifth build, not the still-pending second artifact. + await expect( + host.callTool( + 'install_app', + { ...route, artifactId: artifactIds[0] }, + { sessionId: 'session-inst', origin: 'user' }, + ), + ).resolves.toMatchObject({ ok: true }); + + const fifth = await host.callTool('build_app', route, { + sessionId: 'session-inst', + origin: 'user', + }); + expect(fifth).toMatchObject({ ok: true }); + await vi.waitFor(() => expect(stat(inspectedPaths[0])).rejects.toMatchObject({ code: 'ENOENT' })); + await expect(stat(inspectedPaths[1])).resolves.toBeDefined(); + } finally { + getPath.mockRestore(); + await host.dispose(); + await rm(userData, { recursive: true, force: true }); + await rm(worktree, { recursive: true, force: true }); + } + }); + + it('does not leave a stuck build when containerPath is invalid', async () => { + const userData = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-arg-ud-')); + const worktree = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-arg-wt-')); + const getPath = vi.spyOn(app, 'getPath').mockImplementation(() => userData); + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle: { + findExact: vi.fn(), + bootExact: vi.fn(async () => ({ ...READY_REPORT.devices[0]!, state: 'Booted' })), + shutdownExact: vi.fn(async () => undefined), + createExact: vi.fn(), + deleteExact: vi.fn(), + }, + }); + const sourceApp = path.join(worktree, 'Demo.app'); + await mkdir(sourceApp, { recursive: true }); + const host = createIOSSimulatorHost({ + actor, + driverManager: { + get: vi.fn(() => null), + start: vi.fn( + async (options) => + ({ + instanceId: options.instanceId, + simulatorUdid: options.simulatorUdid, + pid: 42, + driver: {}, + driverSessionId: 'wda-session', + }) as unknown as Promise, + ), + stop: vi.fn(async () => undefined), + }, + projectBuilder: { + build: vi.fn(async ({ worktreeRoot, derivedDataPath }) => ({ + kind: 'xcode-project' as const, + worktreeRoot, + projectRoot: worktreeRoot, + containerPath: `${worktreeRoot}/Demo.xcodeproj`, + scheme: 'Demo', + appPath: sourceApp, + resultBundlePath: `${derivedDataPath}/CindyBuild.xcresult`, + buildLogTail: '', + })), + }, + appLifecycle: { + inspectArtifact: vi.fn(async (_worktreeRoot, appPath) => ({ + artifactId: 'artifact-arg', + worktreeRoot: worktree, + authorizedRoot: path.dirname(appPath), + appPath, + bundleId: 'com.example.demo', + createdAt: new Date(Date.UTC(2026, 7, 18)).toISOString(), + })), + installExact: vi.fn(async () => undefined), + launchExact: vi.fn(async () => undefined), + terminateExact: vi.fn(async () => undefined), + openUrlExact: vi.fn(async () => undefined), + }, + resourceScheduler: testResourceScheduler(), + runtime: { inspect: vi.fn(async () => READY_REPORT) }, + getSession: vi.fn(async (id) => ({ id, workDir: worktree, remoteHostId: null })), + resolveWorktreeRoot: vi.fn(async () => worktree), + }); + + try { + await host.callTool( + 'attach_device', + { udid: READY_REPORT.devices[0]!.udid }, + { sessionId: 'session-arg', origin: 'user' }, + ); + const instance = actor.list('session-arg')[0]!; + const route = { + instanceId: instance.instanceId, + generation: instance.generation, + leaseId: instance.lease.id, + }; + // Invalid containerPath must reject without registering a stuck build. + await expect( + host.callTool( + 'build_app', + { ...route, containerPath: '' }, + { sessionId: 'session-arg', origin: 'user' }, + ), + ).resolves.toMatchObject({ ok: false, errorCode: 'INVALID_ARGUMENT' }); + // A subsequent valid build must still be admitted (not DEVICE_BUSY). + await expect( + host.callTool('build_app', route, { sessionId: 'session-arg', origin: 'user' }), + ).resolves.toMatchObject({ ok: true }); + } finally { + getPath.mockRestore(); + await host.dispose(); + await rm(userData, { recursive: true, force: true }); + await rm(worktree, { recursive: true, force: true }); + } + }); + + it('does not evict an artifact while launch_app is waiting', async () => { + const userData = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-launch-ud-')); + const worktree = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-launch-wt-')); + const getPath = vi.spyOn(app, 'getPath').mockImplementation(() => userData); + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle: { + findExact: vi.fn(), + bootExact: vi.fn(async () => ({ ...READY_REPORT.devices[0]!, state: 'Booted' })), + shutdownExact: vi.fn(async () => undefined), + createExact: vi.fn(), + deleteExact: vi.fn(), + }, + }); + const sourceApp = path.join(worktree, 'Demo.app'); + await mkdir(sourceApp, { recursive: true }); + let createdSeq = 0; + const inspectedPaths: string[] = []; + const inspectArtifact = vi.fn( + async (_worktreeRoot, appPath) => { + createdSeq += 1; + inspectedPaths.push(appPath); + return { + artifactId: `artifact-${createdSeq}`, + worktreeRoot: worktree, + authorizedRoot: path.dirname(appPath), + appPath, + bundleId: 'com.example.demo', + createdAt: new Date(Date.UTC(2026, 7, 18, 0, 0, createdSeq)).toISOString(), + }; + }, + ); + let releaseLaunch: () => void = () => undefined; + const launchGate = new Promise((resolve) => { + releaseLaunch = resolve; + }); + const launchExact = vi.fn(async () => { + if (launchExact.mock.calls.length === 1) await launchGate; + }); + const host = createIOSSimulatorHost({ + actor, + driverManager: { + get: vi.fn(() => null), + start: vi.fn( + async (options) => + ({ + instanceId: options.instanceId, + simulatorUdid: options.simulatorUdid, + pid: 42, + driver: {}, + driverSessionId: 'wda-session', + }) as unknown as Promise, + ), + stop: vi.fn(async () => undefined), + }, + projectBuilder: { + build: vi.fn(async ({ worktreeRoot, derivedDataPath }) => ({ + kind: 'xcode-project' as const, + worktreeRoot, + projectRoot: worktreeRoot, + containerPath: `${worktreeRoot}/Demo.xcodeproj`, + scheme: 'Demo', + appPath: sourceApp, + resultBundlePath: `${derivedDataPath}/CindyBuild.xcresult`, + buildLogTail: '', + })), + }, + appLifecycle: { + inspectArtifact, + installExact: vi.fn(async () => undefined), + launchExact, + terminateExact: vi.fn(async () => undefined), + openUrlExact: vi.fn(async () => undefined), + }, + resourceScheduler: testResourceScheduler(), + runtime: { inspect: vi.fn(async () => READY_REPORT) }, + getSession: vi.fn(async (id) => ({ id, workDir: worktree, remoteHostId: null })), + resolveWorktreeRoot: vi.fn(async () => worktree), + }); + + try { + await host.callTool( + 'attach_device', + { udid: READY_REPORT.devices[0]!.udid }, + { sessionId: 'session-launch', origin: 'user' }, + ); + const instance = actor.list('session-launch')[0]!; + const route = { + instanceId: instance.instanceId, + generation: instance.generation, + leaseId: instance.lease.id, + }; + const artifactIds: string[] = []; + for (let i = 0; i < 4; i += 1) { + const built = await host.callTool('build_app', route, { + sessionId: 'session-launch', + origin: 'user', + }); + expect(built).toMatchObject({ ok: true }); + artifactIds.push( + (built as { ok: true; data: { artifact: { artifactId: string } } }).data.artifact + .artifactId, + ); + } + const launching = host.callTool( + 'launch_app', + { ...route, artifactId: artifactIds[0], args: [] }, + { sessionId: 'session-launch', origin: 'user' }, + ); + await vi.waitFor(() => expect(launchExact).toHaveBeenCalledOnce()); + + // A fifth build evicts the oldest unpinned artifact; the launched (pinned) + // artifact must survive, so the oldest *unpinned* one goes instead. + const fifth = await host.callTool('build_app', route, { + sessionId: 'session-launch', + origin: 'user', + }); + expect(fifth).toMatchObject({ ok: true }); + await expect(stat(inspectedPaths[0])).resolves.toBeDefined(); + await vi.waitFor(() => + expect(stat(inspectedPaths[1])).rejects.toMatchObject({ code: 'ENOENT' }), + ); + + releaseLaunch(); + await expect(launching).resolves.toMatchObject({ ok: true }); + } finally { + releaseLaunch(); + getPath.mockRestore(); + await host.dispose(); + await rm(userData, { recursive: true, force: true }); + await rm(worktree, { recursive: true, force: true }); + } + }); + + itMac('reclaims artifact copies when userData is behind a symlink', async () => { + const realUserData = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-link-real-')); + const linkUserData = path.join(os.tmpdir(), `cindy-ios-link-${crypto.randomUUID()}`); + await symlink(realUserData, linkUserData, 'dir'); + const worktree = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-link-wt-')); + const getPath = vi.spyOn(app, 'getPath').mockImplementation((name) => { + if (name === 'userData') return linkUserData; + return linkUserData; + }); + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle: { + findExact: vi.fn(), + bootExact: vi.fn(async () => ({ ...READY_REPORT.devices[0]!, state: 'Booted' })), + shutdownExact: vi.fn(async () => undefined), + createExact: vi.fn(), + deleteExact: vi.fn(), + }, + }); + const sourceApp = path.join(worktree, 'Demo.app'); + await mkdir(sourceApp, { recursive: true }); + const resolvedAppPaths: string[] = []; + const inspectArtifact = vi.fn( + async (_worktreeRoot, appPath) => { + // Mirror the runtime adapter: it realpaths the copy, so the stored + // appPath no longer shares the (symlinked) managed root's prefix. + const resolved = await realpath(appPath); + resolvedAppPaths.push(resolved); + return { + artifactId: 'artifact-link', + worktreeRoot: worktree, + authorizedRoot: path.dirname(resolved), + appPath: resolved, + bundleId: 'com.example.demo', + createdAt: new Date(Date.UTC(2026, 7, 18)).toISOString(), + }; + }, + ); + const host = createIOSSimulatorHost({ + actor, + driverManager: { + get: vi.fn(() => null), + start: vi.fn( + async (options) => + ({ + instanceId: options.instanceId, + simulatorUdid: options.simulatorUdid, + pid: 42, + driver: {}, + driverSessionId: 'wda-session', + }) as unknown as Promise, + ), + stop: vi.fn(async () => undefined), + }, + projectBuilder: { + build: vi.fn(async ({ worktreeRoot, derivedDataPath }) => ({ + kind: 'xcode-project' as const, + worktreeRoot, + projectRoot: worktreeRoot, + containerPath: `${worktreeRoot}/Demo.xcodeproj`, + scheme: 'Demo', + appPath: sourceApp, + resultBundlePath: `${derivedDataPath}/CindyBuild.xcresult`, + buildLogTail: '', + })), + }, + appLifecycle: { + inspectArtifact, + installExact: vi.fn(async () => undefined), + launchExact: vi.fn(async () => undefined), + terminateExact: vi.fn(async () => undefined), + openUrlExact: vi.fn(async () => undefined), + }, + resourceScheduler: testResourceScheduler(), + runtime: { inspect: vi.fn(async () => READY_REPORT) }, + getSession: vi.fn(async (id) => ({ id, workDir: worktree, remoteHostId: null })), + resolveWorktreeRoot: vi.fn(async () => worktree), + }); + + try { + await host.callTool( + 'attach_device', + { udid: READY_REPORT.devices[0]!.udid }, + { sessionId: 'session-link', origin: 'user' }, + ); + const instance = actor.list('session-link')[0]!; + const route = { + instanceId: instance.instanceId, + generation: instance.generation, + leaseId: instance.lease.id, + }; + const built = await host.callTool('build_app', route, { + sessionId: 'session-link', + origin: 'user', + }); + expect(built).toMatchObject({ ok: true }); + const resolvedAppPath = resolvedAppPaths[0]!; + await expect(stat(resolvedAppPath)).resolves.toBeDefined(); + + await host.dispose(); + // Cleanup must reclaim the copy via its unresolved path; otherwise the + // realpathed appPath fails the managed-root comparison and the copy leaks. + await expect(stat(resolvedAppPath)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + getPath.mockRestore(); + await host.dispose(); + await rm(linkUserData, { recursive: true, force: true }); + await rm(realUserData, { recursive: true, force: true }); + await rm(worktree, { recursive: true, force: true }); + } + }); + + itMac('isolates the build cache by container within a worktree', async () => { + const userData = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-cont-ud-')); + const worktree = await mkdtemp(path.join(os.tmpdir(), 'cindy-ios-cont-wt-')); + const getPath = vi.spyOn(app, 'getPath').mockImplementation(() => userData); + const actor = new IOSSimulatorInstanceActor({ + store: new IOSSimulatorOwnershipStore({ createId: () => crypto.randomUUID() }), + lifecycle: { + findExact: vi.fn(), + bootExact: vi.fn(async () => ({ ...READY_REPORT.devices[0]!, state: 'Booted' })), + shutdownExact: vi.fn(async () => undefined), + createExact: vi.fn(), + deleteExact: vi.fn(), + }, + }); + const sourceApp = path.join(worktree, 'Demo.app'); + await mkdir(sourceApp, { recursive: true }); + const derivedDataPaths: string[] = []; + let artifactSeq = 0; + const host = createIOSSimulatorHost({ + actor, + driverManager: { + get: vi.fn(() => null), + start: vi.fn( + async (options) => + ({ + instanceId: options.instanceId, + simulatorUdid: options.simulatorUdid, + pid: 42, + driver: {}, + driverSessionId: 'wda-session', + }) as unknown as Promise, + ), + stop: vi.fn(async () => undefined), + }, + projectBuilder: { + build: vi.fn(async ({ worktreeRoot, derivedDataPath }) => { + derivedDataPaths.push(derivedDataPath); + return { + kind: 'xcode-project' as const, + worktreeRoot, + projectRoot: worktreeRoot, + containerPath: `${worktreeRoot}/Demo.xcodeproj`, + scheme: 'Demo', + appPath: sourceApp, + resultBundlePath: `${derivedDataPath}/CindyBuild.xcresult`, + buildLogTail: '', + }; + }), + }, + appLifecycle: { + inspectArtifact: vi.fn(async (_worktreeRoot, appPath) => { + artifactSeq += 1; + return { + artifactId: `artifact-${artifactSeq}`, + worktreeRoot: worktree, + authorizedRoot: path.dirname(appPath), + appPath, + bundleId: 'com.example.demo', + createdAt: new Date(Date.UTC(2026, 7, 18, 0, 0, artifactSeq)).toISOString(), + }; + }), + installExact: vi.fn(async () => undefined), + launchExact: vi.fn(async () => undefined), + terminateExact: vi.fn(async () => undefined), + openUrlExact: vi.fn(async () => undefined), + }, + resourceScheduler: testResourceScheduler(), + runtime: { inspect: vi.fn(async () => READY_REPORT) }, + getSession: vi.fn(async (id) => ({ id, workDir: worktree, remoteHostId: null })), + resolveWorktreeRoot: vi.fn(async () => worktree), + }); + + try { + await host.callTool( + 'attach_device', + { udid: READY_REPORT.devices[0]!.udid }, + { sessionId: 'session-cont', origin: 'user' }, + ); + const instance = actor.list('session-cont')[0]!; + const route = { + instanceId: instance.instanceId, + generation: instance.generation, + leaseId: instance.lease.id, + }; + await host.callTool( + 'build_app', + { ...route, containerPath: 'Alpha.xcodeproj' }, + { sessionId: 'session-cont', origin: 'user' }, + ); + await host.callTool( + 'build_app', + { ...route, containerPath: 'Beta.xcodeproj' }, + { sessionId: 'session-cont', origin: 'user' }, + ); + expect(derivedDataPaths).toHaveLength(2); + expect(derivedDataPaths[0]).not.toBe(derivedDataPaths[1]); + } finally { + getPath.mockRestore(); + await host.dispose(); + await rm(userData, { recursive: true, force: true }); + await rm(worktree, { recursive: true, force: true }); + } }); it('returns readable diagnostics when build_app fails', async () => { diff --git a/apps/desktop/src/main/mcp-integrations/ios-simulator.ts b/apps/desktop/src/main/mcp-integrations/ios-simulator.ts index 7dfdbf9615..dbf8789bae 100644 --- a/apps/desktop/src/main/mcp-integrations/ios-simulator.ts +++ b/apps/desktop/src/main/mcp-integrations/ios-simulator.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; +import { renameSync } from 'node:fs'; import type { Dirent } from 'node:fs'; -import { readdir, realpath, rm, stat } from 'node:fs/promises'; +import { cp, mkdir, readdir, realpath, rm, stat } from 'node:fs/promises'; import { release as hostOsRelease } from 'node:os'; import path from 'node:path'; @@ -117,6 +118,72 @@ const DEFAULT_DEVICE_LIVENESS_INTERVAL_MS = 1_000; const MAX_WDA_VIEWER_FRAMES_PER_SECOND = 20; const MAX_NATIVE_H264_VIEWER_FRAMES_PER_SECOND = 60; const MAX_INSTANCES_PER_SESSION = 4; +const MAX_ARTIFACTS_PER_INSTANCE = 4; +const IOS_SIMULATOR_BUILD_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * Reclaim stale per-worktree build caches (DerivedData + SPM checkouts). + * A cache is stale when its directory mtime predates `maxAgeMs`. Because an + * in-flight build does not keep its parent directory's mtime fresh, `isSkip` + * must answer live (not from a snapshot) whether a key is actively used, so a + * build admitted mid-sweep is never removed. Best-effort: a read/stat/rm + * failure on one entry never blocks a build. + */ +export async function pruneStaleIOSSimulatorBuildCaches( + roots: readonly string[], + maxAgeMs: number, + now: () => number = Date.now, + isSkip?: (name: string) => boolean, +): Promise { + const deadline = now() - maxAgeMs; + for (const root of roots) { + let entries: Dirent[]; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + continue; + } + await Promise.all( + entries + .filter((entry) => entry.isDirectory() && !isSkip?.(entry.name)) + .map(async (entry) => { + const full = path.join(root, entry.name); + try { + const info = await stat(full); + // Re-check after the await: another build may have admitted this + // key while stat was in flight. The sync check-then-rm below is + // atomic (no yield), so a key admitted after this point cannot be + // removed under an active build. + if (isSkip?.(entry.name)) return; + if (info.mtimeMs < deadline) { + // Atomically move the key aside before the (async, slow) + // recursive delete. A build admitted during the delete then + // recreates the key instead of racing the in-progress rm. + const trash = path.join( + root, + `.trash-${entry.name}-${randomUUID()}`, + ); + try { + renameSync(full, trash); + } catch { + // Losing the atomic rename (ENOENT from a concurrent sweep, or + // any transient error) abandons this prune attempt. Never remove + // the original path here: a build admitted during the failed + // rename may already be using the directory, and moving a child + // to a sibling can never be cross-device. + return; + } + await rm(trash, { recursive: true, force: true }).catch( + () => undefined, + ); + } + } catch { + // Best-effort per entry. + } + }), + ); + } +} interface IOSSimulatorSessionSnapshot { id: string; @@ -1021,8 +1088,27 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I instanceId: string; projectKind: IOSSimulatorProjectBuildResult['kind']; artifact: IOSSimulatorAppArtifact; + /** + * The unresolved copy path under `managedBuildResultsRoot()`. `artifact.appPath` + * is realpathed by `inspectArtifact`, so when `userData` sits behind a symlink + * the two no longer share a prefix; lifecycle bookkeeping must compare against + * this path, not the resolved one. + */ + immutableAppPath: string; + /** True once install_app has consumed this copy; eviction prefers installed copies. */ + installed: boolean; } >(); + // installExact re-reads Info.plist and runs simctl install against the + // immutable copy. Builds are admitted on a separate track from + // actor.runMutation, so a fifth build must not reclaim a copy that an + // in-flight install is still reading. Refcount: JS is single-threaded, so + // incrementing with no await between lookup and pin is atomic with eviction. + const pinnedArtifactIds = new Map(); + // Removals started by discardArtifactCopy that are still in flight. dispose + // awaits these so quit cannot end the process while a recursive rm — e.g. an + // eviction triggered just before shutdown — is still running. + const inFlightArtifactRemovals = new Set>(); type BuildDiagnosticRecord = { sessionId: string; instanceId: string; @@ -1048,6 +1134,10 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I resolveSettled: () => void; }; const activeBuilds = new Map(); + // Shared per-worktree+arch build cache keys serialize builds across instances: + // two sessions on the same worktree must not run two xcodebuild processes + // against one DerivedData/SPM checkout. beginBuild only serializes per instance. + const activeBuildCacheKeys = new Set(); const sessionOperationAdmissionEpochs = new Map(); const sessionRemovalAdmissionEpochs = new Map(); const activeSessionRemovalBarrierOperations = new Map< @@ -1391,6 +1481,45 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I }); }); } + /** + * Reclaim a per-build immutable artifact copy. Only paths shaped like + * `projects//artifacts/.app` are removed; anything else is + * left untouched. Returns the removal promise so teardown can await it; + * best-effort callers may ignore the returned promise. + */ + function discardArtifactCopy(appPath: string): Promise { + const managedRoot = managedBuildResultsRoot(); + const relative = path.relative(managedRoot, appPath); + const segments = relative.split(path.sep); + if ( + relative === '' || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + segments.length !== 3 || + segments[1] !== 'artifacts' + ) { + return Promise.resolve(); + } + const removal = rm(appPath, { recursive: true, force: true }).catch(() => undefined); + inFlightArtifactRemovals.add(removal); + void removal.finally(() => inFlightArtifactRemovals.delete(removal)); + return removal; + } + /** + * Cache keys that still have a live (uninstalled) artifact handle. The stale + * sweep must not remove these directories even when their mtime is old. + */ + function liveArtifactCacheKeys(): Set { + const keys = new Set(); + const managedRoot = managedBuildResultsRoot(); + for (const stored of appArtifacts.values()) { + const relative = path.relative(managedRoot, stored.immutableAppPath); + const segments = relative.split(path.sep); + if (segments.length >= 2 && segments[0]) keys.add(segments[0]); + } + return keys; + } async function removeBuildDiagnostic( diagnosticsId: string, diagnostic: BuildDiagnosticRecord, @@ -1946,7 +2075,10 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I function clearRemovedInstanceProjection(instanceId: string): void { agentControlLeases.delete(instanceId); for (const [artifactId, stored] of appArtifacts) { - if (stored.instanceId === instanceId) appArtifacts.delete(artifactId); + if (stored.instanceId === instanceId) { + appArtifacts.delete(artifactId); + void discardArtifactCopy(stored.immutableAppPath); + } } } @@ -3390,6 +3522,55 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I return stored.artifact; } + function pinArtifact(artifactId: string): void { + pinnedArtifactIds.set(artifactId, (pinnedArtifactIds.get(artifactId) ?? 0) + 1); + } + + /** + * Reclaim oldest unpinned copies until this instance is back at + * MAX_ARTIFACTS_PER_INSTANCE. Pinned copies stay; the bound may + * temporarily exceed MAX while an install is still reading them. + */ + function evictUnpinnedArtifactsForInstance(instanceId: string, keepExtra = 0): void { + const instanceArtifacts = [...appArtifacts.entries()] + .filter(([, stored]) => stored.instanceId === instanceId) + .sort((a, b) => { + // Evict installed (already consumed) copies before uninstalled ones so a + // caller's pending artifact handle survives as long as possible. + if (a[1].installed !== b[1].installed) return a[1].installed ? -1 : 1; + return a[1].artifact.createdAt.localeCompare(b[1].artifact.createdAt); + }); + const overflow = instanceArtifacts.length + keepExtra - MAX_ARTIFACTS_PER_INSTANCE; + let evicted = 0; + for (const [oldArtifactId, oldStored] of instanceArtifacts) { + if (evicted >= overflow) break; + if ((pinnedArtifactIds.get(oldArtifactId) ?? 0) > 0) continue; + void discardArtifactCopy(oldStored.immutableAppPath); + appArtifacts.delete(oldArtifactId); + evicted += 1; + } + } + + function unpinArtifact(artifactId: string): void { + const remaining = (pinnedArtifactIds.get(artifactId) ?? 1) - 1; + if (remaining > 0) { + pinnedArtifactIds.set(artifactId, remaining); + return; + } + pinnedArtifactIds.delete(artifactId); + const stored = appArtifacts.get(artifactId); + if (stored) evictUnpinnedArtifactsForInstance(stored.instanceId); + } + + function requireAndPinArtifact( + instance: IOSSimulatorInstance, + artifactId: string, + ): IOSSimulatorAppArtifact { + const artifact = requireArtifact(instance, artifactId); + pinArtifact(artifactId); + return artifact; + } + function cancelIdleRecycle(instanceId: string): void { const timer = idleRecycleTimers.get(instanceId); if (timer) clearTimeout(timer); @@ -5490,20 +5671,65 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I assertHostActive(); const route = readMutationRoute(buildSession.sessionId, args); const instance = actor.assertRoute(route); + // Validate before beginBuild: a rejected argument must not leave a + // build registered that never reaches the cleanup finally below. + const containerPath = readOptionalString(args, 'containerPath', 4_096); const activeBuild = beginBuild(instance, buildAdmissionEpoch); + const expectedArch = process.arch === 'x64' ? 'x86_64' : 'arm64'; + // Share DerivedData + SPM checkouts across sessions for the same + // worktree+arch+container, so repeated builds are incremental instead + // of re-cloning and recompiling per session. The container identity + // keeps two projects in one worktree from colliding on one DerivedData. + const buildCacheKey = createHash('sha256') + .update(instance.worktreeRoot) + .update('\0') + .update(expectedArch) + .update('\0') + .update(containerPath ?? '') + .digest('hex') + .slice(0, 20); + if (activeBuildCacheKeys.has(buildCacheKey)) { + finishBuild(instance.instanceId, activeBuild); + throw new IOSSimulatorInstanceError( + 'DEVICE_BUSY', + 'Another simulator is already building this worktree for the same architecture.', + true, + ); + } + activeBuildCacheKeys.add(buildCacheKey); const derivedDataPath = path.join( app.getPath('userData'), 'ios-simulator', 'projects', - createHash('sha256').update(instance.instanceId).digest('hex').slice(0, 20), + buildCacheKey, + ); + const clonedSourcePackagesDirPath = path.join( + app.getPath('userData'), + 'ios-simulator', + 'spm', + buildCacheKey, ); + // Reclaim long-idle per-worktree caches before building. The current + // cache is fresh, so it is never removed by this sweep. Failures are + // best-effort and must not block the build. + await pruneStaleIOSSimulatorBuildCaches( + [ + path.join(app.getPath('userData'), 'ios-simulator', 'projects'), + path.join(app.getPath('userData'), 'ios-simulator', 'spm'), + ], + IOS_SIMULATOR_BUILD_CACHE_MAX_AGE_MS, + undefined, + (key) => activeBuildCacheKeys.has(key) || liveArtifactCacheKeys().has(key), + ).catch(() => undefined); try { let built: IOSSimulatorProjectBuildResult; try { built = await projectBuilder.build({ worktreeRoot: instance.worktreeRoot, derivedDataPath, - containerPath: readOptionalString(args, 'containerPath', 4_096), + expectedArch, + clonedSourcePackagesDirPath, + containerPath, scheme: typeof args.scheme === 'string' ? args.scheme : undefined, signal: activeBuild.controller.signal, }); @@ -5552,63 +5778,86 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I outputTruncated: built.outputTruncated, }); assertHostActive(); - let artifact: IOSSimulatorAppArtifact; + // Copy the product out of mutable DerivedData into a per-build + // immutable location. Under the shared worktree cache a later build + // would otherwise overwrite the same `.app` path and corrupt a + // still-pending artifact handle (install would ship the newer build). + const immutableArtifactRoot = path.join(derivedDataPath, 'artifacts'); + await mkdir(immutableArtifactRoot, { recursive: true }); + const immutableAppPath = path.join( + immutableArtifactRoot, + `${randomUUID()}.app`, + ); + try { + await cp(built.appPath, immutableAppPath, { + recursive: true, + verbatimSymlinks: true, + }); + } catch (error) { + // A partially completed copy must not survive as an orphan. + void discardArtifactCopy(immutableAppPath); + throw error; + } + let artifactRegistered = false; try { + let artifact: IOSSimulatorAppArtifact; try { artifact = await appLifecycle.inspectArtifact( instance.worktreeRoot, - built.appPath, - undefined, + immutableAppPath, + derivedDataPath, activeBuild.controller.signal, ); } catch (error) { - if ( - !(error instanceof IOSSimulatorInstanceError) || - error.code !== 'APP_ARTIFACT_INVALID' - ) { - throw error; + if (disposePromise || error instanceof IOSSimulatorHostDisposedError) { + throw new IOSSimulatorHostDisposedError(); } - artifact = await appLifecycle.inspectArtifact( - instance.worktreeRoot, - built.appPath, - derivedDataPath, - activeBuild.controller.signal, - ); + return buildFailureWithDiagnostics(error, sessionId, diagnostics); } - } catch (error) { - if (disposePromise || error instanceof IOSSimulatorHostDisposedError) { - throw new IOSSimulatorHostDisposedError(); + assertHostActive(); + if (activeBuild.controller.signal.aborted) { + throw new IOSSimulatorInstanceError( + 'MUTATION_CANCELLED', + 'The app build was cancelled because its simulator session ended.', + true, + ); } - return buildFailureWithDiagnostics(error, sessionId, diagnostics); - } - assertHostActive(); - if (activeBuild.controller.signal.aborted) { - throw new IOSSimulatorInstanceError( - 'MUTATION_CANCELLED', - 'The app build was cancelled because its simulator session ended.', - true, - ); - } - actor.assertRoute(route); - appArtifacts.set(artifact.artifactId, { - instanceId: instance.instanceId, - projectKind: built.kind, - artifact, - }); - return { - ok: true, - data: { - artifact: { - artifactId: artifact.artifactId, - bundleId: artifact.bundleId, - projectKind: built.kind, - scheme: built.scheme, - createdAt: artifact.createdAt, + actor.assertRoute(route); + // Bound retained artifacts per instance so iterative builds + // cannot accumulate one immutable .app per build (which + // liveArtifactCacheKeys would then keep from being swept). + // Skip copies an in-flight install is still reading; the bound + // may temporarily exceed MAX until those pins drop. + evictUnpinnedArtifactsForInstance(instance.instanceId, 1); + appArtifacts.set(artifact.artifactId, { + instanceId: instance.instanceId, + projectKind: built.kind, + artifact, + immutableAppPath, + installed: false, + }); + artifactRegistered = true; + return { + ok: true, + data: { + artifact: { + artifactId: artifact.artifactId, + bundleId: artifact.bundleId, + projectKind: built.kind, + scheme: built.scheme, + createdAt: artifact.createdAt, + }, + diagnostics, }, - diagnostics, - }, - }; + }; + } finally { + // Reclaim the copy on any pre-registration exit (inspection + // failure, disposal, cancellation, or a lost route). Only a + // registered artifact keeps its immutable copy. + if (!artifactRegistered) void discardArtifactCopy(immutableAppPath); + } } finally { + activeBuildCacheKeys.delete(buildCacheKey); finishBuild(instance.instanceId, activeBuild); } } @@ -5711,14 +5960,25 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I if (name === 'install_app') { const route = readMutationRoute(sessionId, args); const artifactId = readString(args, 'artifactId'); - await runHostMutation(route, context, async (instance, signal) => { - requireControlGrant(instance, context); - await appLifecycle.installExact( - instance.simulatorUdid, - requireArtifact(instance, artifactId), - signal, - ); - }); + // Pin before awaiting the mutation queue: a concurrent build can + // finish (and evict) while this call is still waiting on + // actor.runMutation. Lookup+pin is sync, so it cannot interleave + // with the eviction loop on this thread. + requireAndPinArtifact(actor.assertRoute(route), artifactId); + try { + await runHostMutation(route, context, async (instance, signal) => { + requireControlGrant(instance, context); + await appLifecycle.installExact( + instance.simulatorUdid, + requireArtifact(instance, artifactId), + signal, + ); + const stored = appArtifacts.get(artifactId); + if (stored) stored.installed = true; + }); + } finally { + unpinArtifact(artifactId); + } return { ok: true, data: { artifactId, installed: true } }; } if (name === 'launch_app') { @@ -5728,36 +5988,48 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I if (!Array.isArray(launchArgs) || launchArgs.some((value) => typeof value !== 'string')) { throw new IOSSimulatorInstanceError('INVALID_ARGUMENT', 'args must be a string array'); } - await runHostMutation(route, context, async (instance, signal) => { - requireControlGrant(instance, context); - const stored = appArtifacts.get(artifactId); - if (stored?.projectKind === 'cindy-mobile' && projectBuilder.validateLaunch) { - await projectBuilder.validateLaunch( - stored.artifact.worktreeRoot, + // Pin before awaiting the mutation queue (same as install_app): a + // concurrent build can evict the artifact while this call waits. + requireAndPinArtifact(actor.assertRoute(route), artifactId); + try { + await runHostMutation(route, context, async (instance, signal) => { + requireControlGrant(instance, context); + const stored = appArtifacts.get(artifactId); + if (stored?.projectKind === 'cindy-mobile' && projectBuilder.validateLaunch) { + await projectBuilder.validateLaunch( + stored.artifact.worktreeRoot, + instance.simulatorUdid, + signal, + ); + } + await appLifecycle.launchExact( instance.simulatorUdid, + requireArtifact(instance, artifactId), + launchArgs, signal, ); - } - await appLifecycle.launchExact( - instance.simulatorUdid, - requireArtifact(instance, artifactId), - launchArgs, - signal, - ); - screenMaps.invalidate(instance.instanceId); - }); + screenMaps.invalidate(instance.instanceId); + }); + } finally { + unpinArtifact(artifactId); + } requestViewerFocus(sessionId, route.instanceId); return { ok: true, data: { artifactId, launched: true } }; } if (name === 'terminate_app') { const route = readMutationRoute(sessionId, args); const artifactId = readString(args, 'artifactId'); - await runHostMutation(route, context, async (instance, signal) => { - requireControlGrant(instance, context); - const artifact = requireArtifact(instance, artifactId); - await appLifecycle.terminateExact(instance.simulatorUdid, artifact.bundleId, signal); - screenMaps.invalidate(instance.instanceId); - }); + requireAndPinArtifact(actor.assertRoute(route), artifactId); + try { + await runHostMutation(route, context, async (instance, signal) => { + requireControlGrant(instance, context); + const artifact = requireArtifact(instance, artifactId); + await appLifecycle.terminateExact(instance.simulatorUdid, artifact.bundleId, signal); + screenMaps.invalidate(instance.instanceId); + }); + } finally { + unpinArtifact(artifactId); + } return { ok: true, data: { artifactId, terminated: true } }; } if (name === 'open_url') { @@ -6270,7 +6542,14 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I removeBuildDiagnostic(diagnosticsId, diagnostic), ), ); + for (const stored of appArtifacts.values()) { + void discardArtifactCopy(stored.immutableAppPath); + } appArtifacts.clear(); + pinnedArtifactIds.clear(); + // Await every in-flight removal (the ones started above plus any + // eviction that was still running when quit began). + await Promise.all([...inFlightArtifactRemovals]); for (const timer of idleRecycleTimers.values()) clearTimeout(timer); idleRecycleTimers.clear(); const instances = actor.listAll(); diff --git a/apps/desktop/src/shared/ipc-errors.ts b/apps/desktop/src/shared/ipc-errors.ts index bc3027d907..1f0ab2e771 100644 --- a/apps/desktop/src/shared/ipc-errors.ts +++ b/apps/desktop/src/shared/ipc-errors.ts @@ -148,6 +148,7 @@ export type IpcErrorCode = | 'AMBIGUOUS_XCODE_PROJECT' | 'APP_BUILD_FAILED' | 'APP_ARTIFACT_INVALID' + | 'APP_ARCH_MISMATCH' | 'APP_INSTALL_FAILED' | 'APP_LAUNCH_FAILED' | 'METRO_NOT_READY' @@ -339,6 +340,7 @@ const IPC_ERROR_CODES: ReadonlySet = new Set([ 'AMBIGUOUS_XCODE_PROJECT', 'APP_BUILD_FAILED', 'APP_ARTIFACT_INVALID', + 'APP_ARCH_MISMATCH', 'APP_INSTALL_FAILED', 'APP_LAUNCH_FAILED', 'METRO_NOT_READY', diff --git a/packages/ios-simulator-runtime/src/instance-errors.ts b/packages/ios-simulator-runtime/src/instance-errors.ts index 490f989630..ca92065271 100644 --- a/packages/ios-simulator-runtime/src/instance-errors.ts +++ b/packages/ios-simulator-runtime/src/instance-errors.ts @@ -27,6 +27,7 @@ export const IOS_SIMULATOR_INSTANCE_ERROR_CODES = [ "AMBIGUOUS_XCODE_PROJECT", "APP_BUILD_FAILED", "APP_ARTIFACT_INVALID", + "APP_ARCH_MISMATCH", "APP_INSTALL_FAILED", "APP_LAUNCH_FAILED", "METRO_NOT_READY", diff --git a/packages/ios-simulator-runtime/src/project-adapter.test.ts b/packages/ios-simulator-runtime/src/project-adapter.test.ts index 07dd4c0e9a..b1793b2a9c 100644 --- a/packages/ios-simulator-runtime/src/project-adapter.test.ts +++ b/packages/ios-simulator-runtime/src/project-adapter.test.ts @@ -596,4 +596,201 @@ describe("IOSSimulatorProjectBuilder", () => { }), ); }); + + it("pre-flights the target architecture before building when arm64 is excluded", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "cindy-project-")); + roots.push(root); + const workspace = path.join(root, "Example.xcworkspace"); + await mkdir(workspace); + const run = vi.fn( + async (_command, args) => { + if (args.includes("-list")) { + return { + stdout: JSON.stringify({ workspace: { schemes: ["Example"] } }), + stderr: "", + exitCode: 0, + }; + } + if (args.includes("-showBuildSettings")) { + return { + stdout: JSON.stringify([ + { + buildSettings: { + ARCHS: "arm64 x86_64", + EXCLUDED_ARCHS: "arm64", + TARGET_BUILD_DIR: path.join(root, "derived", "Build"), + WRAPPER_NAME: "Example.app", + }, + }, + ]), + stderr: "", + exitCode: 0, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + ); + await expect( + new IOSSimulatorProjectBuilder({ commandRunner: { run } }).build({ + worktreeRoot: root, + derivedDataPath: path.join(root, "derived"), + expectedArch: "arm64", + }), + ).rejects.toMatchObject({ code: "APP_ARCH_MISMATCH" }); + // The build command must never run when the preflight rejects. + expect( + run.mock.calls.some(([, args]) => args.includes("build")), + ).toBe(false); + }); + + it("proceeds to build when the target architecture is available", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "cindy-project-")); + roots.push(root); + const workspace = path.join(root, "Example.xcworkspace"); + const appPath = path.join(root, "derived", "Build", "Example.app"); + await mkdir(workspace); + await mkdir(appPath, { recursive: true }); + const run = vi.fn( + async (_command, args) => { + if (args.includes("-list")) { + return { + stdout: JSON.stringify({ workspace: { schemes: ["Example"] } }), + stderr: "", + exitCode: 0, + }; + } + if (args.includes("-showBuildSettings")) { + return { + stdout: JSON.stringify([ + { + buildSettings: { + ARCHS: "arm64 x86_64", + TARGET_BUILD_DIR: path.dirname(appPath), + WRAPPER_NAME: "Example.app", + }, + }, + ]), + stderr: "", + exitCode: 0, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + ); + const result = await new IOSSimulatorProjectBuilder({ + commandRunner: { run }, + }).build({ + worktreeRoot: root, + derivedDataPath: path.join(root, "derived"), + expectedArch: "arm64", + }); + expect(result).toMatchObject({ scheme: "Example" }); + expect( + run.mock.calls.some(([, args]) => args.includes("-onlyUsePackageVersionsFromResolvedFile")), + ).toBe(true); + }); + + it("pre-flights against the .app target when a scheme has multiple targets", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "cindy-project-")); + roots.push(root); + const workspace = path.join(root, "Example.xcworkspace"); + const appPath = path.join(root, "derived", "Build", "Example.app"); + await mkdir(workspace); + await mkdir(appPath, { recursive: true }); + const run = vi.fn( + async (_command, args) => { + if (args.includes("-list")) { + return { + stdout: JSON.stringify({ workspace: { schemes: ["Example"] } }), + stderr: "", + exitCode: 0, + }; + } + if (args.includes("-showBuildSettings")) { + // 多 target:第一个是 extension(仅 x86_64),第二个才是 .app。 + // 预检必须读 .app 的 ARCHS,否则会基于 extension 误判 arch 不匹配。 + return { + stdout: JSON.stringify([ + { + buildSettings: { + WRAPPER_NAME: "ExampleExtension.appex", + ARCHS: "x86_64", + }, + }, + { + buildSettings: { + WRAPPER_NAME: "Example.app", + ARCHS: "arm64 x86_64", + TARGET_BUILD_DIR: path.dirname(appPath), + }, + }, + ]), + stderr: "", + exitCode: 0, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + ); + const result = await new IOSSimulatorProjectBuilder({ + commandRunner: { run }, + }).build({ + worktreeRoot: root, + derivedDataPath: path.join(root, "derived"), + expectedArch: "arm64", + }); + expect(result).toMatchObject({ scheme: "Example" }); + }); + + it("ignores arch exclusions from targets not embedded in the app", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "cindy-project-")); + roots.push(root); + const workspace = path.join(root, "Example.xcworkspace"); + const appPath = path.join(root, "derived", "Build", "Example.app"); + await mkdir(workspace); + await mkdir(appPath, { recursive: true }); + const run = vi.fn( + async (_command, args) => { + if (args.includes("-list")) { + return { + stdout: JSON.stringify({ workspace: { schemes: ["Example"] } }), + stderr: "", + exitCode: 0, + }; + } + if (args.includes("-showBuildSettings")) { + // 测试 bundle (.xctest) 排除 arm64 不影响主 app 的预检。 + return { + stdout: JSON.stringify([ + { + buildSettings: { + WRAPPER_NAME: "ExampleTests.xctest", + ARCHS: "arm64 x86_64", + EXCLUDED_ARCHS: "arm64", + }, + }, + { + buildSettings: { + WRAPPER_NAME: "Example.app", + ARCHS: "arm64 x86_64", + TARGET_BUILD_DIR: path.dirname(appPath), + }, + }, + ]), + stderr: "", + exitCode: 0, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + ); + const result = await new IOSSimulatorProjectBuilder({ + commandRunner: { run }, + }).build({ + worktreeRoot: root, + derivedDataPath: path.join(root, "derived"), + expectedArch: "arm64", + }); + expect(result).toMatchObject({ scheme: "Example" }); + }); }); diff --git a/packages/ios-simulator-runtime/src/project-adapter.ts b/packages/ios-simulator-runtime/src/project-adapter.ts index c922617d67..3795422e5a 100644 --- a/packages/ios-simulator-runtime/src/project-adapter.ts +++ b/packages/ios-simulator-runtime/src/project-adapter.ts @@ -31,7 +31,7 @@ export interface IOSSimulatorProjectBuildResult extends IOSSimulatorProjectDescr /** Build failure that retains bounded diagnostics without exposing raw process state. */ export class IOSSimulatorProjectBuildError extends IOSSimulatorInstanceError { constructor( - code: "APP_BUILD_FAILED" | "APP_ARTIFACT_INVALID", + code: "APP_BUILD_FAILED" | "APP_ARTIFACT_INVALID" | "APP_ARCH_MISMATCH", message: string, readonly buildLogTail: string, readonly resultBundlePath: string | null, @@ -134,6 +134,65 @@ function summarize(values: readonly string[], limit = 8): string { return `${bounded.join(", ")}${remaining > 0 ? `, and ${remaining} more` : ""}`; } +function entryBuildSettings(entry: unknown): Record | null { + if (typeof entry !== "object" || entry === null) return null; + const buildSettings = (entry as { buildSettings?: unknown }).buildSettings; + return typeof buildSettings === "object" && buildSettings !== null + ? (buildSettings as Record) + : null; +} + +/** + * Pick the build settings of the target that produces the installable `.app`. + * `-showBuildSettings -json` emits one entry per target; a scheme with app + + * extension/helper targets would otherwise pre-flight against the wrong + * target's ARCHS. Returns null when no `.app` target is identifiable. + */ +function appTargetBuildSettings(parsed: unknown): Record | null { + if (!Array.isArray(parsed) || parsed.length === 0) return null; + for (const entry of parsed) { + const settings = entryBuildSettings(entry); + if (settings) { + const wrapper = settings.WRAPPER_NAME; + if (typeof wrapper === "string" && wrapper.endsWith(".app")) return settings; + } + } + // No `.app` target could be identified (missing/unevaluated WRAPPER_NAME). + // Do NOT fall back to the first entry — it may be a framework/extension/test + // bundle whose ARCHS differs from the app. Skip the preflight instead. + return null; +} + +/** + * Derive the effective arch set from the installable `.app` target's own + * `ARCHS − EXCLUDED_ARCHS`. Dependency targets (frameworks, extensions, Pods) + * are deliberately not modeled: `-showBuildSettings` exposes no reliable + * dependency graph, and a dependency that excludes an arch fails the build + * itself with a linker error — a heuristic here would only add false positives + * (test bundles, independent frameworks). Returns `null` when the output cannot + * be trusted. + */ +function effectiveArchitectures(showBuildSettingsJson: string): string[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(showBuildSettingsJson); + } catch { + return null; + } + const settings = appTargetBuildSettings(parsed); + if (!settings) return null; + const archs = String(settings.ARCHS ?? "") + .split(/\s+/) + .map((value) => value.trim()) + .filter(Boolean); + const excluded = String(settings.EXCLUDED_ARCHS ?? "") + .split(/\s+/) + .map((value) => value.trim()) + .filter(Boolean); + if (archs.length === 0) return null; + return archs.filter((arch) => !excluded.includes(arch)); +} + async function throwIfBuildCancelled( signal?: AbortSignal, resultBundlePath?: string, @@ -287,6 +346,10 @@ export class IOSSimulatorProjectBuilder { containerPath?: string; scheme?: string; signal?: AbortSignal; + /** Target simulator architecture; when set, the build is pre-flighted so an unmatchable artifact fails before compiling. */ + expectedArch?: "arm64" | "x86_64"; + /** Shared SPM checkout root; reuses cloned packages across sessions instead of re-cloning per build. */ + clonedSourcePackagesDirPath?: string; }): Promise { await throwIfBuildCancelled(input.signal); const project = await this.inspect(input.worktreeRoot, input.containerPath); @@ -414,14 +477,50 @@ export class IOSSimulatorProjectBuilder { "generic/platform=iOS Simulator", "-derivedDataPath", input.derivedDataPath, + ...(input.clonedSourcePackagesDirPath + ? ["-clonedSourcePackagesDirPath", input.clonedSourcePackagesDirPath] + : []), ]; + if (input.expectedArch) { + const archSettings = await this.#runner.run( + "xcodebuild", + [...commonArgs, "-showBuildSettings", "-json"], + { + cwd: project.projectRoot, + timeoutMs: 60_000, + maxBufferBytes: 4 * 1024 * 1024, + signal: input.signal, + env: this.#childEnvironment, + }, + ); + await throwIfBuildCancelled(input.signal); + const effective = + archSettings.exitCode === 0 && !archSettings.outputTruncated + ? effectiveArchitectures(archSettings.stdout) + : null; + if (effective && !effective.includes(input.expectedArch)) { + throw new IOSSimulatorProjectBuildError( + "APP_ARCH_MISMATCH", + `The build would produce architectures [${effective.join(", ")}], but the target simulator needs ${input.expectedArch}. Check whether a dependency excludes ${input.expectedArch} (for example EXCLUDED_ARCHS or an arm64-less binary framework).`, + commandLogTail([archSettings]), + null, + Boolean(archSettings.outputTruncated), + ); + } + } const resultBundlePath = path.join( input.derivedDataPath, `CindyBuild-${randomUUID()}.xcresult`, ); - const build = await this.#runner.run( + let build = await this.#runner.run( "xcodebuild", - [...commonArgs, "-resultBundlePath", resultBundlePath, "build"], + [ + ...commonArgs, + "-onlyUsePackageVersionsFromResolvedFile", + "-resultBundlePath", + resultBundlePath, + "build", + ], { cwd: project.projectRoot, timeoutMs: this.#buildTimeoutMs, @@ -431,6 +530,30 @@ export class IOSSimulatorProjectBuilder { }, ); await throwIfBuildCancelled(input.signal, resultBundlePath); + if ( + build.exitCode !== 0 && + /\bPackage\.resolved\b/i.test(`${build.stdout}\n${build.stderr}`) + ) { + // No locked Package.resolved yet (fresh SPM project), so the + // resolved-file pin cannot be honored. Retry once with a full resolve. + // The failed build may have written its .xcresult; -resultBundlePath + // requires a non-existent path, so remove it before retrying. + await rm(resultBundlePath, { recursive: true, force: true }).catch( + () => undefined, + ); + build = await this.#runner.run( + "xcodebuild", + [...commonArgs, "-resultBundlePath", resultBundlePath, "build"], + { + cwd: project.projectRoot, + timeoutMs: this.#buildTimeoutMs, + maxBufferBytes: 1024 * 1024, + signal: input.signal, + env: this.#childEnvironment, + }, + ); + await throwIfBuildCancelled(input.signal, resultBundlePath); + } const availableResultBundlePath = (await exists(resultBundlePath)) ? resultBundlePath : null; From fa1eaf0dde2c311bf3176114dbee95dc1389b68d Mon Sep 17 00:00:00 2001 From: han Date: Wed, 19 Aug 2026 14:28:49 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(ios-simulator):=20lastUsed=20=E5=86=B7?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=A0=87=E8=AE=B0=20+=20=E6=94=B6=E7=AA=84?= =?UTF-8?q?=20SPM=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回应 #2958:prune 按显式 lastUsed,不再把 cache-key 目录 mtime 当最后使用; SPM 仅在 Xcode 明确报 Package.resolved 缺失/不可用时去掉 resolved-file pin 重试。 7 天策略是机会式 TTL,不是磁盘配额。 Refs #2891 #2958 Signed-off-by: han --- .../__tests__/ios-simulator.test.ts | 26 ++++++ .../main/mcp-integrations/ios-simulator.ts | 76 ++++++++++++---- .../src/project-adapter.test.ts | 89 +++++++++++++++++++ .../src/project-adapter.ts | 31 +++++-- 4 files changed, 199 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts b/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts index 9a420ab292..f31cbfcd29 100644 --- a/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts +++ b/apps/desktop/src/main/mcp-integrations/__tests__/ios-simulator.test.ts @@ -60,6 +60,7 @@ import { getIOSSimulatorMcpDeps, reconcilePersistedIOSSimulatorOwnership, pruneStaleIOSSimulatorBuildCaches, + touchIOSSimulatorBuildCacheLastUsed, type IOSSimulatorAppLifecycleAdapter, type IOSSimulatorMediaCaptureAdapter, type IOSSimulatorProjectBuilderAdapter, @@ -97,6 +98,31 @@ describe('pruneStaleIOSSimulatorBuildCaches', () => { ).resolves.toBeUndefined(); }); + it('keeps a cache whose directory mtime is old when the last-used marker is fresh', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'cindy-cache-')); + try { + const used = path.join(root, 'used-key'); + const idle = path.join(root, 'idle-key'); + await mkdir(used); + await mkdir(idle); + const now = Date.now(); + const staleTime = new Date(now - 8 * 24 * 60 * 60 * 1000); + await utimes(used, staleTime, staleTime); + await utimes(idle, staleTime, staleTime); + await touchIOSSimulatorBuildCacheLastUsed(used, () => now); + await utimes(used, staleTime, staleTime); + await pruneStaleIOSSimulatorBuildCaches( + [root], + 7 * 24 * 60 * 60 * 1000, + () => now, + ); + await expect(stat(used)).resolves.toBeTruthy(); + await expect(stat(idle)).rejects.toThrow(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it('keeps active (skipped) caches even when their mtime is stale', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'cindy-cache-')); try { diff --git a/apps/desktop/src/main/mcp-integrations/ios-simulator.ts b/apps/desktop/src/main/mcp-integrations/ios-simulator.ts index dbf8789bae..b001b579ba 100644 --- a/apps/desktop/src/main/mcp-integrations/ios-simulator.ts +++ b/apps/desktop/src/main/mcp-integrations/ios-simulator.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { renameSync } from 'node:fs'; import type { Dirent } from 'node:fs'; -import { cp, mkdir, readdir, realpath, rm, stat } from 'node:fs/promises'; +import { cp, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; import { release as hostOsRelease } from 'node:os'; import path from 'node:path'; @@ -120,14 +120,49 @@ const MAX_NATIVE_H264_VIEWER_FRAMES_PER_SECOND = 60; const MAX_INSTANCES_PER_SESSION = 4; const MAX_ARTIFACTS_PER_INSTANCE = 4; const IOS_SIMULATOR_BUILD_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const IOS_SIMULATOR_BUILD_CACHE_LAST_USED_FILE = '.cindy-last-used'; /** - * Reclaim stale per-worktree build caches (DerivedData + SPM checkouts). - * A cache is stale when its directory mtime predates `maxAgeMs`. Because an - * in-flight build does not keep its parent directory's mtime fresh, `isSkip` - * must answer live (not from a snapshot) whether a key is actively used, so a - * build admitted mid-sweep is never removed. Best-effort: a read/stat/rm - * failure on one entry never blocks a build. + * Record the last time this cache key was admitted or finished a build. + * Xcode writes DerivedData/SPM descendants and does not refresh the cache-key + * directory mtime, so opportunistic 7-day reclaim must not use that mtime. + */ +export async function touchIOSSimulatorBuildCacheLastUsed( + cacheDir: string, + now: () => number = Date.now, +): Promise { + await mkdir(cacheDir, { recursive: true }); + await writeFile( + path.join(cacheDir, IOS_SIMULATOR_BUILD_CACHE_LAST_USED_FILE), + `${now()}\n`, + 'utf8', + ); +} + +async function readIOSSimulatorBuildCacheLastUsedMs( + cacheDir: string, +): Promise { + try { + const raw = await readFile( + path.join(cacheDir, IOS_SIMULATOR_BUILD_CACHE_LAST_USED_FILE), + 'utf8', + ); + const parsed = Number.parseInt(raw.trim(), 10); + return Number.isFinite(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * Opportunistic cold-cache reclaim for per-worktree DerivedData + SPM + * checkouts. This is a 7-day unused TTL, not a disk quota: it cannot cap + * total size. Age comes from the explicit last-used marker, not the + * cache-key directory mtime. Because an in-flight build does not keep that + * parent mtime fresh, `isSkip` must answer live (not from a snapshot) + * whether a key is actively used, so a build admitted mid-sweep is never + * removed. Best-effort: a read/stat/rm failure on one entry never blocks a + * build. */ export async function pruneStaleIOSSimulatorBuildCaches( roots: readonly string[], @@ -149,13 +184,15 @@ export async function pruneStaleIOSSimulatorBuildCaches( .map(async (entry) => { const full = path.join(root, entry.name); try { - const info = await stat(full); + const lastUsedMs = await readIOSSimulatorBuildCacheLastUsedMs(full); + const info = lastUsedMs === null ? await stat(full) : null; // Re-check after the await: another build may have admitted this - // key while stat was in flight. The sync check-then-rm below is - // atomic (no yield), so a key admitted after this point cannot be - // removed under an active build. + // key while the marker/stat was in flight. The sync check-then-rm + // below is atomic (no yield), so a key admitted after this point + // cannot be removed under an active build. if (isSkip?.(entry.name)) return; - if (info.mtimeMs < deadline) { + const ageSourceMs = lastUsedMs ?? info?.mtimeMs; + if (ageSourceMs !== undefined && ageSourceMs < deadline) { // Atomically move the key aside before the (async, slow) // recursive delete. A build admitted during the delete then // recreates the key instead of racing the in-progress rm. @@ -1508,7 +1545,8 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I } /** * Cache keys that still have a live (uninstalled) artifact handle. The stale - * sweep must not remove these directories even when their mtime is old. + * sweep must not remove these directories even when their last-used marker + * is old. */ function liveArtifactCacheKeys(): Set { const keys = new Set(); @@ -5709,9 +5747,13 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I 'spm', buildCacheKey, ); + await Promise.all([ + touchIOSSimulatorBuildCacheLastUsed(derivedDataPath), + touchIOSSimulatorBuildCacheLastUsed(clonedSourcePackagesDirPath), + ]).catch(() => undefined); // Reclaim long-idle per-worktree caches before building. The current - // cache is fresh, so it is never removed by this sweep. Failures are - // best-effort and must not block the build. + // cache is freshly marked, so it is never removed by this sweep. + // Failures are best-effort and must not block the build. await pruneStaleIOSSimulatorBuildCaches( [ path.join(app.getPath('userData'), 'ios-simulator', 'projects'), @@ -5857,6 +5899,10 @@ export function createIOSSimulatorHost(options: IOSSimulatorHostOptions = {}): I if (!artifactRegistered) void discardArtifactCopy(immutableAppPath); } } finally { + await Promise.all([ + touchIOSSimulatorBuildCacheLastUsed(derivedDataPath), + touchIOSSimulatorBuildCacheLastUsed(clonedSourcePackagesDirPath), + ]).catch(() => undefined); activeBuildCacheKeys.delete(buildCacheKey); finishBuild(instance.instanceId, activeBuild); } diff --git a/packages/ios-simulator-runtime/src/project-adapter.test.ts b/packages/ios-simulator-runtime/src/project-adapter.test.ts index b1793b2a9c..38cf63f33f 100644 --- a/packages/ios-simulator-runtime/src/project-adapter.test.ts +++ b/packages/ios-simulator-runtime/src/project-adapter.test.ts @@ -690,6 +690,95 @@ describe("IOSSimulatorProjectBuilder", () => { ).toBe(true); }); + it("retries without the resolved-file pin only when Package.resolved is missing", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "cindy-project-")); + roots.push(root); + const workspace = path.join(root, "Example.xcworkspace"); + const appPath = path.join(root, "derived", "Build", "Example.app"); + await mkdir(workspace); + await mkdir(appPath, { recursive: true }); + const run = vi.fn( + async (_command, args) => { + if (args.includes("-list")) { + return { + stdout: JSON.stringify({ workspace: { schemes: ["Example"] } }), + stderr: "", + exitCode: 0, + }; + } + if (args.includes("-showBuildSettings")) { + return { + stdout: JSON.stringify([ + { + buildSettings: { + ARCHS: "arm64", + TARGET_BUILD_DIR: path.dirname(appPath), + WRAPPER_NAME: "Example.app", + }, + }, + ]), + stderr: "", + exitCode: 0, + }; + } + if (args.includes("-onlyUsePackageVersionsFromResolvedFile")) { + return { + stdout: "", + stderr: + "error: unable to load the resolved file: Package.resolved does not exist", + exitCode: 65, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + ); + await new IOSSimulatorProjectBuilder({ commandRunner: { run } }).build({ + worktreeRoot: root, + derivedDataPath: path.join(root, "derived"), + }); + const buildCalls = run.mock.calls.filter(([, args]) => args.includes("build")); + expect(buildCalls).toHaveLength(2); + expect(buildCalls[0]![1]).toContain("-onlyUsePackageVersionsFromResolvedFile"); + expect(buildCalls[1]![1]).not.toContain( + "-onlyUsePackageVersionsFromResolvedFile", + ); + }); + + it("does not retry when a compile failure only mentions Package.resolved", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "cindy-project-")); + roots.push(root); + await mkdir(path.join(root, "Example.xcworkspace")); + const run = vi.fn( + async (_command, args) => { + if (args.includes("-list")) { + return { + stdout: JSON.stringify({ workspace: { schemes: ["Example"] } }), + stderr: "", + exitCode: 0, + }; + } + if (args.includes("build")) { + return { + stdout: "Compile Swift source files", + stderr: + "error: cannot find 'Resolved' in scope\nnote: see Package.resolved for locked versions", + exitCode: 65, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + ); + await expect( + new IOSSimulatorProjectBuilder({ commandRunner: { run } }).build({ + worktreeRoot: root, + derivedDataPath: path.join(root, "derived"), + }), + ).rejects.toMatchObject({ code: "APP_BUILD_FAILED" }); + expect(run.mock.calls.filter(([, args]) => args.includes("build"))).toHaveLength( + 1, + ); + }); + it("pre-flights against the .app target when a scheme has multiple targets", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "cindy-project-")); roots.push(root); diff --git a/packages/ios-simulator-runtime/src/project-adapter.ts b/packages/ios-simulator-runtime/src/project-adapter.ts index 3795422e5a..f07f362d9d 100644 --- a/packages/ios-simulator-runtime/src/project-adapter.ts +++ b/packages/ios-simulator-runtime/src/project-adapter.ts @@ -96,6 +96,23 @@ function tail(value: string, maxBytes = 32 * 1024): string { : bytes.subarray(-maxBytes).toString("utf8"); } +/** + * Retry the resolved-file pin only for Xcode's "file missing / unusable" + * diagnostics. A compile or link failure that merely mentions Package.resolved + * must not trigger a second full build. + */ +function isMissingPackageResolvedDiagnostic( + result: IOSSimulatorCommandResult, +): boolean { + const output = `${result.stdout}\n${result.stderr}`; + if (!/\bPackage\.resolved\b/i.test(output)) return false; + return ( + /\b(missing|does not exist|couldn't be opened|could not be opened|unable to read|no such file)\b/i.test( + output, + ) || /unable to load the resolved file/i.test(output) + ); +} + function commandLogTail( results: readonly IOSSimulatorCommandResult[], maxBytes = 32 * 1024, @@ -530,14 +547,12 @@ export class IOSSimulatorProjectBuilder { }, ); await throwIfBuildCancelled(input.signal, resultBundlePath); - if ( - build.exitCode !== 0 && - /\bPackage\.resolved\b/i.test(`${build.stdout}\n${build.stderr}`) - ) { - // No locked Package.resolved yet (fresh SPM project), so the - // resolved-file pin cannot be honored. Retry once with a full resolve. - // The failed build may have written its .xcresult; -resultBundlePath - // requires a non-existent path, so remove it before retrying. + if (build.exitCode !== 0 && isMissingPackageResolvedDiagnostic(build)) { + // Only retry when Xcode says the resolved file is missing or unusable. + // A generic compile/link failure that happens to mention Package.resolved + // must not pay for a second full build. The failed build may have written + // its .xcresult; -resultBundlePath requires a non-existent path, so + // remove it before retrying. await rm(resultBundlePath, { recursive: true, force: true }).catch( () => undefined, ); From 66bdb67a97a01005f9a69ab307302700f383b35d Mon Sep 17 00:00:00 2001 From: han Date: Wed, 19 Aug 2026 15:04:51 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(ios-simulator):=20SPM=20fallback=20?= =?UTF-8?q?=E5=8F=AA=E8=AE=A4=E5=90=8C=E4=B8=80=E8=A1=8C=20resolved=20?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2:全文分别匹配 Package.resolved 和 missing/no such file,会把 「一行提 lockfile、另一行缺头文件」误判成 resolved 缺失并再跑一轮完整 xcodebuild。改为同一诊断行同时点名 Package.resolved 且表示该文件不可用。 Refs #2899 #2958 Signed-off-by: han --- .../src/project-adapter.test.ts | 68 +++++++++++++++++++ .../src/project-adapter.ts | 20 +++--- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/packages/ios-simulator-runtime/src/project-adapter.test.ts b/packages/ios-simulator-runtime/src/project-adapter.test.ts index 38cf63f33f..6aa71f2af6 100644 --- a/packages/ios-simulator-runtime/src/project-adapter.test.ts +++ b/packages/ios-simulator-runtime/src/project-adapter.test.ts @@ -779,6 +779,74 @@ describe("IOSSimulatorProjectBuilder", () => { ); }); + it("does not retry when Package.resolved and a missing-file error are on different lines", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "cindy-project-")); + roots.push(root); + await mkdir(path.join(root, "Example.xcworkspace")); + const run = vi.fn( + async (_command, args) => { + if (args.includes("-list")) { + return { + stdout: JSON.stringify({ workspace: { schemes: ["Example"] } }), + stderr: "", + exitCode: 0, + }; + } + if (args.includes("build")) { + return { + stdout: "note: resolved versions are listed in Package.resolved", + stderr: "error: 'Header.h' file not found\nNo such file or directory: Foo.swift", + exitCode: 65, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + ); + await expect( + new IOSSimulatorProjectBuilder({ commandRunner: { run } }).build({ + worktreeRoot: root, + derivedDataPath: path.join(root, "derived"), + }), + ).rejects.toMatchObject({ code: "APP_BUILD_FAILED" }); + expect(run.mock.calls.filter(([, args]) => args.includes("build"))).toHaveLength( + 1, + ); + }); + + it("does not retry when unable to load a different resolved file", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "cindy-project-")); + roots.push(root); + await mkdir(path.join(root, "Example.xcworkspace")); + const run = vi.fn( + async (_command, args) => { + if (args.includes("-list")) { + return { + stdout: JSON.stringify({ workspace: { schemes: ["Example"] } }), + stderr: "", + exitCode: 0, + }; + } + if (args.includes("build")) { + return { + stdout: "", + stderr: "error: unable to load the resolved file of Foo.xcframework", + exitCode: 65, + }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + ); + await expect( + new IOSSimulatorProjectBuilder({ commandRunner: { run } }).build({ + worktreeRoot: root, + derivedDataPath: path.join(root, "derived"), + }), + ).rejects.toMatchObject({ code: "APP_BUILD_FAILED" }); + expect(run.mock.calls.filter(([, args]) => args.includes("build"))).toHaveLength( + 1, + ); + }); + it("pre-flights against the .app target when a scheme has multiple targets", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "cindy-project-")); roots.push(root); diff --git a/packages/ios-simulator-runtime/src/project-adapter.ts b/packages/ios-simulator-runtime/src/project-adapter.ts index f07f362d9d..55459c26ae 100644 --- a/packages/ios-simulator-runtime/src/project-adapter.ts +++ b/packages/ios-simulator-runtime/src/project-adapter.ts @@ -96,20 +96,24 @@ function tail(value: string, maxBytes = 32 * 1024): string { : bytes.subarray(-maxBytes).toString("utf8"); } +const PACKAGE_RESOLVED_ON_LINE = /\bPackage\.resolved\b/i; +const PACKAGE_RESOLVED_UNUSABLE_ON_LINE = + /\b(missing|does not exist|couldn't be opened|could not be opened|unable to read|no such file|unable to load the resolved file)\b/i; + /** - * Retry the resolved-file pin only for Xcode's "file missing / unusable" - * diagnostics. A compile or link failure that merely mentions Package.resolved - * must not trigger a second full build. + * Retry the resolved-file pin only when one diagnostic line names + * Package.resolved and says that file is missing or unusable. Two + * independent whole-log matches would retry a compile/link failure that + * mentions the lockfile on one line and a missing header on another. */ function isMissingPackageResolvedDiagnostic( result: IOSSimulatorCommandResult, ): boolean { const output = `${result.stdout}\n${result.stderr}`; - if (!/\bPackage\.resolved\b/i.test(output)) return false; - return ( - /\b(missing|does not exist|couldn't be opened|could not be opened|unable to read|no such file)\b/i.test( - output, - ) || /unable to load the resolved file/i.test(output) + return output.split(/\r?\n/).some( + (line) => + PACKAGE_RESOLVED_ON_LINE.test(line) && + PACKAGE_RESOLVED_UNUSABLE_ON_LINE.test(line), ); } From 13beec3928372d1509d211fc62f08d2106950bf0 Mon Sep 17 00:00:00 2001 From: han Date: Wed, 19 Aug 2026 17:19:23 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(ios-simulator):=20ARCH=20=E9=A2=84?= =?UTF-8?q?=E6=A3=80=E6=96=87=E6=A1=88=E5=8F=AA=E6=8C=87=E5=90=91=20app=20?= =?UTF-8?q?target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预检只读 .app 的 ARCHS − EXCLUDED_ARCHS,报错却让用户去查依赖排除, 会把人带到 Pod/SPM。改成说明 app target 有效架构与模拟器需求不匹配。 Refs #2899 Signed-off-by: han --- packages/ios-simulator-runtime/src/project-adapter.test.ts | 7 ++++++- packages/ios-simulator-runtime/src/project-adapter.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/ios-simulator-runtime/src/project-adapter.test.ts b/packages/ios-simulator-runtime/src/project-adapter.test.ts index 6aa71f2af6..0a44362309 100644 --- a/packages/ios-simulator-runtime/src/project-adapter.test.ts +++ b/packages/ios-simulator-runtime/src/project-adapter.test.ts @@ -636,7 +636,12 @@ describe("IOSSimulatorProjectBuilder", () => { derivedDataPath: path.join(root, "derived"), expectedArch: "arm64", }), - ).rejects.toMatchObject({ code: "APP_ARCH_MISMATCH" }); + ).rejects.toMatchObject({ + code: "APP_ARCH_MISMATCH", + message: expect.stringMatching( + /app target would produce architectures \[x86_64\].*needs arm64.*ARCHS and EXCLUDED_ARCHS/s, + ), + }); // The build command must never run when the preflight rejects. expect( run.mock.calls.some(([, args]) => args.includes("build")), diff --git a/packages/ios-simulator-runtime/src/project-adapter.ts b/packages/ios-simulator-runtime/src/project-adapter.ts index 55459c26ae..339e3018ae 100644 --- a/packages/ios-simulator-runtime/src/project-adapter.ts +++ b/packages/ios-simulator-runtime/src/project-adapter.ts @@ -522,7 +522,7 @@ export class IOSSimulatorProjectBuilder { if (effective && !effective.includes(input.expectedArch)) { throw new IOSSimulatorProjectBuildError( "APP_ARCH_MISMATCH", - `The build would produce architectures [${effective.join(", ")}], but the target simulator needs ${input.expectedArch}. Check whether a dependency excludes ${input.expectedArch} (for example EXCLUDED_ARCHS or an arm64-less binary framework).`, + `The app target would produce architectures [${effective.join(", ")}], but the target simulator needs ${input.expectedArch}. Check the app target's ARCHS and EXCLUDED_ARCHS.`, commandLogTail([archSettings]), null, Boolean(archSettings.outputTruncated),