diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 6170a5e70..fd30fb8ce 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -307,6 +307,10 @@ "types": "./src/platform-runtime-operations.ts", "default": "./src/platform-runtime-operations.ts" }, + "./platform-resource-cleanup": { + "types": "./src/platform-resource-cleanup.ts", + "default": "./src/platform-resource-cleanup.ts" + }, "./platform-runtime-unavailable": { "types": "./src/platform-runtime-unavailable.ts", "default": "./src/platform-runtime-unavailable.ts" diff --git a/packages/contracts/src/platform-resource-cleanup.ts b/packages/contracts/src/platform-resource-cleanup.ts new file mode 100644 index 000000000..ca623b053 --- /dev/null +++ b/packages/contracts/src/platform-resource-cleanup.ts @@ -0,0 +1,21 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; + +/** Platform-owned resource finalization invoked by neutral daemon orchestration. */ +export type PlatformResourceCleanup = Readonly<{ + stopSnapshotHelper(device: DeviceInfo): Promise; + closeManagedBrowser( + params: Readonly<{ + device: DeviceInfo; + sessionName: string; + stateDir: string; + openSessionNames: () => readonly string[]; + }>, + ): Promise; + cleanupSessionlessExecutionHost(device: DeviceInfo): Promise; + retainExecutionHostAfterClose(params: { + device: DeviceInfo; + shutdownRequested: boolean; + hasScreenRecording: boolean; + hasLease: boolean; + }): boolean; +}>; diff --git a/scripts/__tests__/test-file-size-ratchet.test.ts b/scripts/__tests__/test-file-size-ratchet.test.ts index b4fa1e494..2f6558483 100644 --- a/scripts/__tests__/test-file-size-ratchet.test.ts +++ b/scripts/__tests__/test-file-size-ratchet.test.ts @@ -34,7 +34,7 @@ const TRIPWIRE_LINES = 1_000; // Exact current lengths. Lower a pin when its file shrinks; never raise one — extract instead. const PINNED_TEST_FILE_LINES: Readonly> = Object.freeze({ 'src/__tests__/remote-connection.test.ts': 2973, - 'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2284, + 'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2242, 'src/commands/interaction/runtime/settle.test.ts': 2359, 'src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts': 1963, 'packages/platform-apple/src/runner/__tests__/runner-session.test.ts': 1957, diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 07336a28d..85bfab697 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -19,7 +19,9 @@ // an import whose source zone outranks its target zone, plus a ratchet on the // same inversion measured over TYPE-ONLY edges (R6). // - Over the DAEMON only: SessionState field ownership (R7), because the session -// record is store-owned mutable state that any daemon module can write. +// record is store-owned mutable state that any daemon module can write; and the terminal +// concrete-platform boundary (R65), which rejects every import form into src/platforms or a +// platform package. // - Over the TYPE GRAPH: the largest type-level import cycle is pinned by // equality (R9). R4 keeps the value graph acyclic, so these cycles are free at // runtime but bound what can be read in isolation; growth fails, and so does a @@ -98,6 +100,7 @@ import { policyLead, policyViolation, ZONE_POLICIES } from './zone-policy.ts'; import { contractsImplementationAuthorityViolations } from './contracts-implementation-policy.ts'; import { selectorPipelineOwnershipViolations } from './selector-pipeline-ownership.ts'; import { recordRuntimeRegistryJoinViolations } from './record-runtime-registry-policy.ts'; +import { checkDaemonPlatformBoundary } from './daemon-platform-boundary.ts'; import { listTrackedProductionSources, listTrackedTypeScriptFiles } from './tracked-sources.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { @@ -485,7 +488,9 @@ function report( `inside its declared owner (R7); the largest type-level cycle is ${typeCycle} files ` + `(R9); ${daemonModularitySummary()}; ` + `${packageBoundariesSummary(repoRoot)}; ${platformPackagePolicySummary()}; ` + - `${runtimeCommandCutoverSummary()}; and bin.ts imports normalizeCliCommandAlias, ` + + `${runtimeCommandCutoverSummary()}; R65 keeps production src/daemon free of concrete ` + + `platform imports in every executable and type-only form; and bin.ts imports ` + + `normalizeCliCommandAlias, ` + `actually passes it into buildCommandUsageText, and holds no local alias literals ` + `(R12).\n`, ); @@ -543,6 +548,7 @@ export const LAYERING_RULE_IDS = [ 'type-spine-inversions', 'session-state-ownership', 'daemon-modularity-ratchets', + 'daemon-platform-boundary', 'bin-alias-fast-path', 'package-boundaries', 'platform-package-policy', @@ -564,6 +570,8 @@ export const LAYERING_RULES: Readonly> = { 'session-state-ownership': (context) => checkSessionStateOwnership(context.sources), 'daemon-modularity-ratchets': (context) => checkDaemonModularityRatchets(context.edges, context.typeCycleMembers), + 'daemon-platform-boundary': (context) => + checkDaemonPlatformBoundary([...context.sources].map(([path, source]) => ({ path, source }))), 'bin-alias-fast-path': (context) => checkBinAliasFastPath(context.sources), 'package-boundaries': () => checkPackageBoundaries(repoRoot), 'platform-package-policy': (context) => diff --git a/scripts/layering/daemon-platform-boundary.test.ts b/scripts/layering/daemon-platform-boundary.test.ts new file mode 100644 index 000000000..0f6c5af2a --- /dev/null +++ b/scripts/layering/daemon-platform-boundary.test.ts @@ -0,0 +1,292 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + DAEMON_PLATFORM_BOUNDARY_RULE, + daemonPlatformBoundaryViolations, + findDaemonPlatformDependencies, +} from './daemon-platform-boundary.ts'; + +const daemonFile = 'src/daemon/terminal-boundary-fixture.ts'; + +function dependencies(source: string, file = daemonFile) { + return findDaemonPlatformDependencies([{ path: file, source }]); +} + +function violations(source: string, file = daemonFile) { + return daemonPlatformBoundaryViolations([{ path: file, source }]); +} + +test('R65 rejects static, type-only, dynamic, type, and re-export dependencies with source lines', () => { + const source = [ + "import { old } from '../platforms/android/old.ts';", + "import type { OldType } from '../platforms/android/types.ts';", + "const lazy = import('../platforms/android/lazy.ts');", + "type ImportedType = import('../platforms/android/type.ts').ImportedType;", + "export { old } from '../platforms/android/re-export.ts';", + "export type { OldType } from '@agent-device/platform-android/types';", + "export * from '@agent-device/platform-apple';", + ].join('\n'); + + assert.deepEqual( + dependencies(source).map(({ kind, spec, line, target }) => ({ kind, spec, line, target })), + [ + { + kind: 'static import', + spec: '../platforms/android/old.ts', + line: 1, + target: 'src/platforms/android/old.ts', + }, + { + kind: 'type-only import', + spec: '../platforms/android/types.ts', + line: 2, + target: 'src/platforms/android/types.ts', + }, + { + kind: 'dynamic import', + spec: '../platforms/android/lazy.ts', + line: 3, + target: 'src/platforms/android/lazy.ts', + }, + { + kind: 'type import', + spec: '../platforms/android/type.ts', + line: 4, + target: 'src/platforms/android/type.ts', + }, + { + kind: 're-export', + spec: '../platforms/android/re-export.ts', + line: 5, + target: 'src/platforms/android/re-export.ts', + }, + { + kind: 'type-only re-export', + spec: '@agent-device/platform-android/types', + line: 6, + target: '@agent-device/platform-android/types', + }, + { + kind: 're-export', + spec: '@agent-device/platform-apple', + line: 7, + target: '@agent-device/platform-apple', + }, + ], + ); + + assert.deepEqual( + violations(source).map(({ rule, file, line }) => ({ rule, file, line })), + Array.from({ length: 7 }, (_, index) => ({ + rule: DAEMON_PLATFORM_BOUNDARY_RULE, + file: daemonFile, + line: index + 1, + })), + ); +}); + +test('R65 recognizes side-effect and aliased imports, including nested daemon paths', () => { + const source = [ + "import '../../platforms/web/runtime.ts';", + "import { runtime as platformRuntime } from '@agent-device/platform-web/runtime';", + ].join('\n'); + + assert.deepEqual( + dependencies(source, 'src/daemon/handlers/terminal-boundary-fixture.ts').map((found) => ({ + kind: found.kind, + line: found.line, + spec: found.spec, + target: found.target, + })), + [ + { + kind: 'static import', + line: 1, + spec: '../../platforms/web/runtime.ts', + target: 'src/platforms/web/runtime.ts', + }, + { + kind: 'static import', + line: 2, + spec: '@agent-device/platform-web/runtime', + target: '@agent-device/platform-web/runtime', + }, + ], + ); +}); + +test('R65 ignores comments, ordinary strings, unresolved dynamic imports, and lookalike names', () => { + const source = [ + 'const documentation = "import(\'../platforms/android/comment.ts\'); @agent-device/platform-android";', + "// import { ignored } from '../platforms/android/comment.ts';", + "const packageName = '@agent-device/platform-android';", + "const relativeName = '../platforms/android/not-an-import.ts';", + 'const computed = import(platformSpecifier);', + "import '../platforms-sibling/not-platform.ts';", + "import '@agent-device/platforms';", + "import '@agent-device/platform';", + ].join('\n'); + + assert.deepEqual(dependencies(source), []); +}); + +test('R65 rejects require, import-equals, and template-literal type imports', () => { + const source = [ + "const android = require('@agent-device/platform-android');", + 'const runtime = require(`../platforms/android/runtime.ts`);', + "import web = require('@agent-device/platform-web');", + 'type Apple = import(`@agent-device/platform-apple`).Apple;', + 'type Android = import(`../platforms/android/types.ts`).Android;', + ].join('\n'); + + assert.deepEqual( + dependencies(source).map(({ kind, spec, line }) => ({ kind, spec, line })), + [ + { kind: 'require', spec: '@agent-device/platform-android', line: 1 }, + { kind: 'require', spec: '../platforms/android/runtime.ts', line: 2 }, + { kind: 'import equals', spec: '@agent-device/platform-web', line: 3 }, + { kind: 'type import', spec: '@agent-device/platform-apple', line: 4 }, + { kind: 'type import', spec: '../platforms/android/types.ts', line: 5 }, + ], + ); +}); + +test('R65 folds statically constructed dynamic platform specifiers', () => { + const source = [ + "const apple = import('@agent-device/' + 'platform-apple');", + 'const android = import(`../platforms/${"android"}/runtime.ts`);', + "const wrapped = import((('@agent-device/' + 'platform-android')));", + 'const required = require((`@agent-device/platform-web`));', + ].join('\n'); + + assert.deepEqual( + dependencies(source).map(({ kind, spec, line }) => ({ kind, spec, line })), + [ + { kind: 'dynamic import', spec: '@agent-device/platform-apple', line: 1 }, + { kind: 'dynamic import', spec: '../platforms/android/runtime.ts', line: 2 }, + { kind: 'dynamic import', spec: '@agent-device/platform-android', line: 3 }, + { kind: 'require', spec: '@agent-device/platform-web', line: 4 }, + ], + ); +}); + +test('R65 unwraps erased TypeScript expressions around executable specifiers', () => { + const source = [ + "void import('@agent-device/platform-android' as string);", + "require('@agent-device/platform-web' satisfies string);", + "void import('../platforms/apple/runtime.ts');", + ].join('\n'); + + assert.deepEqual( + dependencies(source).map(({ kind, spec, line }) => ({ kind, spec, line })), + [ + { kind: 'dynamic import', spec: '@agent-device/platform-android', line: 1 }, + { kind: 'require', spec: '@agent-device/platform-web', line: 2 }, + { kind: 'dynamic import', spec: '../platforms/apple/runtime.ts', line: 3 }, + ], + ); +}); + +test('R65 follows common createRequire and require aliases', () => { + const source = [ + "import { createRequire as makeRequire } from 'node:module';", + "import * as moduleApi from 'node:module';", + "import moduleDefault from 'node:module';", + 'const load = makeRequire(import.meta.url);', + 'const loadAgain = load;', + "loadAgain('@agent-device/platform-android');", + "makeRequire(import.meta.url)('../platforms/web/runtime.ts');", + "moduleApi.createRequire(import.meta.url)('@agent-device/platform-apple');", + "moduleDefault.createRequire(import.meta.url)('@agent-device/platform-web');", + 'const loadAlias = require;', + "loadAlias('@agent-device/platform-linux');", + "module.require('@agent-device/platform-vega');", + "const { createRequire: fromCjs } = require('node:module');", + "fromCjs(import.meta.url)('@agent-device/platform-harmonyos');", + ].join('\n'); + + assert.deepEqual( + dependencies(source).map(({ spec, line }) => ({ spec, line })), + [ + { spec: '@agent-device/platform-android', line: 6 }, + { spec: '../platforms/web/runtime.ts', line: 7 }, + { spec: '@agent-device/platform-apple', line: 8 }, + { spec: '@agent-device/platform-web', line: 9 }, + { spec: '@agent-device/platform-linux', line: 11 }, + { spec: '@agent-device/platform-vega', line: 12 }, + { spec: '@agent-device/platform-harmonyos', line: 14 }, + ], + ); +}); + +test('R65 rejects triple-slash concrete-platform type references', () => { + const source = [ + '/// ', + '/// ', + '/*', + '/// ', + '*/', + 'export {};', + ].join('\n'); + assert.deepEqual( + dependencies(source).map(({ kind, spec, line }) => ({ kind, spec, line })), + [ + { kind: 'type import', spec: '@agent-device/platform-android', line: 1 }, + { kind: 'type import', spec: '../platforms/apple/types.ts', line: 2 }, + ], + ); +}); + +test('R65 rejects platform selection inside cleanup orchestrators', () => { + const direct = violations( + "export const cleanup = (session: any) => session.device.platform === 'android';", + 'src/daemon/session-teardown.ts', + ); + assert.match(direct[0]?.message ?? '', /may not select a concrete platform/); + + const predicate = violations( + 'export const cleanup = (device: any) => isIosFamily(device);', + 'src/daemon/handlers/snapshot-session.ts', + ); + assert.match(predicate[0]?.message ?? '', /typed root-composed cleanup capability/); + + const destructured = violations( + "export const cleanup = (session: any) => { const { platform } = session.device; return platform === 'android'; };", + 'src/daemon/handlers/session-close-lifecycle-teardown.ts', + ); + assert.match(destructured[0]?.message ?? '', /may not select a concrete platform/); + + assert.deepEqual( + violations( + 'export const cleanup = (owner: any, device: any) => owner.cleanupSessionlessExecutionHost(device);', + 'src/daemon/handlers/snapshot-session.ts', + ), + [], + ); +}); + +test('R65 ignores non-daemon and test-shaped records even when their syntax is red', () => { + const source = "import { platform } from '../platforms/android/runtime.ts';"; + assert.deepEqual(dependencies(source, 'src/core/terminal-boundary-fixture.ts'), []); + assert.deepEqual(dependencies(source, 'src/daemon/terminal-boundary-fixture.test.ts'), []); + assert.deepEqual(dependencies(source, 'src/daemon/__tests__/terminal-boundary-fixture.ts'), []); +}); + +test('R65 treats a relative specifier as legacy platform code only after path resolution', () => { + const source = [ + "import { platform } from '../../platforms/android/runtime.ts';", + "import { sibling } from '../platforms-sibling/runtime.ts';", + "import { exact } from '../platforms';", + ].join('\n'); + + assert.deepEqual( + dependencies(source).map(({ spec, target, line }) => ({ spec, target, line })), + [ + { + spec: '../platforms', + target: 'src/platforms', + line: 3, + }, + ], + ); +}); diff --git a/scripts/layering/daemon-platform-boundary.ts b/scripts/layering/daemon-platform-boundary.ts new file mode 100644 index 000000000..b64932436 --- /dev/null +++ b/scripts/layering/daemon-platform-boundary.ts @@ -0,0 +1,409 @@ +import path from 'node:path'; +import { parseSync } from 'oxc-parser'; +import { isProductionSourceFile } from './tracked-sources.ts'; +import type { LayeringViolation } from './model.ts'; + +/** + * R65 is the terminal daemon/platform boundary. + * + * R3's old platforms seam deliberately tolerated dynamic and type-only edges while the + * request-bound runtime migration was in flight. That tolerance is not a terminal property: + * either edge still makes the daemon depend on a concrete platform implementation. This policy + * closes the boundary completely for tracked production daemon sources. It is kept independent + * from `check.ts` until the remaining live edges are removed, so the migration can plant and + * verify the red forms before making the repository-wide gate fail. + * + * The module record supplies static imports and re-exports; the AST supplies dynamic imports and + * TypeScript import types. Reading syntax through oxc-parser, rather than searching source text, + * means comments and string data cannot masquerade as dependencies. + */ + +export const DAEMON_PLATFORM_BOUNDARY_RULE = 'R65 daemon-platform-boundary'; + +/** A tracked production source passed to the standalone policy. */ +export type ProductionSource = Readonly<{ path: string; source: string }>; + +export type DaemonPlatformDependencyKind = + | 'static import' + | 'type-only import' + | 'dynamic import' + | 'type import' + | 'require' + | 'import equals' + | 're-export' + | 'type-only re-export'; + +export type DaemonPlatformDependency = Readonly<{ + file: string; + line: number; + spec: string; + kind: DaemonPlatformDependencyKind; + target: string; +}>; + +const PLATFORM_PACKAGE = /^@agent-device\/platform-[^/]+(?:\/|$)/; +const LEGACY_PLATFORM_ROOT = 'src/platforms'; + +function lineOf(source: string, offset: number | null | undefined): number { + const start = typeof offset === 'number' && offset >= 0 ? offset : 0; + return source.slice(0, start).split('\n').length; +} + +function sourceLiteral(node: Record, source: string): string | undefined { + const start = node.start; + const end = node.end; + if (typeof start !== 'number' || typeof end !== 'number') return undefined; + const raw = source.slice(start, end); + const quote = raw[0]; + if ((quote !== "'" && quote !== '"' && quote !== '`') || raw.at(-1) !== quote) return undefined; + const body = raw.slice(1, -1); + if (quote === '`' && body.includes('${')) return undefined; + return body; +} + +function constantSpecifier(node: unknown, source: string): string | undefined { + if (node === null || typeof node !== 'object') return undefined; + const record = node as Record; + if (record.type === 'ParenthesizedExpression') { + return constantSpecifier(record.expression, source); + } + if ( + record.type === 'TSAsExpression' || + record.type === 'TSTypeAssertion' || + record.type === 'TSSatisfiesExpression' || + record.type === 'TSNonNullExpression' + ) { + return constantSpecifier(record.expression, source); + } + if (record.type === 'Literal' && typeof record.value === 'string') { + // oxc-parser currently represents a template-literal TS import type as an empty Literal with + // no raw value. Recover its exact no-substitution source span so `import(`platform`)` cannot + // bypass the type-only boundary. + return record.value || sourceLiteral(record, source); + } + if (record.type === 'BinaryExpression' && record.operator === '+') { + const left = constantSpecifier(record.left, source); + const right = constantSpecifier(record.right, source); + return left === undefined || right === undefined ? undefined : left + right; + } + if (record.type !== 'TemplateLiteral') return undefined; + const expressions = record.expressions; + const quasis = record.quasis; + if (!Array.isArray(expressions) || !Array.isArray(quasis)) return undefined; + const parts: string[] = []; + for (let index = 0; index < quasis.length; index++) { + const quasi = quasis[index]; + if (quasi === null || typeof quasi !== 'object') return undefined; + const value = (quasi as Record).value; + if (value === null || typeof value !== 'object') return undefined; + const cooked = (value as Record).cooked; + if (typeof cooked !== 'string') return undefined; + parts.push(cooked); + if (index < expressions.length) { + const expression = constantSpecifier(expressions[index], source); + if (expression === undefined) return undefined; + parts.push(expression); + } + } + return parts.join(''); +} + +function visitAst(node: unknown, visitor: (node: Record) => void): void { + if (node === null || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) visitAst(child, visitor); + return; + } + const record = node as Record; + visitor(record); + for (const child of Object.values(record)) visitAst(child, visitor); +} + +function resolveRelative(file: string, spec: string): string | undefined { + if (!spec.startsWith('.')) return undefined; + return path.posix.normalize(path.posix.join(path.posix.dirname(file), spec)); +} + +function targetFor(file: string, spec: string): string | undefined { + if (PLATFORM_PACKAGE.test(spec)) return spec; + const resolved = resolveRelative(file, spec); + if (!resolved) return undefined; + return resolved === LEGACY_PLATFORM_ROOT || resolved.startsWith(`${LEGACY_PLATFORM_ROOT}/`) + ? resolved + : undefined; +} + +function dependency( + file: string, + source: string, + kind: DaemonPlatformDependencyKind, + spec: string, + offset: number | null | undefined, +): DaemonPlatformDependency | undefined { + const target = targetFor(file, spec); + if (!target) return undefined; + return { file, line: lineOf(source, offset), spec, kind, target }; +} + +function staticDependencies(file: string, source: string): DaemonPlatformDependency[] { + const parsed = parseSync(file, source); + const dependencies: DaemonPlatformDependency[] = []; + + for (const entry of parsed.module.staticImports) { + const kind: DaemonPlatformDependencyKind = + entry.entries.length > 0 && entry.entries.every(({ isType }) => isType) + ? 'type-only import' + : 'static import'; + const found = dependency(file, source, kind, entry.moduleRequest.value, entry.start); + if (found) dependencies.push(found); + } + + for (const statement of parsed.module.staticExports) { + const moduleRequest = statement.entries.find((entry) => entry.moduleRequest)?.moduleRequest; + if (!moduleRequest) continue; + const entries = statement.entries.filter((entry) => entry.moduleRequest); + const kind: DaemonPlatformDependencyKind = + entries.length > 0 && entries.every(({ isType }) => isType) + ? 'type-only re-export' + : 're-export'; + const found = dependency(file, source, kind, moduleRequest.value, statement.start); + if (found) dependencies.push(found); + } + + return dependencies; +} + +function dynamicDependencies(file: string, source: string): DaemonPlatformDependency[] { + const parsed = parseSync(file, source); + const dependencies: DaemonPlatformDependency[] = []; + const requireBindings = new Set(['require']); + const createRequireBindings = new Set(); + const moduleNamespaces = new Set(); + const createdRequireBindings = new Set(); + + visitAst(parsed.program, (node) => { + if (node.type === 'ImportDeclaration') { + const spec = constantSpecifier(node.source, source); + if (spec !== 'node:module' && spec !== 'module') return; + for (const item of (node.specifiers as Array> | undefined) ?? []) { + const local = item.local as Record | undefined; + if (typeof local?.name !== 'string') continue; + if (item.type === 'ImportNamespaceSpecifier' || item.type === 'ImportDefaultSpecifier') { + moduleNamespaces.add(local.name); + } + const imported = item.imported as Record | undefined; + if (imported?.name === 'createRequire') createRequireBindings.add(local.name); + } + return; + } + if (node.type !== 'VariableDeclarator') return; + const id = node.id as Record | undefined; + const init = node.init as Record | undefined; + if (!id || !init) return; + if (id.type === 'ObjectPattern' && isNodeModuleRequire(init, requireBindings, source)) { + for (const property of (id.properties as Array> | undefined) ?? []) { + const key = property.key as Record | undefined; + const value = property.value as Record | undefined; + if (key?.name === 'createRequire' && value?.type === 'Identifier') { + createRequireBindings.add(String(value.name)); + } + } + return; + } + if (id.type !== 'Identifier' || typeof id.name !== 'string') return; + if (init.type === 'Identifier' && requireBindings.has(String(init.name))) { + requireBindings.add(id.name); + return; + } + if (init.type === 'Identifier' && createdRequireBindings.has(String(init.name))) { + createdRequireBindings.add(id.name); + return; + } + if (isCreateRequireCall(init, createRequireBindings, moduleNamespaces)) { + createdRequireBindings.add(id.name); + } + }); + visitAst(parsed.program, (node) => { + const callee = node.callee as Record | undefined; + const isRequire = + node.type === 'CallExpression' && + callee?.type === 'Identifier' && + (requireBindings.has(String(callee.name)) || createdRequireBindings.has(String(callee.name))); + const isInlineCreateRequire = + node.type === 'CallExpression' && + callee?.type === 'CallExpression' && + isCreateRequireCall(callee, createRequireBindings, moduleNamespaces); + const isModuleRequire = + node.type === 'CallExpression' && + callee?.type === 'MemberExpression' && + memberName(callee) === 'require'; + const kind: DaemonPlatformDependencyKind | undefined = + node.type === 'ImportExpression' + ? 'dynamic import' + : node.type === 'TSImportType' + ? 'type import' + : node.type === 'TSImportEqualsDeclaration' + ? 'import equals' + : isRequire || isInlineCreateRequire || isModuleRequire + ? 'require' + : undefined; + if (!kind) return; + const specifierNode = + node.type === 'TSImportEqualsDeclaration' + ? (node.moduleReference as Record | undefined)?.expression + : isRequire || isInlineCreateRequire || isModuleRequire + ? (node.arguments as unknown[] | undefined)?.[0] + : node.source; + const spec = constantSpecifier(specifierNode, source); + if (!spec) return; + const found = dependency(file, source, kind, spec, node.start as number | undefined); + if (found) dependencies.push(found); + }); + return dependencies; +} + +function isNodeModuleRequire( + node: Record, + requireBindings: ReadonlySet, + source: string, +): boolean { + if (node.type !== 'CallExpression') return false; + const callee = node.callee as Record | undefined; + if (callee?.type !== 'Identifier' || !requireBindings.has(String(callee.name))) return false; + const spec = constantSpecifier((node.arguments as unknown[] | undefined)?.[0], source); + return spec === 'node:module' || spec === 'module'; +} + +function memberName(node: Record): string | undefined { + const property = node.property as Record | undefined; + if (property?.type === 'Identifier') return property.name as string | undefined; + return typeof property?.value === 'string' ? property.value : undefined; +} + +function isCreateRequireCall( + node: Record, + bindings: ReadonlySet, + namespaces: ReadonlySet, +): boolean { + if (node.type !== 'CallExpression') return false; + const callee = node.callee as Record | undefined; + if (callee?.type === 'Identifier') return bindings.has(String(callee.name)); + if (callee?.type !== 'MemberExpression' || memberName(callee) !== 'createRequire') return false; + const object = callee.object as Record | undefined; + return object?.type === 'Identifier' && namespaces.has(String(object.name)); +} + +function tripleSlashDependencies(file: string, source: string): DaemonPlatformDependency[] { + const dependencies: DaemonPlatformDependency[] = []; + const comments = parseSync(file, source).comments as readonly { + type: string; + value: string; + start: number; + end: number; + }[]; + const pattern = /^\s*\/\/\/\s*]*\/?>/gm; + for (const match of source.matchAll(pattern)) { + const spec = match[1]; + const start = match.index; + const end = start + match[0].length; + const directiveComment = comments.some( + (comment) => + comment.type === 'Line' && + comment.start >= start && + comment.end <= end && + comment.value.trimStart().startsWith('/'), + ); + if (!spec || !directiveComment) continue; + const found = dependency(file, source, 'type import', spec, match.index); + if (found) dependencies.push(found); + } + return dependencies; +} + +function dependencyMessage(dependency: DaemonPlatformDependency): string { + return ( + `${dependency.kind} '${dependency.spec}' resolves to concrete platform code ` + + `(${dependency.target}); production src/daemon/ must use the request-bound runtime contract` + ); +} + +/** + * Find every concrete platform dependency in tracked production `src/daemon/**` sources. + * + * The input is intentionally source records rather than a filesystem walk. Callers use the + * canonical tracked-production enumerator, while synthetic tests can plant one edge at a time + * without touching the live tree. Non-daemon, test, and untracked-shaped records are ignored. + */ +export function findDaemonPlatformDependencies( + sources: readonly ProductionSource[], +): DaemonPlatformDependency[] { + return sources + .filter(({ path: file }) => file.startsWith('src/daemon/') && isProductionSourceFile(file)) + .flatMap(({ path: file, source }) => [ + ...staticDependencies(file, source), + ...dynamicDependencies(file, source), + ...tripleSlashDependencies(file, source), + ]) + .sort( + (left, right) => + left.file.localeCompare(right.file) || + left.line - right.line || + left.kind.localeCompare(right.kind), + ); +} + +/** The gate-facing projection of `findDaemonPlatformDependencies`. */ +export function daemonPlatformBoundaryViolations( + sources: readonly ProductionSource[], +): LayeringViolation[] { + const importViolations = findDaemonPlatformDependencies(sources).map((found) => ({ + rule: DAEMON_PLATFORM_BOUNDARY_RULE, + file: found.file, + line: found.line, + message: dependencyMessage(found), + })); + return [...importViolations, ...cleanupDispatchViolations(sources)]; +} + +const CLEANUP_ORCHESTRATORS = new Set([ + 'src/daemon/session-teardown.ts', + 'src/daemon/handlers/session-close-lifecycle-teardown.ts', + 'src/daemon/handlers/snapshot-session.ts', +]); + +function cleanupDispatchViolations(sources: readonly ProductionSource[]): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const { path: file, source } of sources) { + if (!CLEANUP_ORCHESTRATORS.has(file)) continue; + visitAst(parseSync(file, source).program, (node) => { + const platformMember = node.type === 'MemberExpression' && memberName(node) === 'platform'; + const destructuresPlatform = + node.type === 'VariableDeclarator' && + (node.id as Record | undefined)?.type === 'ObjectPattern' && + ( + ((node.id as Record).properties as Array>) ?? [] + ).some( + (property) => + memberName(property) === 'platform' || + (property.key as Record | undefined)?.name === 'platform', + ); + const callee = node.callee as Record | undefined; + const platformPredicate = + node.type === 'CallExpression' && + callee?.type === 'Identifier' && + /^(?:is|has).*(?:Android|Apple|Ios|Web|Harmony|Vega|Linux)/.test(String(callee.name)); + if (!platformMember && !destructuresPlatform && !platformPredicate) return; + violations.push({ + rule: DAEMON_PLATFORM_BOUNDARY_RULE, + file, + line: lineOf(source, node.start as number | undefined), + message: + 'daemon cleanup orchestration may not select a concrete platform; invoke the typed root-composed cleanup capability', + }); + }); + } + return violations; +} + +/** Alias kept explicit for callers that name all layering checks `check*`. */ +export const checkDaemonPlatformBoundary = daemonPlatformBoundaryViolations; diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 6cc1b8f2d..799832292 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -124,6 +124,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/platform-module', '@agent-device/contracts/platform-plugin', '@agent-device/contracts/platform-providers', + '@agent-device/contracts/platform-resource-cleanup', '@agent-device/contracts/platform-runtime', '@agent-device/contracts/platform-runtime-host', '@agent-device/contracts/platform-runtime-operations', diff --git a/scripts/layering/zone-policy.test.ts b/scripts/layering/zone-policy.test.ts index d9b15b804..c6372b491 100644 --- a/scripts/layering/zone-policy.test.ts +++ b/scripts/layering/zone-policy.test.ts @@ -43,21 +43,20 @@ test('R2 commands-floor closes the four zones below the command surface, whateve assert.deepEqual(firing(edge('src/mcp/tools.ts', 'mcp', 'commands')), []); }); -test('R3 platforms-seam closes static value imports and opens the three declared seam owners', () => { +test('R3 platforms-seam closes static value imports and opens the two declared seam owners', () => { // Closed from an ordinary zone. assert.deepEqual(firing(edge('src/cli/run.ts', 'cli', 'platforms')), ['R3 platforms-seam']); // Open to the seam owners. - for (const file of [ - 'src/core/interactors/android.ts', - 'src/daemon/handlers/perf.ts', - 'src/sdk/android-adb.ts', - ]) { + for (const file of ['src/core/interactors/android.ts', 'src/sdk/android-adb.ts']) { assert.deepEqual(firing(edge(file, 'core', 'platforms')), [], `${file} is a seam owner`); } - // daemon/client/ is inside the daemon prefix but is NOT part of the seam: it is the client half - // of the process boundary (ADR 0008), so it must not reach platform code at all. + // The daemon has completed ADR 0019 and is no longer a platform seam. Neither its server nor + // its process-boundary client may reach platform code directly. + assert.deepEqual(firing(edge('src/daemon/handlers/perf.ts', 'daemon', 'platforms')), [ + 'R3 platforms-seam', + ]); assert.deepEqual(firing(edge('src/daemon/client/daemon-client.ts', 'daemon', 'platforms')), [ 'R3 platforms-seam', ]); diff --git a/scripts/layering/zone-policy.ts b/scripts/layering/zone-policy.ts index 575f1ea99..9dd446694 100644 --- a/scripts/layering/zone-policy.ts +++ b/scripts/layering/zone-policy.ts @@ -60,10 +60,9 @@ export const ZONE_POLICIES: readonly ZonePolicy[] = [ rule: 'R3 platforms-seam', to: ['platforms'], tolerates: ['type-only', 'dynamic'], - seam: ['src/core/interactors/', 'src/daemon/', 'src/sdk/'], - seamExcept: ['src/daemon/client/'], + seam: ['src/core/interactors/', 'src/sdk/'], hint: - 'Only src/core/interactors/, the daemon server and the sdk barrel may statically import ' + + 'Only src/core/interactors/ and the sdk barrel may statically import ' + 'platforms/; elsewhere use a dynamic import() or a type-only import to preserve CLI ' + 'cold-start.', }, diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index f421f058d..d7e59e6ee 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -195,6 +195,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/platform-module.ts': 5, 'packages/contracts/src/platform-plugin.ts': 1, 'packages/contracts/src/platform-providers.ts': 1, + 'packages/contracts/src/platform-resource-cleanup.ts': 1, 'packages/contracts/src/platform-runtime-host.ts': 1, 'packages/contracts/src/platform-runtime-operations.ts': 2, 'packages/contracts/src/platform-runtime-unavailable.ts': 30, diff --git a/src/daemon/__tests__/android-observation-fixture.ts b/src/daemon/__tests__/android-observation-fixture.ts index bf6bb85a5..2f3459842 100644 --- a/src/daemon/__tests__/android-observation-fixture.ts +++ b/src/daemon/__tests__/android-observation-fixture.ts @@ -24,7 +24,6 @@ export const androidObservationFixture: AndroidObservationAdapter = Object.freez readScreenSize: async (device) => await getAndroidScreenSize(device), isPermissionPackage: async (packageName) => isAndroidPermissionPackage(packageName), }); - /** Benign router default for tests that exercise locking or response shape, not Android state. */ export const clearAndroidObservationFixture = Object.freeze({ ...androidObservation, diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index 21cf59635..f24360b05 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -613,13 +613,13 @@ test('prepareLockedRequestScope preserves existing-session selector validation', leaseRegistry: new LeaseRegistry(), }); - expect(() => + await expect( prepareLockedRequestScope({ scope, sessionStore, trackDownloadableArtifact: () => 'artifact-id', }), - ).toThrow(/already bound to android device "Pixel" \(emulator-5554\).*--platform=ios/i); + ).rejects.toThrow(/already bound to android device "Pixel" \(emulator-5554\).*--platform=ios/i); }); test('prepareLockedRequestScope blocks commands for invalidated recordings before handlers run', async () => { @@ -639,12 +639,14 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor leaseRegistry: new LeaseRegistry(), }); - const result = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, async () => - prepareLockedRequestScope({ - scope, - sessionStore, - trackDownloadableArtifact: () => 'artifact-id', - }), + const result = await withDiagnosticsScope( + { command: 'snapshot', logPath: LOG_PATH }, + async () => + await prepareLockedRequestScope({ + scope, + sessionStore, + trackDownloadableArtifact: () => 'artifact-id', + }), ); expect(result.type).toBe('response'); @@ -665,7 +667,7 @@ test('prepareLockedRequestScope passes the session runner log path into handler leaseRegistry: new LeaseRegistry(), }); - const result = prepareLockedRequestScope({ + const result = await prepareLockedRequestScope({ scope, sessionStore, trackDownloadableArtifact: () => 'artifact-id', @@ -694,7 +696,7 @@ test('prepareLockedRequestScope streams ordinary diagnostics into the active tra }); await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, async () => { - const result = prepareLockedRequestScope({ + const result = await prepareLockedRequestScope({ scope, sessionStore, trackDownloadableArtifact: () => 'artifact-id', diff --git a/src/daemon/__tests__/request-recording-health.test.ts b/src/daemon/__tests__/request-recording-health.test.ts index b9ff748b8..f60c08026 100644 --- a/src/daemon/__tests__/request-recording-health.test.ts +++ b/src/daemon/__tests__/request-recording-health.test.ts @@ -40,7 +40,7 @@ function makeIosSimulatorSession(showTouches: boolean): SessionState { return session; } -test('runner-backed iOS recordings still invalidate on runner restarts', () => { +test('runner-backed iOS recordings still invalidate on runner restarts', async () => { const session = makeIosSimulatorSession(true); session.device.kind = 'device'; session.screenRecording = makeTestScreenRecordingResource(session, { @@ -53,7 +53,7 @@ test('runner-backed iOS recordings still invalidate on runner restarts', () => { sessionId: 'runner-after', }); - refreshRecordingHealth(session); + await refreshRecordingHealth(session); expect(mockGetRunnerSessionSnapshot).toHaveBeenCalledWith('sim-1'); expect(session.screenRecording?.handle.inspect().invalidatedReason).toBe( diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 86259f848..862ba4bb1 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -31,6 +31,7 @@ import { createRequestHandler, lifecycleDeviceRuntimeGateway, } from './test-device-runtime-gateway.ts'; +import { createRequestHandler as createProductionRequestHandler } from '../request-router.ts'; import { resolveRequestExecutionLockKeys } from '../request-binding.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { ensureDeviceReady } from '../device-ready.ts'; @@ -400,6 +401,43 @@ test('close releases the session lease', async () => { expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); +test('close fails synchronously when root composition omits platform resource cleanup', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + sessionStore.set('default', { + name: 'default', + device: makeIosDevice('SIM-CLOSE-MISSING-CLEANUP'), + createdAt: Date.now(), + actions: [], + }); + const handler = createProductionRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + deviceInventoryGateways: createTestDeviceInventoryGateways(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'close', + positionals: [], + meta: { requestId: 'req-close-missing-cleanup' }, + }); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toMatchObject({ + code: 'INTERNAL_ERROR', + message: 'Platform resource cleanup was not supplied by root runtime composition', + }); + } + expect(sessionStore.get('default')).toBeDefined(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); +}); + test('close rejects a different client before cleanup', async () => { const sessionStore = makeSessionStore('agent-device-router-open-'); const leaseRegistry = new LeaseRegistry(); diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index 7b46191cb..6a1803bc7 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -207,27 +207,30 @@ test('router serializes concurrent commands for the same device across sessions' const order: string[] = []; let active = 0; let maxActive = 0; - const gates: Array<() => void> = []; - const gate = async (label: string) => { + let releaseScreenshot!: () => void; + const screenshotGate = new Promise((resolve) => { + releaseScreenshot = resolve; + }); + const enter = (label: string) => { order.push(`start-${label}`); active += 1; maxActive = Math.max(maxActive, active); - await new Promise((resolve) => { - gates.push(() => { - active -= 1; - order.push(`end-${label}`); - resolve(); - }); - }); + }; + const exit = (label: string) => { + active -= 1; + order.push(`end-${label}`); }; const runtime = screenshotRuntimeFixture({ onCapture: async (input) => { writeSolidPng(input.outPath); - await gate('screenshot'); + enter('screenshot'); + await screenshotGate; + exit('screenshot'); }, onScroll: async () => { - await gate('scroll'); + enter('scroll'); + exit('scroll'); }, }); @@ -249,9 +252,12 @@ test('router serializes concurrent commands for the same device across sessions' meta: { requestId: 'req-lock-1' }, }); - await vi.waitFor(() => { - expect(order).toEqual(['start-screenshot']); - }); + await vi.waitFor( + () => { + expect(order).toEqual(['start-screenshot']); + }, + { timeout: 5_000 }, + ); const scrollRequest = handler({ token: 'test-token', @@ -264,13 +270,7 @@ test('router serializes concurrent commands for the same device across sessions' await new Promise((resolve) => setTimeout(resolve, 20)); expect(order).toEqual(['start-screenshot']); - gates.shift()?.(); - - await vi.waitFor(() => { - expect(order).toEqual(['start-screenshot', 'end-screenshot', 'start-scroll']); - }); - - gates.shift()?.(); + releaseScreenshot(); const [screenshotResponse, scrollResponse] = await Promise.all([ screenshotRequest, @@ -281,7 +281,7 @@ test('router serializes concurrent commands for the same device across sessions' expect(scrollResponse.ok).toBe(true); expect(maxActive).toBe(1); expect(order).toEqual(['start-screenshot', 'end-screenshot', 'start-scroll', 'end-scroll']); -}); +}, 15_000); test('iOS simulator screenshot response includes output dimensions and logical density metadata', async () => { const screenshotPath = path.join(os.tmpdir(), `agent-device-ios-meta-${Date.now()}.png`); diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index 993d612c6..d50147e3b 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -23,6 +23,7 @@ import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../__ import { withClientReplayScriptSources } from '../../__tests__/test-utils/replay-script-source.ts'; import type { DaemonInvokeFn } from '../types.ts'; import { clearAndroidObservationFixture } from './android-observation-fixture.ts'; +import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; const unavailable = Object.freeze({ available: false as const, @@ -260,6 +261,7 @@ export function createRequestHandler( const { deviceRuntimeGateway = unavailableDeviceRuntimeGateway, ...rest } = deps; const handle = createProductionRequestHandler({ androidObservation: clearAndroidObservationFixture, + platformResourceCleanup, ...rest, deviceRuntimeGateway, }); diff --git a/src/daemon/device-ready.ts b/src/daemon/device-ready.ts index 898e25c63..2137c227c 100644 --- a/src/daemon/device-ready.ts +++ b/src/daemon/device-ready.ts @@ -1,5 +1,5 @@ -import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; -import { resolveIosPhysicalDeviceControl } from '../platforms/apple/core/physical-device-control.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { ensureLocalPlatformDeviceReady } from '../platform-runtime-device-ready.ts'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; import { createTtlMemo } from '../utils/ttl-memo.ts'; @@ -27,26 +27,8 @@ export async function ensureDeviceReady( readyCache.delete(cacheKey); } - if (isIosFamily(device)) { - if (device.kind === 'simulator') { - const { ensureBootedSimulator } = await import('../platforms/apple/core/simulator.ts'); - await ensureBootedSimulator(device, { - deviceHub: options.deviceHub, - focusExisting: options.focusExisting, - onColdBootStart: options.onIosSimulatorColdBootStart, - }); - markDeviceReady(cacheKey); - return; - } - if (device.kind === 'device') { - await resolveIosPhysicalDeviceControl(device).ensureReady(device); - markDeviceReady(cacheKey); - return; - } - } - if (device.platform === 'android') { - const { waitForAndroidBoot } = await import('../platforms/android/emulator-lifecycle.ts'); - await waitForAndroidBoot(device.id); + const handled = await ensureLocalPlatformDeviceReady(device, options); + if (handled) { markDeviceReady(cacheKey); } } diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts b/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts index 54ec72da9..e8871a54c 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts @@ -50,7 +50,8 @@ import { mockInspectDeviceRuntimeFacts, mockShutdownTargetRuntime, } from './session-command-harness.ts'; -import { teardownSessionResources } from '../../session-teardown.ts'; +import { teardownSessionResources as teardownProductionSessionResources } from '../../session-teardown.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; import { shutdownSimulator } from '../../../platforms/apple/core/simulator.ts'; import { runCmd } from '../../../utils/exec.ts'; @@ -87,6 +88,14 @@ const mockStopAndroidSnapshotHelperSessionForDevice = vi.mocked( ); const mockStopIosRunnerSession = vi.mocked(stopIosRunnerSession); +const teardownSessionResources = ( + request: Parameters[0], +) => + teardownProductionSessionResources({ + ...request, + platformCleanup: request.platformCleanup ?? platformResourceCleanup, + }); + const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {}, diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index cad8ce5ea..0d15c3849 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -32,6 +32,7 @@ import { deviceShape, isIosFamily, type DeviceInfo } from '@agent-device/kernel/ import { beforeEach, vi } from 'vitest'; import { applicationLifecycleRuntimeFixture } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; import { withClientReplayScriptSources } from '../../../__tests__/test-utils/replay-script-source.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; const unavailable = Object.freeze({ available: false, @@ -107,6 +108,7 @@ export async function handleSessionCommands( req: await withClientReplayScriptSources(params.req), inspectFacts: params.inspectFacts ?? mockInspectDeviceRuntimeFacts, bindDevice: params.bindDevice ?? mockBindDeviceRuntime, + platformResourceCleanup: params.platformResourceCleanup ?? platformResourceCleanup, reconcileOrphanedDeviceClaim: async () => ({ status: 'retained', reason: 'test-harness-has-no-exact-owner-recovery', diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index 032cb9af1..6cecb29d0 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -60,6 +60,7 @@ import { inspectProviderLifecycleRuntimeFacts, inspectLifecycleRuntimeFacts, } from './application-lifecycle-runtime-harness.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); @@ -89,6 +90,7 @@ function handleCloseCommand( ) { return handleProductionCloseCommand({ ...params, + platformResourceCleanup, inspectFacts: inspectLifecycleRuntimeFacts, bindDevice: bindLifecycleRuntime, }); diff --git a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts index 2783e6562..a56b62ca6 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts @@ -72,6 +72,7 @@ import { bindLifecycleRuntime, inspectLifecycleRuntimeFacts, } from './application-lifecycle-runtime-harness.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; import { captureSnapshotThroughLegacyDispatchFixture, legacyDispatchCapture, @@ -85,6 +86,7 @@ function handleCloseCommand( ) { return handleProductionCloseCommand({ ...params, + platformResourceCleanup, inspectFacts: inspectLifecycleRuntimeFacts, bindDevice: bindLifecycleRuntime, }); diff --git a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts index b7e61956c..c3c4b0711 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts @@ -2,8 +2,8 @@ * ADR 0012 decision 6 "repair transaction" lifecycle fixes (Q1/Q2a/Q2b/Q2c): * proves the WHOLE chain end to end, at the layer these fixes actually live — * `runReplayScriptSource` + `handleCloseCommand` sharing a live `SessionStore`, - * exactly like an agent's separate CLI invocations against the same daemon - * session would. `sendToDaemon`'s process-level keep-alive (Fix 1's daemon + * exactly like separate CLI invocations against the same daemon. `sendToDaemon`'s + * process-level keep-alive (Fix 1's daemon * teardown guard) is a different architectural layer — a client-side process * manager, not session/script state — and is covered separately in * `src/utils/__tests__/daemon-client-lifecycle.test.ts` @@ -16,8 +16,6 @@ * abandoned close. * Fix 3: the source plan's terminal `close` is skipped while repair-armed, so * the resume completes instead of diverging on lifecycle. - * Fix 4: the publish is atomic (temp + rename) and carries the completeness - * sentinel, so a stale/partial file never blocks a later repair. */ import { test, expect, vi, beforeEach } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; @@ -93,6 +91,7 @@ import { bindLifecycleRuntime, inspectLifecycleRuntimeFacts, } from './application-lifecycle-runtime-harness.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); @@ -103,6 +102,7 @@ function handleCloseCommand( ) { return handleProductionCloseCommand({ ...params, + platformResourceCleanup, inspectFacts: inspectLifecycleRuntimeFacts, bindDevice: bindLifecycleRuntime, }); diff --git a/src/daemon/handlers/__tests__/session-test-suite-command-flag-policy.test.ts b/src/daemon/handlers/__tests__/session-test-suite-command-flag-policy.test.ts index 16b2e994c..808655329 100644 --- a/src/daemon/handlers/__tests__/session-test-suite-command-flag-policy.test.ts +++ b/src/daemon/handlers/__tests__/session-test-suite-command-flag-policy.test.ts @@ -14,6 +14,7 @@ import { handleSessionReplayCommands } from '../session-replay.ts'; import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from '../session-replay-test-policy.ts'; import { replayCommandFamily } from '../../../commands/replay/index.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; // --- ADR 0012 decision 4 / migration step 5: `--from` is replay-only --- @@ -51,6 +52,7 @@ test('test rejects raw --keep-session with INVALID_ARGS before running the suite logPath: path.join(root, 'daemon.log'), sessionStore, leaseRegistry: new LeaseRegistry(), + platformResourceCleanup, invoke, }); @@ -81,6 +83,7 @@ test('test rejects --from with INVALID_ARGS before running the suite', async () logPath: path.join(root, 'daemon.log'), sessionStore, leaseRegistry: new LeaseRegistry(), + platformResourceCleanup, invoke: async () => { throw new Error('test must not start executing when --from is rejected'); }, @@ -112,6 +115,7 @@ test('test rejects --plan-digest alone with INVALID_ARGS before running the suit logPath: path.join(root, 'daemon.log'), sessionStore, leaseRegistry: new LeaseRegistry(), + platformResourceCleanup, invoke: async () => { throw new Error('test must not start executing when --plan-digest is rejected'); }, @@ -144,6 +148,7 @@ test('test rejects --save-script with INVALID_ARGS before running the suite', as logPath: path.join(root, 'daemon.log'), sessionStore, leaseRegistry: new LeaseRegistry(), + platformResourceCleanup, invoke: async () => { throw new Error('test must not start executing when --save-script is rejected'); }, @@ -176,6 +181,7 @@ test('test rejects raw --force without --save-script before running the suite', logPath: path.join(root, 'daemon.log'), sessionStore, leaseRegistry: new LeaseRegistry(), + platformResourceCleanup, invoke, }); diff --git a/src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts b/src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts index 14a786fe4..50337082a 100644 --- a/src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts +++ b/src/daemon/handlers/__tests__/session-test-suite-command-video.test.ts @@ -23,6 +23,7 @@ import type { RecordRuntimeHandlerParams } from '../record-runtime.ts'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { ScreenRecordingLiveHandle } from '@agent-device/contracts/screen-recording-runtime'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; const recordRuntimeMocks = vi.hoisted(() => ({ handleRecordCommand: vi.fn(), @@ -268,6 +269,7 @@ test('test finalizes replay video exactly once when cancellation arrives after s logPath: path.join(root, 'daemon.log'), sessionStore, leaseRegistry: new LeaseRegistry(), + platformResourceCleanup, bindDevice: unavailableBindDevice, bindExactDevice: unavailableBindExactDevice, screenRecordingAdmissionLedger, diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 780e4f0ea..0a05f8e32 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -8,13 +8,13 @@ import { import fs from 'node:fs'; import path from 'node:path'; import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; -import { withSessionlessRunnerCleanup } from '../snapshot-session.ts'; import { captureSnapshot } from '../snapshot-capture.ts'; import { SessionStore } from '../../session-store.ts'; import { setActiveProviderDeviceRuntimes } from '../../../provider-device-runtime.ts'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { DaemonResponse, SessionState } from '../../types.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; import { buildSnapshotSignatures } from '../../../snapshot/snapshot-freshness/index.ts'; import { buildInteractionSurfaceSignature } from '../../interaction-outcome-policy.ts'; import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; @@ -40,15 +40,6 @@ vi.mock('../../../platforms/apple/core/runner-client.ts', async (importOriginal) return { ...actual, runAppleRunnerCommand: vi.fn(async () => ({})), - stopIosRunnerSession: vi.fn(async () => {}), - }; -}); - -vi.mock('../../../platforms/apple/core/apps.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - closeIosApp: vi.fn(async () => {}), }; }); @@ -60,16 +51,10 @@ vi.mock('../../ios-app-session-hint.ts', () => ({ buildIosOpenCommandHint: vi.fn(async () => undefined), })); -import { - runAppleRunnerCommand, - stopIosRunnerSession, -} from '../../../platforms/apple/core/runner-client.ts'; -import { closeIosApp } from '../../../platforms/apple/core/apps.ts'; +import { runAppleRunnerCommand } from '../../../platforms/apple/core/runner-client.ts'; import { buildIosOpenCommandHint } from '../../ios-app-session-hint.ts'; const mockRunnerCommand = vi.mocked(runAppleRunnerCommand); -const mockStopIosRunnerSession = vi.mocked(stopIosRunnerSession); -const mockCloseIosApp = vi.mocked(closeIosApp); const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); const SNAPSHOT_ROUTE_RUNTIME_COMMANDS = new Set(['snapshot', 'diff', 'settings', 'alert']); @@ -77,15 +62,18 @@ const SNAPSHOT_ROUTE_RUNTIME_COMMANDS = new Set(['snapshot', 'diff', 'settings', function handleSnapshotCommands( params: Parameters[0], ): ReturnType { - // R58/R59 bound `settings` and `alert` too, so the fixture serves those commands as well. if (!SNAPSHOT_ROUTE_RUNTIME_COMMANDS.has(params.req.command)) { - return handleProductionSnapshotCommands(params); + return handleProductionSnapshotCommands({ + ...params, + platformResourceCleanup: params.platformResourceCleanup ?? platformResourceCleanup, + }); } const runtime = snapshotRuntimeFixture(params.req.meta?.requestId); return handleProductionSnapshotCommands({ ...params, inspectFacts: params.inspectFacts ?? runtime.inspectFacts, bindDevice: params.bindDevice ?? runtime.bindDevice, + platformResourceCleanup: params.platformResourceCleanup ?? platformResourceCleanup, }); } @@ -161,10 +149,6 @@ beforeEach(() => { resetGetRuntimeFixture(); mockRunnerCommand.mockReset(); mockRunnerCommand.mockResolvedValue({}); - mockStopIosRunnerSession.mockReset(); - mockStopIosRunnerSession.mockResolvedValue(); - mockCloseIosApp.mockReset(); - mockCloseIosApp.mockResolvedValue(); mockBuildIosOpenCommandHint.mockReset(); mockBuildIosOpenCommandHint.mockResolvedValue(undefined); }); @@ -2256,29 +2240,3 @@ test('wait sleep bypasses sessionless runner cleanup wrapper', async () => { expect(response).toBeTruthy(); expect(response?.ok).toBe(true); }); - -const returnOk = async () => 'ok'; - -test('sessionless iOS runner cleanup stops the runner host app', async () => { - const result = await withSessionlessRunnerCleanup(undefined, iosSimulatorDevice, returnOk); - - expect(result).toBe('ok'); - expect(mockStopIosRunnerSession).toHaveBeenCalledWith(iosSimulatorDevice.id); - expect(mockCloseIosApp).toHaveBeenCalledWith( - iosSimulatorDevice, - 'com.callstack.agentdevice.runner', - ); -}); - -test('sessionless iOS runner host close is best effort', async () => { - mockCloseIosApp.mockRejectedValueOnce(new Error('terminate failed')); - - const result = await withSessionlessRunnerCleanup(undefined, iosSimulatorDevice, returnOk); - - expect(result).toBe('ok'); - expect(mockStopIosRunnerSession).toHaveBeenCalledWith(iosSimulatorDevice.id); - expect(mockCloseIosApp).toHaveBeenCalledWith( - iosSimulatorDevice, - 'com.callstack.agentdevice.runner', - ); -}); diff --git a/src/daemon/handlers/__tests__/snapshot-session-cleanup.test.ts b/src/daemon/handlers/__tests__/snapshot-session-cleanup.test.ts new file mode 100644 index 000000000..2c5a85c32 --- /dev/null +++ b/src/daemon/handlers/__tests__/snapshot-session-cleanup.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('../../../platforms/apple/core/runner-client.ts', async (importOriginal) => ({ + ...(await importOriginal()), + stopIosRunnerSession: vi.fn(async () => {}), +})); +vi.mock('../../../platforms/apple/core/apps.ts', async (importOriginal) => ({ + ...(await importOriginal()), + closeIosApp: vi.fn(async () => {}), +})); + +import { withSessionlessRunnerCleanup } from '../snapshot-session.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; +import { stopIosRunnerSession } from '../../../platforms/apple/core/runner-client.ts'; +import { closeIosApp } from '../../../platforms/apple/core/apps.ts'; +import { IOS_SIMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; + +const mockStopIosRunnerSession = vi.mocked(stopIosRunnerSession); +const mockCloseIosApp = vi.mocked(closeIosApp); +const returnOk = async () => 'ok'; + +beforeEach(() => { + mockStopIosRunnerSession.mockReset().mockResolvedValue(); + mockCloseIosApp.mockReset().mockResolvedValue(); +}); + +test('sessionless iOS runner cleanup stops the runner host app', async () => { + const result = await withSessionlessRunnerCleanup( + undefined, + IOS_SIMULATOR, + returnOk, + platformResourceCleanup, + ); + expect(result).toBe('ok'); + expect(mockStopIosRunnerSession).toHaveBeenCalledWith(IOS_SIMULATOR.id); + expect(mockCloseIosApp).toHaveBeenCalledWith(IOS_SIMULATOR, 'com.callstack.agentdevice.runner'); +}); + +test('sessionless iOS runner host close is best effort', async () => { + mockCloseIosApp.mockRejectedValueOnce(new Error('terminate failed')); + const result = await withSessionlessRunnerCleanup( + undefined, + IOS_SIMULATOR, + returnOk, + platformResourceCleanup, + ); + expect(result).toBe('ok'); + expect(mockStopIosRunnerSession).toHaveBeenCalledWith(IOS_SIMULATOR.id); + expect(mockCloseIosApp).toHaveBeenCalledWith(IOS_SIMULATOR, 'com.callstack.agentdevice.runner'); +}); diff --git a/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts b/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts index 09454c4b3..8747e66c5 100644 --- a/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts +++ b/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts @@ -8,6 +8,7 @@ import { ANDROID_SYSTEM_SURFACE_DISCLOSURE } from '../../../core/android-system- import { snapshotRuntimeFixture } from '../../__tests__/snapshot-runtime-fixture.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -132,6 +133,7 @@ test('wait timeout for app text hidden behind a system surface discloses the occ sessionName: 'default', logPath: '/tmp/test.log', sessionStore, + platformResourceCleanup, ...snapshotRuntimeFixture(), }); @@ -217,6 +219,7 @@ test('sessionless wait success on shade content still discloses the occluding sy sessionName: 'default', logPath: '/tmp/test.log', sessionStore, + platformResourceCleanup, ...snapshotRuntimeFixture(), }); @@ -243,6 +246,7 @@ test('sessionless wait timeout still discloses the occluding system surface', as sessionName: 'default', logPath: '/tmp/test.log', sessionStore, + platformResourceCleanup, ...snapshotRuntimeFixture(), }); diff --git a/src/daemon/handlers/interaction-common.ts b/src/daemon/handlers/interaction-common.ts index f517ade7f..e4442648a 100644 --- a/src/daemon/handlers/interaction-common.ts +++ b/src/daemon/handlers/interaction-common.ts @@ -14,6 +14,7 @@ import { parameterizeRecordedFillPayload } from '../parameterized-recorded-fill. import { isSessionRecording } from '../session-script-publication-capability.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; export type ContextFromFlags = ( flags: CommandFlags | undefined, @@ -30,6 +31,7 @@ export type InteractionHandlerParams = { inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; androidObservation?: AndroidObservationAdapter; + platformResourceCleanup?: PlatformResourceCleanup; }; export function finalizeTouchInteraction(params: { diff --git a/src/daemon/handlers/session-close-lifecycle-teardown.ts b/src/daemon/handlers/session-close-lifecycle-teardown.ts index c4afd49fe..095c3e4ec 100644 --- a/src/daemon/handlers/session-close-lifecycle-teardown.ts +++ b/src/daemon/handlers/session-close-lifecycle-teardown.ts @@ -7,13 +7,13 @@ import { reportSessionCleanupFailures, finishSessionAudioProbe, finishSessionScreenRecording, - stopSessionAndroidSnapshotHelper, + stopSessionSnapshotHelper, stopSessionAppLog, stopSessionPerfCapture, type SessionCleanupFailure, } from '../session-teardown.ts'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; import { hasRuntimeTransportHints, runtimeHintValues } from './session-runtime.ts'; -import { isIosSimulator } from './session-device-utils.ts'; import type { CloseRuntime, CloseRuntimeWithRuntimeHintClear, @@ -51,6 +51,7 @@ export async function runSessionCloseTeardown(params: { sessionStore: SessionStore; platformCloseError: unknown; }): Error | undefined; + platformResourceCleanup: PlatformResourceCleanup; }): Promise { const { req, @@ -76,9 +77,19 @@ export async function runSessionCloseTeardown(params: { return undefined; } }; - const retainAppleRunner = shouldRetainAppleRunnerAfterClose(req, session); + const retainExecutionHost = params.platformResourceCleanup.retainExecutionHostAfterClose({ + device: session.device, + shutdownRequested: req.flags?.shutdown === true, + hasScreenRecording: Boolean(session.screenRecording), + hasLease: Boolean(session.lease), + }); const configuredRuntimeHints = sessionStore.getRuntimeHints(sessionName); - await stopBestEffortSessionResources(session, sessionStore, attemptCleanup); + await stopBestEffortSessionResources( + session, + sessionStore, + attemptCleanup, + params.platformResourceCleanup, + ); const platformCloseError = repairArmed ? undefined : await dispatchTargetedPlatformClose({ req, session, logPath, lifecycle }); @@ -100,7 +111,7 @@ export async function runSessionCloseTeardown(params: { (await lifecycle.operations.finalizeApplicationClose({ appBundleId: session.appBundleId, surface: session.surface ?? 'app', - retainRunner: retainAppleRunner, + retainRunner: retainExecutionHost, stateDir: sessionStore.resolveDaemonStateDir(), shutdownTarget: req.flags?.shutdown === true, })) ?? {}, @@ -114,22 +125,13 @@ export async function runSessionCloseTeardown(params: { return { platformCloseError, saveScriptError, shutdownResult: finalization?.shutdown }; } -function shouldRetainAppleRunnerAfterClose(req: DaemonRequest, session: SessionState): boolean { - return ( - isIosSimulator(session.device) && - !req.flags?.shutdown && - !session.screenRecording && - !session.lease && - !session.device.simulatorSetPath - ); -} - type CleanupRunner = (step: string, run: () => Promise) => Promise; async function stopBestEffortSessionResources( session: SessionState, sessionStore: SessionStore, attemptCleanup: CleanupRunner, + platformCleanup: PlatformResourceCleanup, ): Promise { // Recording overlay finalization needs the Apple runner. const currentSession = sessionStore.get(session.name) ?? session; @@ -151,7 +153,9 @@ async function stopBestEffortSessionResources( await attemptCleanup('perf_capture', () => stopSessionPerfCapture({ session, sessionName: session.name, sessionStore }), ); - await attemptCleanup('android_snapshot_helper', () => stopSessionAndroidSnapshotHelper(session)); + await attemptCleanup('platform_snapshot_helper', () => + stopSessionSnapshotHelper(session, platformCleanup), + ); } export function closeCleanupError( diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index c6223c180..0acbc6a86 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -15,7 +15,8 @@ import { recordRepairPlatformClose, } from '../session-replay-transaction.ts'; import { isAuthoringArmedSession } from '../session-script-publication-capability.ts'; -import { isWebSession, type SessionCleanupFailure } from '../session-teardown.ts'; +import type { SessionCleanupFailure } from '../session-teardown.ts'; +import { isWebSession } from '../web-session-names.ts'; import { clearDeviceClaim } from '../device-claims.ts'; import { applicationLifecycleExecutionFromRequest } from '../application-lifecycle-execution.ts'; import { hasRuntimeTransportHints } from './session-runtime.ts'; @@ -32,6 +33,7 @@ import { type RuntimeHintClearOperation, } from './session-close-runtime-admission.ts'; import { closeCleanupError, runSessionCloseTeardown } from './session-close-lifecycle-teardown.ts'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; function toRepairPlatformCloseFailure(error: unknown): AppError { if (error instanceof AppError) return error; @@ -41,6 +43,18 @@ function toRepairPlatformCloseFailure(error: unknown): AppError { }); } +function requirePlatformCleanup( + cleanup: PlatformResourceCleanup | undefined, +): PlatformResourceCleanup { + if (!cleanup) { + throw new AppError( + 'INTERNAL_ERROR', + 'Platform resource cleanup was not supplied by root runtime composition', + ); + } + return cleanup; +} + function buildRepairPlatformCloseReceipt(req: DaemonRequest): string { return JSON.stringify(req.positionals ?? []); } @@ -181,6 +195,7 @@ export async function handleCloseCommand(params: { leaseLifecycleProvider?: LeaseLifecycleProvider; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; + platformResourceCleanup?: PlatformResourceCleanup; }): Promise { const { req, sessionName, logPath, sessionStore, leaseRegistry, leaseLifecycleProvider } = params; const session = sessionStore.get(sessionName); @@ -196,6 +211,7 @@ export async function handleCloseCommand(params: { if (req.internal?.closeAppOnly === true && !req.positionals?.[0]) { return errorResponse('INVALID_ARGS', 'App-only close requires an app target'); } + const platformResourceCleanup = requirePlatformCleanup(params.platformResourceCleanup); const admission = await admitCloseRuntime({ device: session.device, clearRuntimeHints: @@ -236,6 +252,7 @@ export async function handleCloseCommand(params: { lifecycle: admission.runtime, clearRuntimeHints: admission.clearRuntimeHints, repairArmed: repair.repairArmed, + platformResourceCleanup, }); if (closed.kind === 'response') return closed.response; return buildCloseSuccessResponse({ @@ -270,6 +287,7 @@ async function runCloseTeardownAndRelease(params: { lifecycle: CloseRuntime | CloseRuntimeWithRuntimeHintClear; clearRuntimeHints?: RuntimeHintClearOperation; repairArmed: boolean; + platformResourceCleanup: PlatformResourceCleanup; }): Promise { const { req, @@ -295,6 +313,7 @@ async function runCloseTeardownAndRelease(params: { repairArmed: params.repairArmed, dispatchTargetedPlatformClose, finalizeOrdinaryCloseScript, + platformResourceCleanup: params.platformResourceCleanup, }); const leaseRelease = await releaseProviderLeaseForClose({ session, diff --git a/src/daemon/handlers/session-device-utils.ts b/src/daemon/handlers/session-device-utils.ts index 9b4b32006..0f53e818e 100644 --- a/src/daemon/handlers/session-device-utils.ts +++ b/src/daemon/handlers/session-device-utils.ts @@ -2,16 +2,13 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { isActiveProviderDevice } from '../../provider-device-runtime.ts'; import { ensureDeviceReady } from '../device-ready.ts'; -import { getRunnerSessionSnapshot } from '../../platforms/apple/core/runner-client.ts'; +import { inspectAppleRunnerSession } from '../../platform-runtime-apple-resources.ts'; import { resolveTargetDevice } from '../../core/dispatch-resolve.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { hasDeviceSelectionInput, hasExplicitDeviceSelector } from '../device-selector-intent.ts'; import { listSessionSelectorConflicts } from '../session-selector.ts'; -import { isIosSimulator } from '../device-targets.ts'; import { errorResponse } from './response.ts'; -export { isIosSimulator }; - export function requireSessionOrExplicitSelector( command: string, session: SessionState | undefined, @@ -62,7 +59,7 @@ export async function refreshSessionDeviceIfNeeded(device: DeviceInfo): Promise< // A live XCUITest runner session is attached to this exact UDID, which // proves the simulator still exists and is booted — the two facts the // ~0.7s re-resolve inventory listing exists to establish. - if (getRunnerSessionSnapshot(device.id)?.alive) { + if ((await inspectAppleRunnerSession(device.id))?.alive) { return { ...device, booted: true }; } diff --git a/src/daemon/handlers/session-test-suite-command.ts b/src/daemon/handlers/session-test-suite-command.ts index 148de23d3..bde021901 100644 --- a/src/daemon/handlers/session-test-suite-command.ts +++ b/src/daemon/handlers/session-test-suite-command.ts @@ -53,6 +53,7 @@ import { startReplayTestVideoRecordingIfReady, } from './session-replay-video-recording.ts'; import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from './session-replay-test-policy.ts'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; /** * Binds one replay-test attempt to daemon request cancellation (#1478 P3b). @@ -158,12 +159,20 @@ export type ReplayTestSuiteCommandParams = { requestScope?: PlatformRequestScope; retainDeviceExecutionLock?: (deviceId: string) => Promise; throwIfCanceled?: () => void; + platformResourceCleanup?: PlatformResourceCleanup; }; export async function runReplayTestSuiteCommand( params: ReplayTestSuiteCommandParams, ): Promise { const { req, sessionName, logPath, sessionStore, leaseRegistry, invoke } = params; + if (!params.platformResourceCleanup) { + throw new AppError( + 'INTERNAL_ERROR', + 'Platform resource cleanup was not supplied by root runtime composition', + ); + } + const platformResourceCleanup = params.platformResourceCleanup; const replayVideoRuntime = resolveReplayVideoRuntime(params); if (req.flags?.recordVideo === true && replayVideoRuntime === undefined) { return errorResponse( @@ -319,6 +328,7 @@ export async function runReplayTestSuiteCommand( leaseRegistry, inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, + platformResourceCleanup, }); if (!closeResponse.ok) { throw new AppError(closeResponse.error.code, closeResponse.error.message, { diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 978ca5000..3a092a80f 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -32,6 +32,7 @@ import type { PerfCaptureAdmissionLedger } from '../perf-capture-admission-ledge import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; export type SessionCommandInput = { req: DaemonRequest; @@ -60,6 +61,7 @@ export type SessionCommandInput = { retainDeviceExecutionLock?: (deviceId: string) => Promise; throwIfCanceled?: () => void; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; + platformResourceCleanup?: PlatformResourceCleanup; }; type SessionCommandParams = Omit & { @@ -136,6 +138,7 @@ const handleSessionReplayCommandGroup: SessionCommandHandler = async ({ requestScope, retainDeviceExecutionLock, throwIfCanceled, + platformResourceCleanup, }) => await handleSessionReplayCommands({ req, @@ -151,6 +154,7 @@ const handleSessionReplayCommandGroup: SessionCommandHandler = async ({ requestScope, retainDeviceExecutionLock, throwIfCanceled, + platformResourceCleanup, }); /** @@ -272,6 +276,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { leaseLifecycleProvider, inspectFacts, bindDevice, + platformResourceCleanup, }) => await handleCloseCommand({ req, @@ -282,6 +287,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { leaseLifecycleProvider, inspectFacts, bindDevice, + platformResourceCleanup, }), } satisfies Record; @@ -310,6 +316,7 @@ export async function handleSessionCommands( retainDeviceExecutionLock, throwIfCanceled, reconcileOrphanedDeviceClaim, + platformResourceCleanup, } = params; const handler = @@ -338,5 +345,6 @@ export async function handleSessionCommands( retainDeviceExecutionLock, throwIfCanceled, reconcileOrphanedDeviceClaim, + platformResourceCleanup, }); } diff --git a/src/daemon/handlers/snapshot-session.ts b/src/daemon/handlers/snapshot-session.ts index d2e2a04ac..e93410713 100644 --- a/src/daemon/handlers/snapshot-session.ts +++ b/src/daemon/handlers/snapshot-session.ts @@ -1,11 +1,5 @@ -import { isIosFamily } from '@agent-device/kernel/device'; import { resolveTargetDevice } from '../../core/dispatch-resolve.ts'; -import { - resolveRunnerAppBundleId, - stopIosRunnerSession, -} from '../../platforms/apple/core/runner-client.ts'; -import { closeIosApp } from '../../platforms/apple/core/apps.ts'; -import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; import type { DaemonRequest, SessionState } from '../types.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { SessionStore } from '../session-store.ts'; @@ -25,35 +19,18 @@ export async function withSessionlessRunnerCleanup( session: SessionState | undefined, device: SessionState['device'], task: () => Promise, + platformCleanup?: PlatformResourceCleanup, ): Promise { - const shouldCleanupSessionlessIosRunner = !session && isIosFamily(device); + if (!session && !platformCleanup) { + throw new Error('Platform resource cleanup was not injected'); + } try { return await task(); } finally { - // Sessionless iOS commands intentionally stop the runner to avoid leaked xcodebuild processes. - // For multi-command flows, keep an active session via `open` so the runner can be reused. - if (shouldCleanupSessionlessIosRunner) { - await stopIosRunnerSession(device.id); - await closeSessionlessIosRunnerHostApp(device); - } + if (!session) await platformCleanup!.cleanupSessionlessExecutionHost(device); } } -async function closeSessionlessIosRunnerHostApp(device: SessionState['device']): Promise { - const bundleId = resolveRunnerAppBundleId(); - await closeIosApp(device, bundleId).catch((error) => { - emitDiagnostic({ - level: 'debug', - phase: 'ios_sessionless_runner_host_close_failed', - data: { - deviceId: device.id, - bundleId, - error: error instanceof Error ? error.message : String(error), - }, - }); - }); -} - export function recordIfSession( sessionStore: SessionStore, session: SessionState | undefined, diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index b92ba8e43..15ef4d186 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -8,6 +8,7 @@ import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; import { dispatchWaitViaRuntime } from '../selector-runtime.ts'; import { resolveSessionDevice, withSessionlessRunnerCleanup } from './snapshot-session.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; type SnapshotCommandParams = { req: DaemonRequest; @@ -16,12 +17,21 @@ type SnapshotCommandParams = { sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; + platformResourceCleanup?: PlatformResourceCleanup; }; type SnapshotCommandHandler = (params: SnapshotCommandParams) => Promise; const SNAPSHOT_COMMAND_HANDLER_IMPLS = { - snapshot: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => + snapshot: async ({ + req, + sessionName, + logPath, + sessionStore, + inspectFacts, + bindDevice, + platformResourceCleanup, + }) => await dispatchSnapshotViaRuntime({ req, sessionName, @@ -29,8 +39,17 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformResourceCleanup, }), - diff: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => { + diff: async ({ + req, + sessionName, + logPath, + sessionStore, + inspectFacts, + bindDevice, + platformResourceCleanup, + }) => { if (req.positionals?.[0] !== 'snapshot') { return errorResponse('INVALID_ARGS', 'diff currently supports only: diff snapshot'); } @@ -41,9 +60,18 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformResourceCleanup, }); }, - wait: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => + wait: async ({ + req, + sessionName, + logPath, + sessionStore, + inspectFacts, + bindDevice, + platformResourceCleanup, + }) => await dispatchWaitViaRuntime({ req, sessionName, @@ -51,37 +79,64 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformResourceCleanup, }), - alert: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => { + alert: async ({ + req, + sessionName, + logPath, + sessionStore, + inspectFacts, + bindDevice, + platformResourceCleanup, + }) => { const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); - return await withSessionlessRunnerCleanup(session, device, async () => { - return await handleAlertCommand({ - req, - logPath, - sessionStore, - session, - device, - inspectFacts, - bindDevice, - }); - }); + return await withSessionlessRunnerCleanup( + session, + device, + async () => { + return await handleAlertCommand({ + req, + logPath, + sessionStore, + session, + device, + inspectFacts, + bindDevice, + }); + }, + platformResourceCleanup, + ); }, - settings: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => { + settings: async ({ + req, + sessionName, + logPath, + sessionStore, + inspectFacts, + bindDevice, + platformResourceCleanup, + }) => { const parsedSettings = parseSettingsArgs(req); if (!parsedSettings.ok) return parsedSettings; const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); - return await withSessionlessRunnerCleanup(session, device, async () => { - return await handleSettingsCommand({ - req, - logPath, - sessionStore, - session, - device, - parsed: parsedSettings.parsed, - inspectFacts, - bindDevice, - }); - }); + return await withSessionlessRunnerCleanup( + session, + device, + async () => { + return await handleSettingsCommand({ + req, + logPath, + sessionStore, + session, + device, + parsed: parsedSettings.parsed, + inspectFacts, + bindDevice, + }); + }, + platformResourceCleanup, + ); }, } satisfies Record; diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 8850d4a0c..a04a90e4c 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -57,6 +57,7 @@ import { import { createDeviceClaimAdmission, type DeviceClaimAdmission } from './device-claim-admission.ts'; import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; import { resolveCommandDeviceClaimPolicy } from '../core/command-descriptor/registry.ts'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. @@ -114,6 +115,7 @@ export async function createRequestExecutionScope(params: { leaseRegistry: LeaseRegistry; deviceRuntimeGateway?: DeviceRuntimeGateway; platformRequestScope?: PlatformRequestScope; + platformResourceCleanup?: PlatformResourceCleanup; }): Promise { const { sessionStore, leaseRegistry } = params; let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); @@ -225,6 +227,7 @@ export async function createRequestExecutionScope(params: { sessionStore, inspectFacts: scope.inspectFacts, bindDevice: scope.bindDevice, + platformCleanup: requirePlatformCleanup(params.platformResourceCleanup), }), }); scopedReq = admitRequestLeaseForLockedScope({ @@ -332,8 +335,9 @@ async function teardownExpiredSession(params: { sessionStore: SessionStore; inspectFacts: InspectDeviceRuntimeFacts; bindDevice: BindDeviceRuntime; + platformCleanup: PlatformResourceCleanup; }): Promise { - const { session, sessionName, sessionStore, inspectFacts, bindDevice } = params; + const { session, sessionName, sessionStore, inspectFacts, bindDevice, platformCleanup } = params; let primaryError: unknown; try { await teardownSessionResources({ @@ -341,6 +345,7 @@ async function teardownExpiredSession(params: { session, sessionName, sessionStore, + platformCleanup, }); } catch (error) { primaryError = error; @@ -371,6 +376,18 @@ async function teardownExpiredSession(params: { if (primaryError !== undefined) throw primaryError; } +function requirePlatformCleanup( + cleanup: PlatformResourceCleanup | undefined, +): PlatformResourceCleanup { + if (!cleanup) { + throw new AppError( + 'INTERNAL_ERROR', + 'Platform resource cleanup was not supplied by root runtime composition', + ); + } + return cleanup; +} + function applyRequestCommandDefaults(req: DaemonRequest): DaemonRequest { const flags = { ...(req.flags ?? {}) }; const changed = applyCommandDefaults(req.command, flags); @@ -381,7 +398,7 @@ function applyRequestCommandDefaults(req: DaemonRequest): DaemonRequest { }; } -export function prepareLockedRequestScope(params: { +export async function prepareLockedRequestScope(params: { scope: RequestExecutionScope; sessionStore: SessionStore; trackDownloadableArtifact: (opts: { @@ -390,14 +407,14 @@ export function prepareLockedRequestScope(params: { artifactType: DaemonArtifactType | undefined; fileName?: string; }) => string; -}): LockedRequestScopeResult { +}): Promise { const { scope, sessionStore, trackDownloadableArtifact } = params; const logPath = scope.runnerLogPath; scope.throwIfCanceled(); const seededSession = sessionStore.get(scope.sessionName); if (seededSession) { // Called under runLocked: refreshRecordingHealth may mutate session recording state. - refreshRecordingHealth(seededSession); + await refreshRecordingHealth(seededSession); sessionStore.set(scope.sessionName, seededSession); } const binding = prepareLockedRequestBinding({ diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 06338265f..fff4e4211 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -22,6 +22,7 @@ import type { ScreenRecordingAdmissionLedger } from './screen-recording-admissio import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { RequestPlatformProviderScope } from '@agent-device/contracts/platform-providers'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; type RequestHandlerChainParams = { req: DaemonRequest; @@ -43,6 +44,7 @@ type RequestHandlerChainParams = { */ providerScope: RequestPlatformProviderScope; androidObservation?: AndroidObservationAdapter; + platformResourceCleanup?: PlatformResourceCleanup; bindDevice: BindDeviceRuntime; inspectFacts: InspectDeviceRuntimeFacts; bindExactDevice: BindExactDeviceRuntime; @@ -161,6 +163,7 @@ async function runSessionHandler( requestScope: params.requestScope, retainDeviceExecutionLock: params.retainDeviceExecutionLock, throwIfCanceled: params.throwIfCanceled, + platformResourceCleanup: params.platformResourceCleanup, }), ); } @@ -179,6 +182,7 @@ async function runSnapshotHandler( sessionStore: params.sessionStore, inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, + platformResourceCleanup: params.platformResourceCleanup, }), ); } @@ -262,6 +266,7 @@ async function runInteractionHandler( inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, androidObservation: params.androidObservation, + platformResourceCleanup: params.platformResourceCleanup, }), ); } diff --git a/src/daemon/request-recording-health.ts b/src/daemon/request-recording-health.ts index abecc460e..c00a1ed6e 100644 --- a/src/daemon/request-recording-health.ts +++ b/src/daemon/request-recording-health.ts @@ -1,15 +1,15 @@ import { isIosFamily } from '@agent-device/kernel/device'; -import { getRunnerSessionSnapshot } from '../platforms/apple/core/runner-client.ts'; +import { inspectAppleRunnerSession } from '../platform-runtime-apple-resources.ts'; import type { SessionState } from './types.ts'; -export function refreshRecordingHealth(session: SessionState): void { +export async function refreshRecordingHealth(session: SessionState): Promise { if (!recordingRequiresRunnerHealth(session)) { return; } const recording = session.screenRecording!.handle; const state = recording.inspect(); - const snapshot = getRunnerSessionSnapshot(session.device.id); + const snapshot = await inspectAppleRunnerSession(session.device.id); if (!state.runnerSessionId) { if (snapshot?.alive) { recording.setRunnerSessionId(snapshot.sessionId); diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index ab1985b80..d2c38712a 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -45,7 +45,7 @@ import { } from './request-execution-scope.ts'; import { unsupportedSaveScriptFlagResponse } from './request-save-script-policy.ts'; import { canRunReplayScopedAction } from './daemon-command-registry.ts'; -import { isWebSession } from './session-teardown.ts'; +import { isWebSession } from './web-session-names.ts'; import { inferFillText } from './action-utils.ts'; import { createPlatformRequestScope } from './platform-request-scope.ts'; import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; @@ -68,6 +68,7 @@ import { } from './screen-recording-admission-ledger.ts'; import { resolveGenericRuntimeExecution } from './generic-runtime-execution.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; // --------------------------------------------------------------------------- // Request handler API @@ -91,6 +92,7 @@ export type RequestRouterDeps = { leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; androidObservation?: AndroidObservationAdapter; + platformResourceCleanup?: PlatformResourceCleanup; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; trackDownloadableArtifact: (opts: { artifactPath: string; @@ -111,6 +113,28 @@ const unavailableAndroidObservation = new Proxy({} as AndroidObservationAdapter, }, }); +function missingPlatformResourceCleanup(): AppError { + return new AppError( + 'INTERNAL_ERROR', + 'Platform resource cleanup was not supplied by root runtime composition', + ); +} + +const unavailablePlatformResourceCleanup: PlatformResourceCleanup = Object.freeze({ + stopSnapshotHelper: async () => { + throw missingPlatformResourceCleanup(); + }, + closeManagedBrowser: async () => { + throw missingPlatformResourceCleanup(); + }, + cleanupSessionlessExecutionHost: async () => { + throw missingPlatformResourceCleanup(); + }, + retainExecutionHostAfterClose: () => { + throw missingPlatformResourceCleanup(); + }, +}); + export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { const { logPath, @@ -128,6 +152,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseLifecycleProvider, cloudArtifactProvider, androidObservation = unavailableAndroidObservation, + platformResourceCleanup = unavailablePlatformResourceCleanup, providerDeviceRuntimeScope, trackDownloadableArtifact, } = deps; @@ -196,6 +221,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseRegistry, deviceRuntimeGateway, platformRequestScope, + platformResourceCleanup, }); return await executeRequestScope(scope); }), @@ -211,7 +237,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { inheritedProviderScope?: RequestPlatformProviderScope, ): Promise { const run = async (): Promise => { - const locked = prepareLockedRequestScope({ + const locked = await prepareLockedRequestScope({ scope, sessionStore, trackDownloadableArtifact, @@ -271,6 +297,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { : undefined, providerScope, androidObservation, + platformResourceCleanup, bindDevice: lockedScope.bindDevice, inspectFacts: lockedScope.inspectFacts, bindExactDevice: lockedScope.bindExactDevice, @@ -319,6 +346,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseRegistry, deviceRuntimeGateway, platformRequestScope: createPlatformRequestScope(scopedReq), + platformResourceCleanup, }); // The outer replay keeps its stable session lock plus the device lock // from the first device binding through response projection and ref diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index e86ad3473..627c9f75b 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -26,6 +26,7 @@ import { } from './selector-capture-binding.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; import { getRequestSignal } from '../request/cancel.ts'; import { snapshotOptionsToFlags } from '../backend-snapshot-options.ts'; @@ -42,6 +43,7 @@ export type SelectorRuntimeParams = { inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; androidObservation?: AndroidObservationAdapter; + platformResourceCleanup?: PlatformResourceCleanup; }; export type SelectorRuntimeDeviceParams = SelectorRuntimeParams & { diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index b52c72158..0a83f241a 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -3,7 +3,7 @@ import { parseWaitPositionals } from '../core/wait-positionals.ts'; import type { WaitParsed } from '../core/wait-positionals.ts'; import { AppError, asAppError } from '@agent-device/kernel/errors'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; -import { queryAppleRunnerSelector } from '../platforms/apple/core/runner-selector-query.ts'; +import { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts'; import type { AppleRunnerRequestOptions } from './apple-runner-options.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { errorResponse } from './handlers/response.ts'; @@ -353,7 +353,7 @@ export async function dispatchWaitViaRuntime( // Both a satisfied wait and a timeout consumed the polled capture stored on the session: // when it is an occluding system surface, the outcome must disclose the occlusion. return withSystemSurfaceDisclosure( - await withSessionlessRunnerCleanup(session, device, execute), + await withSessionlessRunnerCleanup(session, device, execute, params.platformResourceCleanup), consumedSessionSnapshot(params), ); } @@ -381,7 +381,7 @@ export async function queryDirectIosSelector( selector: Pick, requestOptions: AppleRunnerRequestOptions, ): Promise { - const data = await queryAppleRunnerSelector( + const data = await queryAppleRuntimeSelector( session.device, selector, session.appBundleId, diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 6e5ed266e..c28c38fe1 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -20,7 +20,7 @@ import { LeaseRegistry } from '../lease-registry.ts'; import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; import { clearDaemonShutdownReport, writeDaemonShutdownReport } from '../daemon-shutdown-report.ts'; import { createRequestHandler } from '../request-router.ts'; -import { isWebSession, stopSessionAppLog, teardownSessionResources } from '../session-teardown.ts'; +import { stopSessionAppLog, teardownSessionResources } from '../session-teardown.ts'; import { finalizeDaemonSessionApplicationLifecycle } from '../application-lifecycle-recovery.ts'; import { runtimeHintValues } from '../handlers/session-runtime.ts'; import { closeDaemonServers } from './server-shutdown.ts'; @@ -37,7 +37,6 @@ import { withDiagnosticsScope, } from '../../utils/diagnostics.ts'; import { isEnvTruthy } from '../../utils/retry.ts'; -import { resetAndroidSnapshotHelperSessions } from '../../platforms/android/snapshot-helper.ts'; import { acquireDaemonLock, parseIntegerEnv, @@ -56,10 +55,13 @@ import { } from './transport.ts'; import { prewarmPngWorker, terminatePngWorker } from '../../utils/png-worker-client.ts'; import { sleep } from '../../utils/timeouts.ts'; -import { setRunnerLeaseOwnerStateDir } from '../../platforms/apple/core/runner-owner-state.ts'; -import { cleanupManagedAgentBrowserOrphans } from '../../platforms/web/agent-browser-lifecycle.ts'; -import { getManagedAgentBrowserStatus } from '../../platforms/web/agent-browser-tool.ts'; -import { openWebSessionNames } from '../web-session-names.ts'; +import { configureAppleRunnerLeaseOwnerStateDir } from '../../platform-runtime-apple-runner-owner.ts'; +import { + cleanupManagedWebRuntimeOrphans, + platformResourceCleanup, + resetAndroidSnapshotHelperRuntime, +} from '../../platform-runtime-resource-cleanup.ts'; +import { isWebSession, openWebSessionNames } from '../web-session-names.ts'; import { recoverAppLogResourcesAfterDaemonLock, type AppLogRecoveryDiagnostic, @@ -173,6 +175,7 @@ export async function teardownDaemonSessionForShutdown(params: { sessionName, sessionStore, stateDir, + platformCleanup: platformResourceCleanup, }), }); const lifecycleTeardownSucceeded = finalizeApplicationLifecycle @@ -248,7 +251,7 @@ export async function startDaemonRuntime( const { baseDir, infoPath, lockPath, logPath, sessionsDir } = daemonPaths; const daemonServerMode = resolveDaemonServerMode(env.AGENT_DEVICE_DAEMON_SERVER_MODE); const retainArtifacts = isEnvTruthy(env.AGENT_DEVICE_RETAIN_ARTIFACTS); - setRunnerLeaseOwnerStateDir(baseDir); + await configureAppleRunnerLeaseOwnerStateDir(baseDir); const sessionStore = new SessionStore(sessionsDir); const ownedProcessRecords = createOwnedProcessRecordStore({ @@ -337,6 +340,7 @@ export async function startDaemonRuntime( screenRecordingAdmissionLedger, requestPlatformProviders, androidObservation, + platformResourceCleanup, providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, @@ -481,7 +485,7 @@ export async function startDaemonRuntime( }; if (!acquireDaemonLock(baseDir, lockPath, lockData)) { stderr.write('Daemon lock is held by another process; exiting.\n'); - setRunnerLeaseOwnerStateDir(undefined); + await configureAppleRunnerLeaseOwnerStateDir(undefined); exit(0); return null; } @@ -562,7 +566,7 @@ export async function startDaemonRuntime( closeServersBestEffort(servers); removeInfo(infoPath); releaseDaemonLock(lockPath); - setRunnerLeaseOwnerStateDir(undefined); + await configureAppleRunnerLeaseOwnerStateDir(undefined); exit(1); return null; } @@ -589,7 +593,7 @@ export async function startDaemonRuntime( expiredProviderLeaseReleaser.beginShutdown(); await teardownDaemonSessions(); try { - await resetAndroidSnapshotHelperSessions(); + await resetAndroidSnapshotHelperRuntime(); } catch (error) { emitDiagnostic({ level: 'warn', @@ -627,7 +631,7 @@ export async function startDaemonRuntime( ]); removeInfo(infoPath); releaseDaemonLock(lockPath); - setRunnerLeaseOwnerStateDir(undefined); + await configureAppleRunnerLeaseOwnerStateDir(undefined); exit(shutdownOptions.exitCode ?? 0); }; @@ -695,10 +699,9 @@ export async function cleanupWebBrowserOrphansForDaemonStartup(params: { sessionStore: SessionStore; ownedProcessRecords?: OwnedProcessRecordStore; }): Promise { - const status = getManagedAgentBrowserStatus({ stateDir: params.stateDir }); - if (!status.installed) return; try { - await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup', { + await cleanupManagedWebRuntimeOrphans({ + stateDir: params.stateDir, openWebSessionNames: openWebSessionNames(params.sessionStore), ...(params.ownedProcessRecords === undefined ? {} diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts index f88811460..43a981608 100644 --- a/src/daemon/session-teardown.ts +++ b/src/daemon/session-teardown.ts @@ -9,12 +9,7 @@ import { finishLiveScreenRecording } from './screen-recording-session-resource.t import { finishLiveAudioProbe } from './audio-probe-session-resource.ts'; import { finishLivePerfCapture } from './perf-capture-session-resource.ts'; import { openWebSessionNames } from './web-session-names.ts'; - -// Android cleanup helpers and the web managed-browser provider stay behind dynamic imports: every -// teardown caller pays this module's graph, while the helpers only matter when the corresponding -// capture (or platform) actually applies to the session. Each import sits inside its sole caller, -// next to the existing guard, matching register-builtins' interactor lazy pattern; the seam is -// pinned by src/daemon/__tests__/session-teardown-import-closure.test.ts. +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; export async function stopSessionAppLog(params: { session: SessionState; @@ -41,18 +36,11 @@ export async function stopSessionPerfCapture(params: { await finishLivePerfCapture({ ...params, session: currentSession }); } -export async function stopSessionAndroidSnapshotHelper(session: SessionState): Promise { - if (session.device.platform !== 'android') return; - const { stopAndroidSnapshotHelperSessionForDevice } = - await import('../platforms/android/snapshot-helper.ts'); - await stopAndroidSnapshotHelperSessionForDevice(session.device); -} - -// Single source of truth for "is this a web session", shared with `shouldDispatchPlatformClose` -// in daemon/handlers/session-close.ts so the ordinary-close and teardown platform-close gates -// cannot silently drift apart. -export function isWebSession(session: SessionState): boolean { - return session.device.platform === 'web'; +export async function stopSessionSnapshotHelper( + session: SessionState, + platformCleanup: PlatformResourceCleanup, +): Promise { + await platformCleanup.stopSnapshotHelper(session.device); } // Best-effort mirror of the platform close `session close` dispatches for a web session @@ -65,16 +53,15 @@ async function stopSessionWebBrowser(params: { session: SessionState; sessionName: string; sessionStore: SessionStore; + platformCleanup: PlatformResourceCleanup; }): Promise { - const { session, sessionName, sessionStore } = params; - if (!isWebSession(session)) return; - const { createAgentBrowserWebProvider } = - await import('../platforms/web/agent-browser-provider.ts'); - await createAgentBrowserWebProvider({ - session: sessionName, + const { session, sessionName, sessionStore, platformCleanup } = params; + await platformCleanup.closeManagedBrowser({ + device: session.device, + sessionName, stateDir: sessionStore.resolveDaemonStateDir(), - openWebSessionNames: () => openWebSessionNames(sessionStore), - }).close(); + openSessionNames: () => openWebSessionNames(sessionStore), + }); } type SessionCleanupStep = { step: string; run: () => Promise }; @@ -137,12 +124,20 @@ type SessionResourceTeardownRequest = { sessionStore: SessionStore; stateDir?: string; appLog: 'run' | 'already-settled'; + platformCleanup?: PlatformResourceCleanup; }; export async function teardownSessionResources( request: SessionResourceTeardownRequest, ): Promise { const { session, sessionName, sessionStore } = request; + if (!request.platformCleanup) { + throw new AppError( + 'INTERNAL_ERROR', + 'Platform resource cleanup was not supplied by root runtime composition', + ); + } + const platformCleanup = request.platformCleanup; const appLogSteps: SessionCleanupStep[] = request.appLog === 'run' ? [ @@ -176,13 +171,22 @@ export async function teardownSessionResources( step: 'perf_capture', run: () => stopSessionPerfCapture({ session, sessionName, sessionStore }), }, - { step: 'android_snapshot_helper', run: () => stopSessionAndroidSnapshotHelper(session) }, + { + step: 'platform_snapshot_helper', + run: () => stopSessionSnapshotHelper(session, platformCleanup), + }, // Runs after the resource steps above (recording, app-log, audio, perf) so nothing is still // reading through the browser when it closes, mirroring the ordering `runSessionCloseTeardown` // uses for an ordinary `session close`: best-effort resources first, platform close after. { step: 'web_browser', - run: () => stopSessionWebBrowser({ session, sessionName, sessionStore }), + run: () => + stopSessionWebBrowser({ + session, + sessionName, + sessionStore, + platformCleanup, + }), }, ]; steps.push({ diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index f508e620e..e8fd3007d 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -56,55 +56,60 @@ export async function dispatchSnapshotRuntimeCommand( const capture = await resolveBoundSnapshotCaptureRuntime(params, params.command); if (!capture.ok) return capture.response; const { session, device, snapshotScope } = capture; - return await withSessionlessRunnerCleanup(session, device, async () => { - const { req, sessionName, logPath, sessionStore } = params; - const capturedQuality: CapturedSnapshotQuality = {}; - const runtime = createSnapshotRuntime({ - req, - sessionName, - logPath, - sessionStore, - session, - device, - snapshotScope, - capturedQuality, - captureSnapshotData: capture.captureSnapshot, - inspectFacts: params.inspectFacts, - bindDevice: params.bindDevice, - }); - let result: Awaited>; - try { - result = await params.execute({ runtime, sessionName, req, snapshotScope }); - } catch (error) { - const timeoutResponse = await maybeBuildAndroidSnapshotTimeoutFailure({ - error, - command: params.command, + return await withSessionlessRunnerCleanup( + session, + device, + async () => { + const { req, sessionName, logPath, sessionStore } = params; + const capturedQuality: CapturedSnapshotQuality = {}; + const runtime = createSnapshotRuntime({ + req, + sessionName, logPath, + sessionStore, session, device, + snapshotScope, + capturedQuality, + captureSnapshotData: capture.captureSnapshot, inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, }); - if (!timeoutResponse) throw error; - return timeoutResponse; - } - recordSnapshotRuntimeAction({ - req, - sessionName, - sessionStore, - result: result.record, - }); - const data = applyRecoveredWarningLatch({ - session: sessionStore.get(sessionName), - data: result.data, - verdict: capturedQuality.value, - internalObservation: req.internal?.observationOnly === true, - }); - return { - ok: true, - data: copySnapshotClickabilityEvidence(result.data, data), - }; - }); + let result: Awaited>; + try { + result = await params.execute({ runtime, sessionName, req, snapshotScope }); + } catch (error) { + const timeoutResponse = await maybeBuildAndroidSnapshotTimeoutFailure({ + error, + command: params.command, + logPath, + session, + device, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); + if (!timeoutResponse) throw error; + return timeoutResponse; + } + recordSnapshotRuntimeAction({ + req, + sessionName, + sessionStore, + result: result.record, + }); + const data = applyRecoveredWarningLatch({ + session: sessionStore.get(sessionName), + data: result.data, + verdict: capturedQuality.value, + internalObservation: req.internal?.observationOnly === true, + }); + return { + ok: true, + data: copySnapshotClickabilityEvidence(result.data, data), + }; + }, + params.platformResourceCleanup, + ); } function createSnapshotRuntime( diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index 87a308f7f..78652367b 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -12,6 +12,7 @@ import type { import { buildIosOpenCommandHint } from './ios-app-session-hint.ts'; import { buildRuntimeCaptureInput } from './snapshot-runtime-capture-input.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { @@ -40,6 +41,7 @@ export type SnapshotRuntimeRouteParams = { sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; + platformResourceCleanup?: PlatformResourceCleanup; }; type ResolvedSnapshotCaptureRuntime = diff --git a/src/daemon/web-session-names.ts b/src/daemon/web-session-names.ts index c067dd8cd..c1585026c 100644 --- a/src/daemon/web-session-names.ts +++ b/src/daemon/web-session-names.ts @@ -1,4 +1,9 @@ import type { SessionStore } from './session-store.ts'; +import type { SessionState } from './types.ts'; + +export function isWebSession(session: SessionState): boolean { + return session.device.platform === 'web'; +} export function openWebSessionNames(sessionStore: SessionStore): string[] { return sessionStore diff --git a/src/platform-runtime-apple-resources.ts b/src/platform-runtime-apple-resources.ts new file mode 100644 index 000000000..50fa0d02d --- /dev/null +++ b/src/platform-runtime-apple-resources.ts @@ -0,0 +1,19 @@ +import type { AppleRunnerRequestOptions } from '@agent-device/contracts/apple-runner-request'; +import type { ElementSelectorKey } from '@agent-device/contracts/interactor-types'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +export async function inspectAppleRunnerSession(deviceId: string) { + const { getRunnerSessionSnapshot } = await import('./platforms/apple/core/runner-client.ts'); + return getRunnerSessionSnapshot(deviceId); +} + +export async function queryAppleRuntimeSelector( + device: DeviceInfo, + selector: Readonly<{ key: ElementSelectorKey; value: string }>, + appBundleId: string | undefined, + options: AppleRunnerRequestOptions, +): Promise> { + const { queryAppleRunnerSelector } = + await import('./platforms/apple/core/runner-selector-query.ts'); + return await queryAppleRunnerSelector(device, selector, appBundleId, options); +} diff --git a/src/platform-runtime-apple-runner-owner.ts b/src/platform-runtime-apple-runner-owner.ts new file mode 100644 index 000000000..58ce45d2a --- /dev/null +++ b/src/platform-runtime-apple-runner-owner.ts @@ -0,0 +1,8 @@ +/** Root-composed daemon ownership input consumed by the Apple runner host. */ +export async function configureAppleRunnerLeaseOwnerStateDir( + stateDir: string | undefined, +): Promise { + const { setRunnerLeaseOwnerStateDir } = + await import('./platforms/apple/core/runner-owner-state.ts'); + setRunnerLeaseOwnerStateDir(stateDir); +} diff --git a/src/platform-runtime-device-ready.ts b/src/platform-runtime-device-ready.ts new file mode 100644 index 000000000..154668d73 --- /dev/null +++ b/src/platform-runtime-device-ready.ts @@ -0,0 +1,40 @@ +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; + +export type LocalPlatformDeviceReadyOptions = { + deviceHub?: boolean; + focusExisting?: boolean; + onIosSimulatorColdBootStart?: (device: DeviceInfo) => void; +}; + +/** + * Runs concrete local-platform readiness mechanics from the root composition layer. + * Returns false when the platform has no readiness preparation step. + */ +export async function ensureLocalPlatformDeviceReady( + device: DeviceInfo, + options: LocalPlatformDeviceReadyOptions = {}, +): Promise { + if (isIosFamily(device)) { + if (device.kind === 'simulator') { + const { ensureBootedSimulator } = await import('./platforms/apple/core/simulator.ts'); + await ensureBootedSimulator(device, { + deviceHub: options.deviceHub, + focusExisting: options.focusExisting, + onColdBootStart: options.onIosSimulatorColdBootStart, + }); + return true; + } + if (device.kind === 'device') { + const { resolveIosPhysicalDeviceControl } = + await import('./platforms/apple/core/physical-device-control.ts'); + await resolveIosPhysicalDeviceControl(device).ensureReady(device); + return true; + } + } + if (device.platform === 'android') { + const { waitForAndroidBoot } = await import('./platforms/android/emulator-lifecycle.ts'); + await waitForAndroidBoot(device.id); + return true; + } + return false; +} diff --git a/src/platform-runtime-resource-cleanup.ts b/src/platform-runtime-resource-cleanup.ts new file mode 100644 index 000000000..9ab506936 --- /dev/null +++ b/src/platform-runtime-resource-cleanup.ts @@ -0,0 +1,94 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { isIosFamily } from '@agent-device/kernel/device'; +import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; +import { emitDiagnostic } from './utils/diagnostics.ts'; +import type { OwnedProcessRecordStore } from './utils/owned-process-record.ts'; + +/** Focused durable-resource cleanup composed above daemon policy and concrete platforms. */ +export async function resetAndroidSnapshotHelperRuntime(): Promise { + const { resetAndroidSnapshotHelperSessions } = + await import('./platforms/android/snapshot-helper.ts'); + await resetAndroidSnapshotHelperSessions(); +} + +async function stopAndroidSnapshotHelperRuntimeForDevice(device: DeviceInfo): Promise { + const { stopAndroidSnapshotHelperSessionForDevice } = + await import('./platforms/android/snapshot-helper.ts'); + await stopAndroidSnapshotHelperSessionForDevice(device); +} + +export async function cleanupManagedWebRuntimeOrphans(params: { + stateDir: string; + openWebSessionNames: readonly string[]; + ownedProcessRecords?: OwnedProcessRecordStore; +}): Promise { + const { getManagedAgentBrowserStatus } = await import('./platforms/web/agent-browser-tool.ts'); + const status = getManagedAgentBrowserStatus({ stateDir: params.stateDir }); + if (!status.installed) return; + const { cleanupManagedAgentBrowserOrphans } = + await import('./platforms/web/agent-browser-lifecycle.ts'); + await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup', { + openWebSessionNames: params.openWebSessionNames, + ...(params.ownedProcessRecords === undefined + ? {} + : { ownedProcessRecords: params.ownedProcessRecords }), + }); +} + +async function closeManagedWebRuntimeSession(params: { + sessionName: string; + stateDir: string; + openWebSessionNames: () => readonly string[]; +}): Promise { + const { createAgentBrowserWebProvider } = + await import('./platforms/web/agent-browser-provider.ts'); + await createAgentBrowserWebProvider({ + session: params.sessionName, + stateDir: params.stateDir, + openWebSessionNames: params.openWebSessionNames, + }).close(); +} + +export const platformResourceCleanup: PlatformResourceCleanup = Object.freeze({ + async stopSnapshotHelper(device) { + if (device.platform !== 'android') return; + await stopAndroidSnapshotHelperRuntimeForDevice(device); + }, + async closeManagedBrowser(params) { + if (params.device.platform !== 'web') return; + await closeManagedWebRuntimeSession({ + sessionName: params.sessionName, + stateDir: params.stateDir, + openWebSessionNames: params.openSessionNames, + }); + }, + async cleanupSessionlessExecutionHost(device) { + if (!isIosFamily(device)) return; + const { resolveRunnerAppBundleId, stopIosRunnerSession } = + await import('./platforms/apple/core/runner-client.ts'); + await stopIosRunnerSession(device.id); + const bundleId = resolveRunnerAppBundleId(); + const { closeIosApp } = await import('./platforms/apple/core/apps.ts'); + await closeIosApp(device, bundleId).catch((error) => { + emitDiagnostic({ + level: 'debug', + phase: 'ios_sessionless_runner_host_close_failed', + data: { + deviceId: device.id, + bundleId, + error: error instanceof Error ? error.message : String(error), + }, + }); + }); + }, + retainExecutionHostAfterClose(params) { + return ( + isIosFamily(params.device) && + params.device.kind === 'simulator' && + !params.shutdownRequested && + !params.hasScreenRecording && + !params.hasLease && + !params.device.simulatorSetPath + ); + }, +}); diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 8b264ea3f..4365446f3 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -16,6 +16,7 @@ import { androidObservation, type PlatformProviderResolvers, } from '../../../src/platform-runtime.ts'; +import { platformResourceCleanup } from '../../../src/platform-runtime-resource-cleanup.ts'; import type { AppleSimulatorScreenRecordingProcess } from '../../../src/platform-runtime-screen-recording-apple-transport.ts'; import { trackDownloadableArtifact } from '../../../src/daemon/artifact-tracking.ts'; import { LeaseRegistry } from '../../../src/daemon/lease-registry.ts'; @@ -157,6 +158,7 @@ export async function createProviderScenarioHarness( // diagnostics are injected at the root, so the harness composes them the same way. hostDiagnostics: createHostDiagnostics(), androidObservation, + platformResourceCleanup, requestPlatformProviders: configuredRequestPlatformProviders ?? createRequestPlatformProviders({