diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 69790a0db..991a5c07b 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -50,6 +50,15 @@ export function isInjectableBundleId(bundleId: string): boolean { return !bundleId.toLowerCase().startsWith("com.apple."); } +/** + * Every app-scoped native-devtools feature tool. Both dead-end warnings below + * interpolate this one list, so neither can come to name a different set of + * tools than the other. + */ +const NATIVE_FEATURE_TOOLS = + "native-describe-screen, native-find-views, native-full-hierarchy, native-network-logs, " + + "native-view-at-point, native-user-interactable-view-at-point"; + /** * The invariant half of the non-injectable recovery guidance: which tools NOT * to fall back to. Shared VERBATIM by every surface that reports this terminal @@ -69,10 +78,41 @@ export function isInjectableBundleId(bundleId: string): boolean { * and *reports* `injectable: false` rather than throwing — see the precheck.) */ export const NON_INJECTABLE_NATIVE_WARNING = - "Do not fall back to the native-devtools feature tools (native-describe-screen, " + - "native-find-views, native-full-hierarchy, native-network-logs, native-view-at-point, " + - "native-user-interactable-view-at-point) — they run the same injection precheck and fail " + - "with the same non-injectable error."; + `Do not fall back to the native-devtools feature tools (${NATIVE_FEATURE_TOOLS}) — ` + + "they run the same injection precheck and fail with the same non-injectable error."; + +/** + * The same dead-end warning for the terminal state that is *measured* rather + * than read off the bundle id ({@link buildInjectionFailedDiagnosis}). The tool + * list is shared with {@link NON_INJECTABLE_NATIVE_WARNING} so neither can drift, + * but the tail differs because the outcome does: these tools reach the same + * measurement through the precheck and report `injection_failed` rather than + * throwing NATIVE_DEVTOOLS_NOT_INJECTABLE. + */ +export const UNINJECTED_NATIVE_WARNING = + `Do not fall back to the native-devtools feature tools (${NATIVE_FEATURE_TOOLS}) — ` + + "they read the same connection state and return the same injection_failed status."; + +/** + * The two readers that work with no injection at all. Shared by + * {@link NON_INJECTABLE_RECOVERY} and {@link INJECTION_FAILED_RECOVERY}; the + * trailing space is included so either can append its own dead-end warning. + */ +const INJECTION_FREE_READERS = + "Use the standard `describe` tool (its accessibility path reads the screen without injection) " + + "or `screenshot` (then interact by coordinate). "; + +/** + * Recovery guidance for the measured terminal state, for a reader that is + * choosing an inspection tool and has not yet tried `describe` — the shared + * precheck's callers and `native-devtools-status`. The `describe` iOS fallback + * and the flow tree reader pass their own, for the same reasons they already + * carry their own non-injectable text: `describe` is reached only after its own + * accessibility path returned empty, so recommending it there is circular, and a + * flow author needs the flow-level remedy rather than a choice of inspection + * tool. + */ +export const INJECTION_FAILED_RECOVERY = INJECTION_FREE_READERS + UNINJECTED_NATIVE_WARNING; /** * Full recovery guidance for surfaces reached BEFORE `describe` has been tried @@ -95,10 +135,7 @@ export const NON_INJECTABLE_NATIVE_WARNING = * substitutes the sim's re-boot hint for `NON_INJECTABLE_HINT` when the * ax-service is degraded — see that call site.) */ -export const NON_INJECTABLE_RECOVERY = - "Use the standard `describe` tool (its accessibility path reads the screen without injection) " + - "or `screenshot` (then interact by coordinate). " + - NON_INJECTABLE_NATIVE_WARNING; +export const NON_INJECTABLE_RECOVERY = INJECTION_FREE_READERS + NON_INJECTABLE_NATIVE_WARNING; // Max consecutive init failures per service instance before it stops retrying. export const MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS = 3; @@ -222,6 +259,108 @@ export function buildAppStateMessage( } } +/** + * Terminal diagnosis for an app whose relaunch has been prescribed, performed, + * and made no difference. + * + * Reached only from `unregistered` after a `stale_process` hand-out for the same + * bundle, and the pair is what makes the opening claim safe. Which side of the + * listener a process falls on is fixed at its exec — `listeningSince` and the + * endpoint do not move within a service instance, and the two states sit on + * opposite sides of the same comparison — so one bundle reading `stale_process` + * and then `unregistered` cannot be one process observed twice. The relaunch + * happened; the process being measured is its result. + * + * `connectedPeers` localises what is left. `DYLD_INSERT_LIBRARIES` is set + * simulator-wide and this service holds one listener, so any other app connected + * on it proves the launchd env, the dylib and the listener all work and narrows + * the fault to this app's binary. No peer at all leaves all three in scope — + * that is the closest thing to a load confirmation available here, since the + * process table can show the insertion but never the load. + */ +export function buildInjectionFailedDiagnosis(bundleId: string, connectedPeers: string[]): string { + const peers = connectedPeers.filter((peer) => peer !== bundleId); + const localisation = + peers.length > 0 + ? `Other apps on this simulator are connected (${peers.join(", ")}), so the launchd environment, the dylib and this service's listener all work — the fault is specific to this app's binary: check that it is a simulator build for this platform and that it does not enforce library validation. ` + : `No app on this simulator has connected to this tool-server, so a dylib dyld never loads and a listener nothing can reach still read the same. Re-boot the simulator (boot-device with force=true) and confirm argent's native binaries are installed. A tool-server restart is worth at most one attempt: it leaves this app older than the new listener, which is the restart-app prompt again, and landing back here after that relaunch means a second, freshly bound listener saw nothing either. `; + + return ( + `${bundleId} was told to relaunch, and the process now running is a different one, so the relaunch happened — and it still never connected. ` + + `It carries argent's bootstrap dylib pointed at this simulator's devtools endpoint and it started after this service's listener bound, so the launchd environment reached it. That proves the dylib was handed to the process, not that dyld loaded it: dyld skips an inserted library silently when its slice does not match the simulator's platform, when it is unsigned, or when one of its dependencies is missing. Relaunching it again reproduces exactly this reading. ` + + localisation + ); +} + +/** + * What to tell an agent about an app that has no live devtools connection: the + * measured state's own remedy, or the terminal diagnosis once that remedy has + * been prescribed and demonstrably not converged. + */ +export interface NativeDevtoolsUninjectedAdvice { + /** + * True when the guidance prescribes no further action on the app or the + * tool-server. Surfaces with a `status` channel report `injection_failed` for + * it; the rest carry the message alone, as they already do for every other + * state. + */ + terminal: boolean; + message: string; +} + +/** + * Turn a measured state into the guidance every surface shares, recording the + * one hand-out that lets a later reading tell a spent remedy from a fresh one. + * Side-effecting on that record: call it exactly where the advice is emitted to + * an agent. + * + * Only `stale_process` and `unregistered` take part. Their remedies are the two + * halves of a cycle: `stale_process` prescribes restart-app, which leaves the + * app younger than the listener and so reads `unregistered`; `unregistered` + * prescribes a tool-server restart, whose new listener is younger than the app + * and so reads `stale_process`. An app whose dylib dyld silently skips satisfies + * both readings forever, so the second half has to stop being prescribed — and + * it is the half that must go, because obeying it discards this record along + * with the service instance holding it. + * + * The other three states are left alone because their remedies do converge. + * `connecting` resolves itself: the process ages out of the grace within seconds + * and the next reading is a verdict. `not_running` asks for a launch, and an app + * that will not stay running is a crash rather than a load failure — nothing + * here has seen its process to diagnose. `indeterminate` is the absence of a + * reading, so there is no injection to claim anything about, and its message + * already bounds itself to one restart; on ios-remote it is the only state a + * running app ever reaches, and stranding that host would leave it no reading at + * all. + * + * `indeterminate` also prescribes a relaunch, and it deliberately does NOT + * record one: only `stale_process` flipping to `unregistered` proves the process + * was replaced. `indeterminate` says the process could not be read at all, so an + * `unregistered` after it may be the same process finally becoming readable — + * and the terminal diagnosis opens by asserting a relaunch took place. + * + * `terminalRecovery` is the caller's own dead-end guidance, appended only when + * the advice turns terminal — see {@link INJECTION_FAILED_RECOVERY} for the + * inspection-tool wording and why two surfaces need their own. + */ +export function adviseOnUninjectedApp( + api: NativeDevtoolsApi, + bundleId: string, + state: Exclude, + terminalRecovery: string +): NativeDevtoolsUninjectedAdvice { + if (state === "stale_process") { + api.noteRelaunchAdvice(bundleId); + } else if (state === "unregistered" && api.wasAdvisedToRelaunch(bundleId)) { + return { + terminal: true, + message: + buildInjectionFailedDiagnosis(bundleId, api.listConnectedBundleIds()) + terminalRecovery, + }; + } + return { terminal: false, message: buildAppStateMessage(bundleId, state) }; +} + export interface NativeDevtoolsInitFailure { attempts: number; lastError: string; @@ -248,9 +387,20 @@ export function buildInitFailedResult( }; } +/** + * The measured terminal state, reported by every surface that has a `status` + * channel. Carries no `state`: the remedies for the state it was measured in + * are exactly what this block exists to withhold. + */ +export interface NativeDevtoolsInjectionFailedResult { + status: "injection_failed"; + message: string; +} + // Overloads for proper return-type inference. export type NativeDevtoolsPrecheckBlock = | NativeDevtoolsInitFailedResult + | NativeDevtoolsInjectionFailedResult | { status: "restart_required"; message: string } | { status: "service_stale"; message: string } | { status: "connect_pending"; message: string }; @@ -326,6 +476,8 @@ export async function precheckNativeDevtools( }); if (typeof state !== "string") return state; if (state === "connected") return null; + const advice = adviseOnUninjectedApp(api, bundleId, state, INJECTION_FAILED_RECOVERY); + if (advice.terminal) return { status: "injection_failed", message: advice.message }; return { // Two states must not be reported as restart_required: `unregistered`, which // a relaunch provably cannot fix, and `connecting`, where a relaunch aborts @@ -337,7 +489,7 @@ export async function precheckNativeDevtools( : state === "connecting" ? "connect_pending" : "restart_required", - message: buildAppStateMessage(bundleId, state), + message: advice.message, }; } @@ -414,6 +566,19 @@ export interface NativeDevtoolsApi { * DYLD_INSERT_LIBRARIES was silently cleared. */ appConnectionState(bundleId: string): Promise; + /** + * Record that `bundleId` has been handed `stale_process`'s relaunch remedy, so + * a later reading can tell whether the process it is looking at is the result + * of one. Prefer {@link adviseOnUninjectedApp}, which pairs this with the + * reading that consumes it. + */ + noteRelaunchAdvice(bundleId: string): void; + /** + * Whether the relaunch remedy has been prescribed for `bundleId` since it last + * connected. Connecting clears it: the remedy worked, and the next failure is + * a fresh problem rather than the continuation of an old one. + */ + wasAdvisedToRelaunch(bundleId: string): boolean; /** * Activates NSURLProtocol network interception for a specific app. * Idempotent — safe to call multiple times. Sticky: if the app is killed @@ -560,6 +725,10 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint | null = null; const activatedBundleIds = new Set(); + // Bundles handed the relaunch remedy with no connection since. Bounded by + // the set of apps ever advised on this simulator, and each entry is dropped + // the moment that app completes its handshake. + const relaunchAdvised = new Set(); const events = new TypedEventEmitter(); // Concurrency guard: a single ensureEnv attempt @@ -695,6 +864,11 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint { + relaunchAdvised.add(bundleId); + }, + wasAdvisedToRelaunch: (bundleId) => relaunchAdvised.has(bundleId), + activateNetworkInspection(bundleId) { activatedBundleIds.add(bundleId); const conn = connections.get(bundleId); @@ -950,6 +1129,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint ({ diff --git a/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts b/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts index 3d6f0bbbc..5a4f2241b 100644 --- a/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts +++ b/packages/tool-server/src/tools/native-devtools/native-devtools-status.ts @@ -1,14 +1,16 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import { - buildAppStateMessage, + adviseOnUninjectedApp, isInjectableBundleId, nativeDevtoolsRef, buildInitFailedResult, precheckNativeDevtools, + INJECTION_FAILED_RECOVERY, type NativeDevtoolsApi, type NativeDevtoolsAppState, type NativeDevtoolsInitFailedResult, + type NativeDevtoolsInjectionFailedResult, } from "../../blueprints/native-devtools"; import { resolveDevice } from "../../utils/device-info"; import { ensureDeps } from "../../utils/check-deps"; @@ -21,6 +23,7 @@ const zodSchema = z.object({ type Params = z.infer; type Result = | NativeDevtoolsInitFailedResult + | NativeDevtoolsInjectionFailedResult | { envSetup: boolean; appRunning: boolean; @@ -78,6 +81,7 @@ If state is unregistered: do NOT restart the app again — it already launched u If state is connecting: do NOT restart the app — launching it is what starts the connection, so a relaunch discards the one in progress and returns this same state. Wait a second or two and repeat this call. If state is indeterminate: the process could not be inspected, so restart-app is worth one attempt. If this call still reports it after that restart, do NOT restart the app again — the service is stale rather than the app uninjected, so restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. Remote simulators can never inspect the process, so this is the only unconnected state a running app reaches there. Returns { status: "init_failed", message, attempts } instead when the simulator's native-devtools environment failed to initialize. +Returns { status: "injection_failed", message } instead once this app has been told to restart, has done so, and the fresh process still never connected — the dylib is being inserted but dyld is not loading it. This is a TERMINAL state: do NOT restart the app again, read the message for the likely cause and use \`describe\` or \`screenshot\` instead. Fails if the simulator server is not running for the given UDID.`, zodSchema, services: (params) => ({ @@ -195,6 +199,16 @@ Fails if the simulator server is not running for the given UDID.`, // process for as long as the sim stays broken. const envSetup = api.isEnvSetup() && api.getInitFailure() === null; + // The remedy for the settled state, and the record of the one hand-out that + // later readings judge against. Reporting the terminal block in place of the + // record mirrors init_failed above: the record's whole point is to route the + // agent to `state`'s remedy, and this is the case where there is none left. + const advice = + state === "connected" + ? null + : adviseOnUninjectedApp(api, params.bundleId, state, INJECTION_FAILED_RECOVERY); + if (advice?.terminal) return { status: "injection_failed", message: advice.message }; + return { envSetup, appRunning, @@ -216,7 +230,7 @@ Fails if the simulator server is not running for the given UDID.`, // for a running app. Carry the same prose every other consumer of this // measurement carries, so the escape does not depend on the agent having // read this tool's description. - ...(state === "connected" ? {} : { message: buildAppStateMessage(params.bundleId, state) }), + ...(advice === null ? {} : { message: advice.message }), nextLaunchWillBeInjected: envSetup, injectable: true, }; diff --git a/packages/tool-server/src/tools/native-devtools/native-find-views.ts b/packages/tool-server/src/tools/native-devtools/native-find-views.ts index 3f2b35494..6a5fc98aa 100644 --- a/packages/tool-server/src/tools/native-devtools/native-find-views.ts +++ b/packages/tool-server/src/tools/native-devtools/native-find-views.ts @@ -55,7 +55,7 @@ export const nativeFindViewsTool: ToolDefinition = { Use when you need to locate a specific view by its properties without dumping the entire hierarchy. Returns { status: "ok", matches } with matching views including their frames, properties, optional ancestors, and optional children. Much more targeted than native-full-hierarchy. At least one of className, identifier, label, tag, or nativeID must be provided. -If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a second or two and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. +If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a second or two and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. If status is injection_failed: the app was told to restart, did, and the fresh process still never connected — the dylib is being inserted but dyld is not loading it, so this is TERMINAL. Do NOT restart the app or the tool-server again; read the message for the likely cause and use the standard \`describe\` tool or \`screenshot\` instead. A not-connected or not-running app comes back as one of those statuses rather than a failure. Failures are separate: an Apple system app is rejected outright (terminal — never retry it), and the hierarchy query itself can error or time out.`, zodSchema, services: (params) => ({ diff --git a/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts b/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts index 3e293e9b3..7845822a2 100644 --- a/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts +++ b/packages/tool-server/src/tools/native-devtools/native-full-hierarchy.ts @@ -67,7 +67,7 @@ WARNING: Output can be extremely large (100KB–500KB+) for complex apps, especi Use skipClasses / skipClassPrefixes to prune SwiftUI internal subtrees and reduce output size. Use the fields param to request only the properties you need. Use when you need deep layout debugging, finding views with no accessibility labels, or verifying view structure not exposed through the accessibility tree. Returns { status: "ok", windows } with the full view hierarchy. -If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a second or two and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. +If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a second or two and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. If status is injection_failed: the app was told to restart, did, and the fresh process still never connected — the dylib is being inserted but dyld is not loading it, so this is TERMINAL. Do NOT restart the app or the tool-server again; read the message for the likely cause and use the standard \`describe\` tool or \`screenshot\` instead. A not-connected or not-running app comes back as one of those statuses rather than a failure. Failures are separate: an Apple system app is rejected outright (terminal — never retry it), and the hierarchy query itself can error or time out.`, zodSchema, services: (params) => ({ diff --git a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts index 6b84e3042..4dc3a9f47 100644 --- a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts +++ b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts @@ -37,7 +37,7 @@ export const nativeNetworkLogsTool: ToolDefinition = { Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that bypass JS fetch. Use when you need to inspect native-level HTTP traffic that is invisible to JS fetch interception. Returns { status, count, events } where each event contains URL, method, status code, headers, and timing. -If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a second or two and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. +If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a second or two and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. If status is injection_failed: the app was told to restart, did, and the fresh process still never connected — the dylib is being inserted but dyld is not loading it, so this is TERMINAL. Do NOT restart the app or the tool-server again; read the message for the likely cause and use the standard \`describe\` tool or \`screenshot\` instead. A not-connected or not-running app comes back as one of those statuses rather than a failure. Failures are separate: an Apple system app is rejected outright (terminal — never retry it), while a missing host dependency or a udid that is not an Apple device is not.`, zodSchema, services: (params) => ({ diff --git a/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts b/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts index 1672ba74a..1f409a3de 100644 --- a/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts +++ b/packages/tool-server/src/tools/native-devtools/native-user-interactable-view-at-point.ts @@ -75,7 +75,7 @@ UIKit hit-testing semantics. IMPORTANT: x and y are raw iOS window coordinates in points, NOT normalized [0,1] simulator tap coordinates. -If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a second or two and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. +If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a second or two and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. If status is injection_failed: the app was told to restart, did, and the fresh process still never connected — the dylib is being inserted but dyld is not loading it, so this is TERMINAL. Do NOT restart the app or the tool-server again; read the message for the likely cause and use the standard \`describe\` tool or \`screenshot\` instead. A not-connected or not-running app comes back as one of those statuses rather than a failure. Failures are separate: an Apple system app is rejected outright (terminal — never retry it), and the point query itself can error or time out.`, zodSchema, services: (params) => ({ diff --git a/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts b/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts index 207659cb7..f0f3ad680 100644 --- a/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts +++ b/packages/tool-server/src/tools/native-devtools/native-view-at-point.ts @@ -75,7 +75,7 @@ so it answers "what is visually here?" rather than "what would receive the touch IMPORTANT: x and y are raw iOS window coordinates in points, NOT normalized [0,1] simulator tap coordinates. -If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a second or two and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. +If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a second or two and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. If status is injection_failed: the app was told to restart, did, and the fresh process still never connected — the dylib is being inserted but dyld is not loading it, so this is TERMINAL. Do NOT restart the app or the tool-server again; read the message for the likely cause and use the standard \`describe\` tool or \`screenshot\` instead. A not-connected or not-running app comes back as one of those statuses rather than a failure. Failures are separate: an Apple system app is rejected outright (terminal — never retry it), and the point query itself can error or time out.`, zodSchema, services: (params) => ({ diff --git a/packages/tool-server/test/describe-tool.test.ts b/packages/tool-server/test/describe-tool.test.ts index 6be69f4fe..b653eda74 100644 --- a/packages/tool-server/test/describe-tool.test.ts +++ b/packages/tool-server/test/describe-tool.test.ts @@ -42,6 +42,7 @@ function makeNativeDevtoolsApi(options: { describeScreenResult?: unknown; }): NativeDevtoolsApi { const connected = new Set(options.connectedBundleIds ?? []); + const relaunchAdvised = new Set(); return { isEnvSetup: () => true, socketPath: "/tmp/test.sock", @@ -51,6 +52,10 @@ function makeNativeDevtoolsApi(options: { isConnected: (bundleId) => connected.has(bundleId), isAppRunning: async () => true, listConnectedBundleIds: () => [...connected], + noteRelaunchAdvice: (bundleId: string) => { + relaunchAdvised.add(bundleId); + }, + wasAdvisedToRelaunch: (bundleId: string) => relaunchAdvised.has(bundleId), appConnectionState: async () => options.state ?? (options.requiresRestart ? "stale_process" : "connected"), activateNetworkInspection: () => {}, diff --git a/packages/tool-server/test/flows/flow-composition.test.ts b/packages/tool-server/test/flows/flow-composition.test.ts index d03401a39..64204992d 100644 --- a/packages/tool-server/test/flows/flow-composition.test.ts +++ b/packages/tool-server/test/flows/flow-composition.test.ts @@ -40,13 +40,33 @@ function mockRegistry(): Registry { // iOS launch steps gate on a native-devtools connection: report connected // so the run proceeds. No selector directives run in these tests, so the // flow tree is never fetched. - resolveService: vi.fn(async () => ({ - isConnected: () => true, - listConnectedBundleIds: () => [], - })), + resolveService: vi.fn( + stubNativeDevtools({ + isConnected: () => true, + listConnectedBundleIds: () => [], + }) + ), } as unknown as Registry; } +/** + * One native-devtools stub, resolved repeatedly the way the registry hands back + * the same per-device service. `noteRelaunchAdvice` / `wasAdvisedToRelaunch` + * back the record `adviseOnUninjectedApp` reads, so it has to survive across + * resolutions exactly as the real instance-scoped set does. + */ +function stubNativeDevtools(api: T): () => Promise { + const advised = new Set(); + const service = { + ...api, + noteRelaunchAdvice: (bundleId: string) => { + advised.add(bundleId); + }, + wasAdvisedToRelaunch: (bundleId: string) => advised.has(bundleId), + }; + return async () => service; +} + async function writeFlow(name: string, yaml: Parameters[0]): Promise { const dir = path.join(tmpDir, ".argent", "flows"); await fs.mkdir(dir, { recursive: true }); @@ -320,14 +340,16 @@ describe("flow composition (run:)", () => { id === "list-devices" ? { devices: [] } : { ok: true } ), getTool: vi.fn(() => undefined), - resolveService: vi.fn(async () => ({ - isConnected: () => false, - listConnectedBundleIds: () => [], - // The launchd env carrying the bootstrap dylib is simulator-wide, so a - // system app's process inherits the injection tokens and scores as a - // live app the service merely never registered. - appConnectionState: async () => "unregistered" as const, - })), + resolveService: vi.fn( + stubNativeDevtools({ + isConnected: () => false, + listConnectedBundleIds: () => [], + // The launchd env carrying the bootstrap dylib is simulator-wide, so a + // system app's process inherits the injection tokens and scores as a + // live app the service merely never registered. + appConnectionState: async () => "unregistered" as const, + }) + ), } as unknown as Registry; vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); @@ -369,11 +391,13 @@ describe("flow composition (run:)", () => { id === "list-devices" ? { devices: [] } : { ok: true } ), getTool: vi.fn(() => undefined), - resolveService: vi.fn(async () => ({ - isConnected: () => false, - listConnectedBundleIds: () => [], - appConnectionState, - })), + resolveService: vi.fn( + stubNativeDevtools({ + isConnected: () => false, + listConnectedBundleIds: () => [], + appConnectionState, + }) + ), } as unknown as Registry; vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); @@ -537,11 +561,13 @@ describe("flow composition (run:)", () => { id === "list-devices" ? { devices: [] } : { ok: true } ), getTool: vi.fn(() => undefined), - resolveService: vi.fn(async () => ({ - isConnected: () => true, - listConnectedBundleIds: () => [], - appConnectionState: async () => "unregistered" as const, - })), + resolveService: vi.fn( + stubNativeDevtools({ + isConnected: () => true, + listConnectedBundleIds: () => [], + appConnectionState: async () => "unregistered" as const, + }) + ), } as unknown as Registry; const result = asRun( @@ -570,11 +596,13 @@ describe("flow composition (run:)", () => { id === "list-devices" ? { devices: [] } : { ok: true } ), getTool: vi.fn(() => undefined), - resolveService: vi.fn(async () => ({ - isConnected: () => false, - listConnectedBundleIds: () => [], - appConnectionState: async () => "unregistered" as const, - })), + resolveService: vi.fn( + stubNativeDevtools({ + isConnected: () => false, + listConnectedBundleIds: () => [], + appConnectionState: async () => "unregistered" as const, + }) + ), } as unknown as Registry; // Leave setImmediate real: the run reads the flow off disk between sleeps, @@ -719,11 +747,13 @@ describe("flow composition (run:)", () => { id === "list-devices" ? { devices: [] } : { ok: true } ), getTool: vi.fn(() => undefined), - resolveService: vi.fn(async () => ({ - isConnected: () => false, // never connects during the poll… - listConnectedBundleIds: () => [], - appConnectionState: async () => "connected" as const, // …but has by the measurement - })), + resolveService: vi.fn( + stubNativeDevtools({ + isConnected: () => false, // never connects during the poll… + listConnectedBundleIds: () => [], + appConnectionState: async () => "connected" as const, // …but has by the measurement + }) + ), } as unknown as Registry; vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); @@ -766,13 +796,15 @@ describe("flow composition (run:)", () => { id === "list-devices" ? { devices: [] } : { ok: true } ), getTool: vi.fn(() => undefined), - resolveService: vi.fn(async () => ({ - isConnected: () => false, - listConnectedBundleIds: () => [], - appConnectionState: async () => { - throw new Error("Invalid device: UDID"); - }, - })), + resolveService: vi.fn( + stubNativeDevtools({ + isConnected: () => false, + listConnectedBundleIds: () => [], + appConnectionState: async () => { + throw new Error("Invalid device: UDID"); + }, + }) + ), } as unknown as Registry; vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); diff --git a/packages/tool-server/test/launch-restart-system-app.test.ts b/packages/tool-server/test/launch-restart-system-app.test.ts index 222ba4342..185331bea 100644 --- a/packages/tool-server/test/launch-restart-system-app.test.ts +++ b/packages/tool-server/test/launch-restart-system-app.test.ts @@ -45,6 +45,7 @@ const SYSTEM_APP = "com.apple.Preferences"; // Minimal api satisfying what the real 2-arg precheck consults // (getInitFailure + ensureEnvReady); the rest must stay untouched. function makeNativeApi(): NativeDevtoolsApi { + const relaunchAdvised = new Set(); return { isEnvSetup: () => true, socketPath: "/tmp/test.sock", @@ -54,6 +55,10 @@ function makeNativeApi(): NativeDevtoolsApi { isConnected: () => false, isAppRunning: async () => false, listConnectedBundleIds: () => [], + noteRelaunchAdvice: (bundleId: string) => { + relaunchAdvised.add(bundleId); + }, + wasAdvisedToRelaunch: (bundleId: string) => relaunchAdvised.has(bundleId), appConnectionState: async () => "connected", activateNetworkInspection: () => {}, getNetworkLog: () => [], diff --git a/packages/tool-server/test/native-devtools-remote-and-flow-tree.test.ts b/packages/tool-server/test/native-devtools-remote-and-flow-tree.test.ts index 2177092db..53e5b772b 100644 --- a/packages/tool-server/test/native-devtools-remote-and-flow-tree.test.ts +++ b/packages/tool-server/test/native-devtools-remote-and-flow-tree.test.ts @@ -75,8 +75,14 @@ describe("queryFullHierarchyTree surfaces the measured diagnosis", () => { // connected set, so auto-targeting can never name the app whose state is the // thing worth explaining; the id the flow launched is what makes it nameable. function registryWith(connected: string[], overrides: Partial = {}): Registry { + // Instance-scoped in the real service, so one set per registry here. + const advised = new Set(); const api = { listConnectedBundleIds: () => connected, + noteRelaunchAdvice: (bundleId: string) => { + advised.add(bundleId); + }, + wasAdvisedToRelaunch: (bundleId: string) => advised.has(bundleId), getAppState: async (bundleId: string) => ({ bundleId, applicationState: "active", diff --git a/packages/tool-server/test/native-devtools-silent-dyld-skip.test.ts b/packages/tool-server/test/native-devtools-silent-dyld-skip.test.ts new file mode 100644 index 000000000..bf0e0ffd2 --- /dev/null +++ b/packages/tool-server/test/native-devtools-silent-dyld-skip.test.ts @@ -0,0 +1,429 @@ +/** + * Reproduction for the remedy cycle a dylib dyld silently skips leaves behind. + * + * `DYLD_INSERT_LIBRARIES` only proves the bootstrap dylib was handed to the + * process; it never proves dyld loaded it. dyld skips an inserted library + * silently when its slice does not match the simulator's platform (the case + * `setupNativeDevtoolsEnvLocal` already dodges for tvOS — see utils/ios-host.ts), + * when the dylib is unsigned, or when one of its dependencies is missing. + * Whenever that happens the app runs, the launchd env is set, the process table + * shows the injection tokens, and no connection ever arrives. + * + * `appConnectionState` reads that process against this service's listener, and + * the reading alternates with the remedies an agent is handed: + * + * stale_process → "call restart-app" (the process predates the listener) + * unregistered → "restart the tool-server" (the fresh process post-dates it) + * stale_process → … (the new listener post-dates the process) + * + * so obeying each remedy in turn returns the app to a state it has already been + * in. The test drives the real blueprint and the real tool `execute()`s around + * that cycle, applying each remedy to the modelled simulator, and asserts the + * guidance reaches a state whose advice is not one of the two. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as net from "node:net"; +import type { DeviceInfo } from "@argent/registry"; + +const world = vi.hoisted(() => ({ + /** When the app's current process exec'd, on the same clock `Date.now()` reads. */ + execAt: 0, + /** The environment `ps` renders for that process. */ + env: "", + /** Bundle ids `launchctl list` reports a UIKitApplication row for. */ + running: [] as string[], +})); + +vi.mock("@argent/native-devtools-ios", () => ({ + bootstrapDylibPath: () => "/fake/dylibs/libArgentInjectionBootstrap.dylib", + bootstrapDylibPathTcp: () => "/fake/dylibs/tcp/libArgentInjectionBootstrap.dylib", + bootstrapDylibPathTvos: () => "/fake/dylibs/tvos/libArgentInjectionBootstrap.dylib", + tcpInjectionDylibs: () => [], + axServiceBinaryPath: () => "/fake/ax-service", + axServiceBinaryPathTcp: () => "/fake/ax-service-tcp", +})); + +// Every tool gates `execute()` behind `ensureDeps(["xcrun"])`, which probes the +// host toolchain. Nothing on the path under test shells out to xcrun itself. +vi.mock("../src/utils/check-deps", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ensureDeps: vi.fn(async () => {}), ensureDep: vi.fn(async () => {}) }; +}); + +type ExecCb = (err: Error | null, out: { stdout: string; stderr: string }) => void; + +/** `ps -o etime` renders `[[dd-]hh:]mm:ss`, dropping leading zero units. */ +function etime(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)); + const pad = (n: number) => String(n).padStart(2, "0"); + const hours = Math.floor(total / 3600); + const rest = `${pad(Math.floor((total % 3600) / 60))}:${pad(total % 60)}`; + return hours > 0 ? `${hours}:${rest}` : rest; +} + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: (cmd: string, args: readonly string[], opts: unknown, cb?: ExecCb) => { + const callback = (typeof opts === "function" ? opts : cb!) as ExecCb; + const argv = args.join(" "); + if (/\bps$/.test(cmd)) { + // Age and launch environment of the one modelled process. + callback(null, { + stdout: `${etime(Date.now() - world.execAt)} /Devices/App.app/App ${world.env}\n`, + stderr: "", + }); + return; + } + if (argv.includes("launchctl list")) { + callback(null, { + stdout: world.running + .map((id) => `4242\t0\tUIKitApplication:${id}[dffa][rb-legacy]\n`) + .join(""), + stderr: "", + }); + return; + } + if (argv.includes("simctl list")) { + callback(null, { stdout: JSON.stringify({ devices: {} }), stderr: "" }); + return; + } + // `launchctl getenv/setenv` and everything else: the env applies cleanly. + callback(null, { stdout: "", stderr: "" }); + }, + }; +}); + +import { + adviseOnUninjectedApp, + buildAppStateMessage, + INJECTION_FAILED_RECOVERY, + nativeDevtoolsBlueprint, + type NativeDevtoolsApi, +} from "../src/blueprints/native-devtools"; +import { nativeDevtoolsStatusTool } from "../src/tools/native-devtools/native-devtools-status"; +import { nativeDescribeScreenTool } from "../src/tools/native-devtools/native-describe-screen"; +import { queryFullHierarchyTree } from "../src/tools/flows/flow-ios-tree"; +import { createDescribeTool } from "../src/tools/describe"; + +const UDID = "DD1D0000-1111-2222-3333-444444444444"; +const SOCKET = "/tmp/argent-nd-DD1D0000.sock"; +const BUNDLE = "com.example.silentskip"; +const device: DeviceInfo = { id: UDID, platform: "ios", kind: "simulator" }; + +/** The tokens a silently-skipped dylib leaves in the process table regardless. */ +const INJECTED_ENV = + `NATIVE_DEVTOOLS_IOS_CDP_SOCKET=${SOCKET} ` + + "DYLD_INSERT_LIBRARIES=/fake/dylibs/libArgentInjectionBootstrap.dylib"; + +/** How many remedy cycles an agent is modelled as obeying before we give up. */ +const REMEDY_CYCLES = 8; + +type Instance = Awaited>; + +function advance(ms: number): void { + vi.setSystemTime(Date.now() + ms); +} + +/** + * Dial the service's real unix socket and complete the bootstrap handshake, so + * an app registers through exactly the path the injected dylib uses. + */ +async function connectApp(api: NativeDevtoolsApi, bundleId: string): Promise { + const socket = net.connect(api.socketPath); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + socket.write(JSON.stringify({ type: "Control", payload: { bundleId } }) + "\n"); + for (let i = 0; i < 200 && !api.isConnected(bundleId); i++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + if (!api.isConnected(bundleId)) throw new Error(`handshake for ${bundleId} never registered`); + return socket; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + world.execAt = Date.now() - 600_000; + world.env = INJECTED_ENV; + world.running = [BUNDLE]; + vi.spyOn(process.stderr, "write").mockImplementation(() => true); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("native-devtools — a dylib inserted but silently skipped by dyld", () => { + it("stops prescribing remedies once they have returned the app to a state it has been in", async () => { + let instance: Instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + advance(10_000); + + const trace: string[] = []; + const seen = new Set(); + const prescribedPerCycle: string[] = []; + let repeatedAt: number | null = null; + let terminalAt: number | null = null; + + try { + for (let cycle = 1; cycle <= REMEDY_CYCLES; cycle++) { + const api = instance.api as NativeDevtoolsApi; + // Surface 1 — what an agent probes before reaching for a native tool. + const status = await nativeDevtoolsStatusTool.execute( + { nativeDevtools: api }, + { udid: UDID, bundleId: BUNDLE } + ); + // Surface 2 — a native feature tool, routed through the shared precheck. + const feature = await nativeDescribeScreenTool.execute( + { nativeDevtools: api }, + { udid: UDID, bundleId: BUNDLE } + ); + + const measured = "status" in status ? status.status : status.state; + const prescribed = feature.status; + const key = `${measured}/${prescribed}`; + prescribedPerCycle.push(prescribed); + trace.push( + `cycle ${cycle} | status → ${measured} | feature → ${prescribed}` + + `${"message" in feature ? `: ${feature.message}` : ""}` + ); + if (seen.has(key) && repeatedAt === null) repeatedAt = cycle; + seen.add(key); + + if (prescribed === "restart_required") { + // Apply restart-app. The relaunch is real — a different process, into + // the current launchd env — and dyld skips the dylib again, so the new + // process carries the same tokens and never dials. + advance(2_000); + world.execAt = Date.now(); + advance(5_000); + continue; + } + if (prescribed === "service_stale") { + // Apply the tool-server restart. The same per-udid socket path is + // rebound by a new listener; the app process is untouched. + advance(2_000); + await instance.dispose(); + advance(3_000); + instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + advance(5_000); + continue; + } + terminalAt = cycle; + break; + } + + expect( + repeatedAt, + `the guidance returned the app to an already-visited state at cycle ${repeatedAt}:\n${trace.join("\n")}` + ).toBeNull(); + expect( + terminalAt, + `no surface ever stopped prescribing restart-app / tool-server restart:\n${trace.join("\n")}` + ).not.toBeNull(); + // One relaunch prescribed, performed, and then the terminal reading. The + // tool-server remedy never appears: it is the half of the cycle whose + // record it discards, so prescribing it once is prescribing it forever. + expect(prescribedPerCycle, trace.join("\n")).toEqual([ + "restart_required", + "injection_failed", + ]); + } finally { + await instance.dispose(); + } + }); + + it("keeps the tool-server remedy on a first-contact unregistered app", async () => { + // The bound is on the SECOND step of the cycle, not on `unregistered` + // itself: a genuinely stale service is what that state is for, and its + // remedy is the only one that fixes it. + const instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + try { + const api = instance.api as NativeDevtoolsApi; + // Launched well after the listener bound, and past the connect grace: the + // reading a genuinely stale service produces. + advance(60_000); + world.execAt = Date.now(); + advance(10_000); + + await expect(api.appConnectionState(BUNDLE)).resolves.toBe("unregistered"); + const advice = adviseOnUninjectedApp(api, BUNDLE, "unregistered", INJECTION_FAILED_RECOVERY); + + expect(advice).toEqual({ + terminal: false, + message: buildAppStateMessage(BUNDLE, "unregistered"), + }); + } finally { + await instance.dispose(); + } + }); + + it("does not spend the relaunch remedy on repeated reads of the same state", async () => { + // A single agent turn legitimately reads twice — a `native-devtools-status` + // probe and the feature tool it was gating. Neither reading is evidence the + // remedy was tried, so neither may consume it. + const instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + try { + const api = instance.api as NativeDevtoolsApi; + + for (let i = 0; i < 5; i++) { + expect( + adviseOnUninjectedApp(api, BUNDLE, "unregistered", INJECTION_FAILED_RECOVERY).terminal + ).toBe(false); + } + for (let i = 0; i < 5; i++) { + expect( + adviseOnUninjectedApp(api, BUNDLE, "stale_process", INJECTION_FAILED_RECOVERY).terminal + ).toBe(false); + } + } finally { + await instance.dispose(); + } + }); + + it("turns terminal only for the bundle whose relaunch was prescribed", async () => { + const instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + try { + const api = instance.api as NativeDevtoolsApi; + adviseOnUninjectedApp(api, BUNDLE, "stale_process", INJECTION_FAILED_RECOVERY); + + expect( + adviseOnUninjectedApp(api, BUNDLE, "unregistered", INJECTION_FAILED_RECOVERY).terminal + ).toBe(true); + expect( + adviseOnUninjectedApp(api, "com.example.other", "unregistered", INJECTION_FAILED_RECOVERY) + .terminal + ).toBe(false); + } finally { + await instance.dispose(); + } + }); + + it("clears the spent remedy when the app connects", async () => { + // A bundle that connects has had its relaunch work. A later stale launch of + // the same app is a fresh problem and gets the remedy again rather than + // inheriting a verdict from the previous process. + const instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + let socket: net.Socket | undefined; + try { + const api = instance.api as NativeDevtoolsApi; + adviseOnUninjectedApp(api, BUNDLE, "stale_process", INJECTION_FAILED_RECOVERY); + + socket = await connectApp(api, BUNDLE); + + expect( + adviseOnUninjectedApp(api, BUNDLE, "unregistered", INJECTION_FAILED_RECOVERY).terminal + ).toBe(false); + } finally { + socket?.destroy(); + await instance.dispose(); + } + }); + + it("localises the fault to this app's binary when a peer is connected", async () => { + // DYLD_INSERT_LIBRARIES is simulator-wide and the listener is one socket, so + // a connected peer proves the env, the dylib and this service's listener all + // work — the difference between "re-boot the simulator" and "look at this + // app's binary". + const instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + let socket: net.Socket | undefined; + try { + const api = instance.api as NativeDevtoolsApi; + socket = await connectApp(api, "com.example.peer"); + adviseOnUninjectedApp(api, BUNDLE, "stale_process", INJECTION_FAILED_RECOVERY); + + const advice = adviseOnUninjectedApp(api, BUNDLE, "unregistered", INJECTION_FAILED_RECOVERY); + + expect(advice.terminal).toBe(true); + expect(advice.message).toContain("com.example.peer"); + expect(advice.message).toContain("specific to this app's binary"); + // The app being diagnosed is not evidence about itself. + expect(advice.message).not.toContain(`(${BUNDLE})`); + } finally { + socket?.destroy(); + await instance.dispose(); + } + }); + + it("names the tool-server as still in scope when nothing has connected", async () => { + const instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + try { + const api = instance.api as NativeDevtoolsApi; + adviseOnUninjectedApp(api, BUNDLE, "stale_process", INJECTION_FAILED_RECOVERY); + + const advice = adviseOnUninjectedApp(api, BUNDLE, "unregistered", INJECTION_FAILED_RECOVERY); + + expect(advice.terminal).toBe(true); + expect(advice.message).toContain("No app on this simulator has connected"); + expect(advice.message).toContain("boot-device with force=true"); + } finally { + await instance.dispose(); + } + }); + + it("gives describe's iOS fallback the terminal diagnosis instead of the tool-server remedy", async () => { + const instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + try { + const api = instance.api as NativeDevtoolsApi; + const registry = { + resolveService: async (urn: string) => { + if (urn.startsWith("NativeDevtools:")) return api; + throw new Error("ax-service unavailable in this test"); + }, + } as unknown as Parameters[0]; + const tool = createDescribeTool(registry); + const params = { udid: UDID, bundleId: BUNDLE }; + + advance(10_000); + const advised = await tool.execute({}, params); + expect(advised.should_restart).toBe(true); + expect(advised.hint).toContain("call restart-app then retry"); + + // Apply restart-app: a fresh process, skipped by dyld exactly as before. + world.execAt = Date.now(); + advance(10_000); + + const terminal = await tool.execute({}, params); + expect(terminal.should_restart).toBeUndefined(); + expect(terminal.hint).toContain( + "was told to relaunch, and the process now running is a different one" + ); + expect(terminal.hint).toContain("dyld skips an inserted library silently"); + // The tool-server remedy is the half of the cycle that discards the record + // proving the relaunch was already tried, so it must not be prescribed. + expect(terminal.hint).not.toContain("argent server stop"); + } finally { + await instance.dispose(); + } + }); + + it("gives the flow hierarchy reader the terminal diagnosis with a flow-level remedy", async () => { + const instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + try { + const api = instance.api as NativeDevtoolsApi; + const registry = { resolveService: async () => api } as unknown as Parameters< + typeof queryFullHierarchyTree + >[0]; + + advance(10_000); + await expect(queryFullHierarchyTree(registry, device, BUNDLE)).rejects.toThrow( + /call restart-app then retry/ + ); + + world.execAt = Date.now(); + advance(10_000); + + await expect(queryFullHierarchyTree(registry, device, BUNDLE)).rejects.toThrow( + /was told to relaunch, and the process now running is a different one/ + ); + await expect(queryFullHierarchyTree(registry, device, BUNDLE)).rejects.toThrow( + /takes a point directly and reads no tree/ + ); + } finally { + await instance.dispose(); + } + }); +}); diff --git a/packages/tool-server/test/native-devtools-status.test.ts b/packages/tool-server/test/native-devtools-status.test.ts index a7c2de396..939376dda 100644 --- a/packages/tool-server/test/native-devtools-status.test.ts +++ b/packages/tool-server/test/native-devtools-status.test.ts @@ -56,6 +56,7 @@ function makeNativeApi(options: { envSetup = true; }); const isAppRunning = vi.fn(async () => options.appRunning ?? false); + const relaunchAdvised = new Set(); return { api: { @@ -67,6 +68,10 @@ function makeNativeApi(options: { isConnected: () => options.connected ?? false, isAppRunning, listConnectedBundleIds: () => [], + noteRelaunchAdvice: (bundleId: string) => { + relaunchAdvised.add(bundleId); + }, + wasAdvisedToRelaunch: (bundleId: string) => relaunchAdvised.has(bundleId), appConnectionState: async () => { if (options.connected) return "connected"; // Mirrors the real API: the unconnected path re-applies the launchd env diff --git a/packages/tool-server/test/native-target-app.test.ts b/packages/tool-server/test/native-target-app.test.ts index 1d89bee70..85f7c51ac 100644 --- a/packages/tool-server/test/native-target-app.test.ts +++ b/packages/tool-server/test/native-target-app.test.ts @@ -21,6 +21,7 @@ function makeAppState(bundleId: string, overrides: Partial = {}) function makeApi(apps: NativeAppState[]): NativeDevtoolsApi { const byBundleId = new Map(apps.map((app) => [app.bundleId, app])); + const relaunchAdvised = new Set(); return { isEnvSetup: () => true, socketPath: "/tmp/mock.sock", @@ -30,6 +31,10 @@ function makeApi(apps: NativeAppState[]): NativeDevtoolsApi { isConnected: (bundleId) => byBundleId.has(bundleId), isAppRunning: async (bundleId) => byBundleId.has(bundleId), listConnectedBundleIds: () => [...byBundleId.keys()], + noteRelaunchAdvice: (bundleId: string) => { + relaunchAdvised.add(bundleId); + }, + wasAdvisedToRelaunch: (bundleId: string) => relaunchAdvised.has(bundleId), appConnectionState: async () => "connected", activateNetworkInspection: () => {}, getNetworkLog: () => [], diff --git a/packages/tool-server/test/simulator-watcher-init-failure.test.ts b/packages/tool-server/test/simulator-watcher-init-failure.test.ts index df76d05ed..72f7e6a5f 100644 --- a/packages/tool-server/test/simulator-watcher-init-failure.test.ts +++ b/packages/tool-server/test/simulator-watcher-init-failure.test.ts @@ -51,6 +51,7 @@ function bootedListResponse(udids: string[]): { stdout: string; stderr: string } function makeFailingApi(): { api: NativeDevtoolsApi; ensureCalls: () => number } { let initFailure: NativeDevtoolsInitFailure | null = null; let calls = 0; + const relaunchAdvised = new Set(); const api: NativeDevtoolsApi = { isEnvSetup: () => false, socketPath: "/tmp/mock.sock", @@ -70,6 +71,10 @@ function makeFailingApi(): { api: NativeDevtoolsApi; ensureCalls: () => number } isConnected: () => false, isAppRunning: async () => false, listConnectedBundleIds: () => [], + noteRelaunchAdvice: (bundleId: string) => { + relaunchAdvised.add(bundleId); + }, + wasAdvisedToRelaunch: (bundleId: string) => relaunchAdvised.has(bundleId), appConnectionState: async () => "stale_process", activateNetworkInspection: () => {}, getNetworkLog: () => [], @@ -161,6 +166,7 @@ describe("simulator-watcher with api-owned init failure state", () => { }); let calls = 0; + const relaunchAdvised = new Set(); const api: NativeDevtoolsApi = { isEnvSetup: () => true, socketPath: "/tmp/mock.sock", @@ -172,6 +178,10 @@ describe("simulator-watcher with api-owned init failure state", () => { isConnected: () => false, isAppRunning: async () => false, listConnectedBundleIds: () => [], + noteRelaunchAdvice: (bundleId: string) => { + relaunchAdvised.add(bundleId); + }, + wasAdvisedToRelaunch: (bundleId: string) => relaunchAdvised.has(bundleId), appConnectionState: async () => "stale_process", activateNetworkInspection: () => {}, getNetworkLog: () => [],