Skip to content

fix(native-devtools): end the restart-app ↔ tool-server cycle a skipped dylib produces - #712

Draft
latekvo wants to merge 1 commit into
fix/native-devtools-restart-loop-guidancefrom
fix/native-devtools-dyld-skip-terminal
Draft

fix(native-devtools): end the restart-app ↔ tool-server cycle a skipped dylib produces#712
latekvo wants to merge 1 commit into
fix/native-devtools-restart-loop-guidancefrom
fix/native-devtools-dyld-skip-terminal

Conversation

@latekvo

@latekvo latekvo commented Aug 3, 2026

Copy link
Copy Markdown
Member

Stacked on #560 (fix/native-devtools-restart-loop-guidance). Review that first; this targets its branch, not main, because it closes a gap #560's own description names as knowingly left open and it touches the same four surfaces.

The gap #560 left

"a dylib that is inserted but silently skipped by dyld (a platform mismatch) reads as injected-but-silent, so service_stale → tool-server restart → stale_processrestart-app is a two-step cycle; distinguishing it needs a load confirmation the process table cannot give."

The first half is right. The conclusion is too pessimistic: terminating the cycle never required confirming the load.

Reproduced on this base

launchctl list reports a live UIKitApplication row, ps eww renders the bootstrap dylib and the per-udid socket in the launch environment, and nothing ever dials. Each cycle applies the remedy the surface just prescribed — restart_required → a new process exec; service_staledispose() plus a fresh factory rebinding the same socket:

cycle 1 | stale_process → restart_required : "...call restart-app then retry."
cycle 2 | unregistered  → service_stale    : "Restarting the app cannot change that...
                                              Restart the tool-server ... and retry."
cycle 3 | stale_process → restart_required : byte-identical to cycle 1
cycle 4..8 | alternating, byte-identical

Repeats at cycle 3. The mechanism is a time comparison: execTime - listeningSince is constant for a given process, so restart-app moves the app to the post-listener side (unregistered), and restarting the tool-server moves the listener past the app (stale_process). Each remedy is individually reasonable; together they are a pump.

The repo already names this failure mode in utils/ios-host.ts"injecting the default IOSSIMULATOR slice causes dyld to silently skip the library and native injection never connects."

Why a retry budget does not work here

A counter is the obvious fix and it is unreachable on this base. The cycle hands out exactly two remedies per tool-server lifetime, and the second one (service_stale → restart the tool-server) discards the counter along with the process that held it. So no budget ≥ 2 ever trips, and a budget of 1 strands an agent that merely calls native-devtools-status and then the feature tool it was gating.

The discriminator

The stale_processunregistered transition, which is a proof rather than a heuristic. Those two states sit on opposite sides of one time-invariant comparison, so a single process observed twice cannot produce both. Seeing the flip means a relaunch demonstrably happened and the app still did not register — which is exactly the claim the terminal message needs, and it is obtainable from the process table.

unregistered turns terminal only once a relaunch has been recorded for that bundle id. The record clears on the socket handshake and on dispose.

Which states are bounded, and which deliberately are not:

state bounded reason
stale_process records only its remedy legitimately re-points a genuinely stale process; the hand-out is the evidence a later reading needs
unregistered terminal after a recorded relaunch its remedy discards the record proving the relaunch was tried, so prescribing it once is prescribing it forever
connecting no self-converging — the process ages out of the 3 s grace and the next read is a verdict; bounding it would strand a genuine mid-handshake app
indeterminate no absence of a reading, not an injection to diagnose. It deliberately does not record a relaunch: a later unregistered may be the same process finally becoming readable, which would falsify the terminal message's opening claim. On ios-remote it is the only state a running app reaches
not_running no asks for a launch; an app that will not stay up is a crash, and the silent-skip process stays alive

Locating the fault without a load confirmation

DYLD_INSERT_LIBRARIES is simulator-wide, so a connected peer proves the environment and the dylib are fine and narrows the fault to this app's binary; no peer points at the simulator-wide setup. listConnectedBundleIds() already existed and was consumed only by auto-targeting.

The message states only what was observed — that the app was reported uninjected across a relaunch with no connection in between — so it stays true regardless of whether the agent actually performed the restart.

Built on what #560 already has

Reuses appConnectionState/NativeDevtoolsAppState as the sole source of truth, buildAppStateMessage for the non-terminal case, the precheck's state→status routing (injection_failed slots in beside restart_required / service_stale / connect_pending), the init_failed block shape for native-devtools-status, and the three-flavour recovery split the non-injectable case already uses. flowLaunchGateReason's "landed here twice" idea is made mechanical.

New: a per-instance record of relaunch advice, and one seam (adviseOnUninjectedApp) that all four surfaces emit through — the six native-* tools via the precheck, native-devtools-status, describe's iOS fallback, and the flow tree reader.

Left alone, deliberately: flow-run.ts's launch gate. It performed the relaunch itself one window earlier, so it has no flip to read, and its reading is exactly what a cold start slower than LAUNCH_TO_VERDICT_MS produces — turning it terminal would break the case its own comment defends.

Verification

  • The 8-cycle reproduction above fails 9/9 on the unchanged base and passes 9/9 with this change; the prescribed sequence becomes ["restart_required", "injection_failed"] and service_stale never appears.
  • Mutations, each applied after committing and restored against an empty git diff:
    • delete the terminal branch → 6 named tests fail
    • make the relaunch record a no-op → the same 6 fail (pins the record, not just the branch)
    • turn terminal without consulting the record → 14 fail, including the guards keeps the tool-server remedy on a first-contact unregistered app, does not spend the relaunch remedy on repeated reads of the same state, clears the spent remedy when the app connects, plus fix(native-devtools): derive restart_required from the running process instead of asserting it #560's own prescribes a tool-server restart that actually returns
  • eslint . 0, prettier --check . 0, tsc --build 0, tsc --noEmit -p tsconfig.test.json 0 — unpiped, exit codes read directly. scripts/extract-tools.test.mjs 46/46, so tool descriptions remain statically extractable for the SpiderShield scan.
  • Suite: 3198 passed / 1 skipped, plus the 6 boot-device-hotboot failures this host produces from an exported ARGENT_EMULATOR_GPU_MODE=host (a separate defect, fixed in test(tool-server): stop unit tests inheriting the developer's ARGENT_* overrides #707, not on this base).

CI does not cover this PR — read this before trusting the check mark

Every workflow in .github/workflows/ is gated on pull_request: branches: [main]. This PR targets #560's branch, so only tool-description-quality (SpiderShield) ran — 1 check, where the same file set on #560 runs 13. Lint, format, unit tests, lockfile and every E2E job were never triggered.

The green tick above therefore means almost nothing. What the change has actually been through, all run locally on this branch and read by exit code, unpiped:

npx eslint .                                                → 0
npx prettier --check .                                      → 0
npx tsc --build                                             → 0
npx tsc --noEmit -p packages/tool-server/tsconfig.test.json → 0
node --test scripts/extract-tools.test.mjs                  → 46/46
packages/tool-server suite                                  → 3198 passed / 1 skipped

The 6 boot-device-hotboot.test.ts failures on that run come from this host exporting ARGENT_EMULATOR_GPU_MODE=host; that is a separate defect fixed in #707 and not present on this base.

Once #560 merges, retargeting this at main picks up the full matrix. It should not merge on the strength of the check above.

…ed dylib produces

DYLD_INSERT_LIBRARIES proves the bootstrap dylib was handed to a process,
never 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 a
dependency is missing. Such a process reads as injected, so appConnectionState
alternates between the two states whose remedies undo each other —
stale_process prescribes restart-app, which leaves the app younger than the
listener and reads unregistered; unregistered prescribes a tool-server
restart, whose new listener is younger than the app and reads stale_process —
and an agent obeying both never leaves the pair.

Record the relaunch hand-out per bundle for the life of the service, and once
a bundle that was told to relaunch reads unregistered, report the terminal
injection_failed diagnosis instead of the tool-server remedy. The flip proves
the process was replaced: which side of the listener a process falls on is
fixed at its exec, so the two states cannot describe one process twice. The
connected-peer list localises what is left, since the launchd env is
simulator-wide and this service holds one listener.

Only those two states take part. connecting resolves itself as the process
ages out of the grace, not_running asks for a launch, and indeterminate is the
absence of a reading — it prescribes a relaunch too, but a later unregistered
there may be the same process finally becoming readable.

The flow launch gate keeps its re-run advice: it performed the relaunch
itself, one window earlier, and a cold start produces the same reading.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant