Repository navigation
fix: remove a --device run's Metro adb reverse from the phone at stop - #4172
Conversation
janicduplessis
left a comment
There was a problem hiding this comment.
Review of the diff against origin/main. Findings are ordered by severity; I found no blocker.
Findings
-
A state-lock failure aborts
stopbefore it signals Metro.stopSupervisorand the "Metro identity same" branch ofstopMetrocallawait releaseReverses()before any try/catch.removePhoneReversesends inupdateWorkspaceStateAsync, which can throw on lock timeout, abort or a write error. A contended or brokenstate.jsonlock would then makestopthrow before the SIGTERM. The next statement instopSupervisorwrapswithWorkspaceStateLockAsync(... requestDevServerStop ...)intry {} catch {}, so the codebase already treats that lock as fallible on this path. Wrap the phone-reverse step so an error is reported andstopcontinues, as the PR body says for an unreachable phone. Only the adb part is covered by that today. -
The record is dropped even when removal was not confirmed.
runFileQuietAsyncturns every failure intonull: an adb timeout (5 s), an adb server restart, an unauthorized device, or a phone that is unplugged. Ifreverse --listreturnsnull, nothing is removed andremovePhoneReversesstill deletes the record. For an unplugged phone that is right, because the reverse is gone with the transport. For a transient adb failure on a connected phone the reverse leaks, andstopprinted nothing, so nobody can tell. Consider areportline when--listyieldsnull, or keep the record in that case. If you keep the current behavior, say so in the PR body, since the "unreachable phone" test only covers dropping the record. -
The final update compares only the serial. In the closing
updateWorkspaceStateAsync,remaining[key]?.serial === done.serialdeletes the whole slot record.stopreads the record, runs adb for seconds, then writes. A concurrentandroid --devicerun on the same slot and serial that adds a new port in that window loses that port, and its reverse leaks. Compareportsas well, or delete only the ports that were removed. This is low likelihood. -
The docs overstate the scope. AGENTS.md says "exactly that reverse and nothing else". The code matches any
tcp:device port whose host side equals a recorded port. That includes a reverse the user added earlier to the same host port. One case is--no-metro-checkwith no reservation: the port falls back to 8081, sostopremoves the user's owntcp:8081 tcp:8081. The emulator path has the same semantics. Reword to "the reverses that point at the recorded Metro ports".
Repo-rule items
-
Comment policy. The JSDoc on
removePhoneReversesrestates the code and links an internal Stim issue. That is neither an external constraint nor an API contract. Delete it. The emulator comment it was copied from stays because it names adb'sservices.cpp. -
Guide placement. The new sentence in
guide/agent.tsis cleanup detail aboutadb reverse. CLAUDE.md keeps the rootguide agentfor actions, safety and routing, and puts specifics in topics.guide/cleanup.tsand the website already carry it, so theagent.tssentence can go. -
Tests.
- The
stop.test.tsassertionexpect(readWorkspaceState(tmpRoot)?.phoneReverses !== undefined).toBe(expected.length === 0)reads backwards. State what is kept, for exampletoBeDefined()in the no-removal case andtoBeUndefined()otherwise, or split the cases. notePhoneReversehas several skip conditions: not physical,remoteDevice, non-Metro runtime,metroPortnot a number. Only the positive case is tested. A regression that records an emulator or a release run would makestoprun adb against a serial the run never reversed. One negative test, for a release variant or an emulator, would cover it.- There is no test for Metro that is running but not Stim's (
not-managed). There the reverse and the record are intentionally kept.
- The
Checked and fine
- Ordering in
stop.ts: removal runs before SIGTERM on the supervisor, and in the orphan-server,missingand no-reserved-port branches. It is skipped for an unverified supervisor and fornot-managedorrefusedMetro, where the workspace still owns the port.stop --slot Xbuilds a project withmetroPort: null, so it takes thereservedPort === nullbranch and removes only that slot's phone. reclaim.ts: the removal runs insidewithIdleWorkspace, before Metro is killed and before the workspace directory is emptied or archived.- Record coverage in
launch.ts: every physical Metro launch records beforeruntime.launch.metroPortis always a number for the Metro runtime. Release, embedded-JS and remote launches add no reverse, so skipping them is correct. A launch that fails after the reverse is added is still recorded. - Fallback reverse: the
tcp:8081 -> tcp:<port>fallback is covered because matching is on the host side. - Source rules: no non-ASCII characters in the changed files under
packages/. - Tests:
pnpm test packages/stim-cli/src/__tests__/stop.test.ts -t "adb reverses"passes (6 tests). The new tests importphone-reverses.ts, so they fail on the old code. - Known gap: moving a slot to a different phone replaces the record, and the old phone keeps its reverse. The PR body already lists this.
janicduplessis
left a comment
There was a problem hiding this comment.
Re-review of cc26199 (fix: keep stop going when the phone reverse record cannot be updated).
No blocking findings.
- The ports filter in removePhoneReverses is correct: it matches on serial, subtracts only the ports this call removed, and deletes the slot record only when no ports remain. A port recorded by a concurrent run on the same serial between the adb removal and the state update is kept.
- The catch in releasePhoneReverses rethrows on abort and otherwise reports a warning. phoneReversesRemoved is set before the attempt, so a failure is not retried in the same stop. The record is left in place on failure, so a later stop or worktree remove retries it. runOperationFileQuiet already turns adb failures into null, so the catch is only reached by state read/write or lock errors. That matches the 'unreachable phone' test still passing.
- Non-blocking: the new catch branch (state update failing) has no test, and nothing exercises a partial-ports update. Both are narrow, and adding either would need a failing or racing state write, so I would leave them.
- Non-blocking: the dropped guide sentence means agent guidance no longer says that stop and worktree remove remove the adb reverse on a physical Android phone. AGENTS.md still documents it. Restore it if you want the guide to keep matching the behavior.
I did not run the test suite.
Description
stim stopleaves theadb reverse tcp:<port> tcp:<port>thatandroid --deviceadded on a physical phone. The forward outlives the workspace, and once Stim hands that port to another workspace's Metro, apps on the phone that openlocalhost:<port>reach that other server (#4132). #4105 removes the same reverse for owned emulators; a--devicephone is used, not owned, and was skipped.The device lease cannot carry this: a
--devicerun releases its run lease when the command exits, so no lease names the phone by the timestopruns. Nothing else recorded the serial.Solution
Before each
android --devicelaunch against Metro, the run records{ serial, ports }under a newphoneReverseskey in the workspacestate.json, keyed by device slot. The ports of repeated runs on the same serial accumulate, so a Metro port that changed between runs is covered too.stopandworktree remove(reclaim) read that record, listadb reverseon the recorded serial, remove onlytcp:reverses whose host side is a recorded port, and drop the record. Every other reverse on the phone stays. The adb calls go through the existing exec wrapper with the same bounded timeout as the emulator path, and an unreachable phone does not fail the stop.stopfollows #4105's ordering rule: it removes the reverses right before it signals Metro or finds none listening or no port reserved. It leaves them when it refuses to signal an unverified supervisor, since the workspace still owns the port then.stop --slotkeeps Metro running and removes only that slot's phone.This adds state for a physical device beyond its lease, which CLAUDE.md invariant 2 said did not exist. I updated that paragraph, the agent and cleanup guides, and
website/docs/commands.md. The record is the issue's own "Fix idea". Tell me if you would rather keep the serial out ofstate.json.The record is dropped after each attempt even when adb could not list the phone (timeout, unplugged, unauthorized). An adb reverse dies with its USB transport, so for an unplugged phone that is correct; a transient adb failure on a connected phone would leave the reverse behind.
Not handled: if a slot's
--devicerun moves to a different phone, the previous phone's reverse stays, because the record is replaced. It is removed when that phone is unplugged or the adb server restarts.Test plan
stop.test.tsandreclaim.test.tsseed the record and assert the exact adb calls. They fail onorigin/main(6 failures) and pass here:--slotremoves only that slot's phone;android-command.test.ts: a physical run records{ serial, ports: [8082] }and no other serial in workspace state.tcp:8085forward is not mine and was left in place).recordPhoneReversethenremovePhoneReversesagainst a scratchSTIM_HOME:UsbFfs tcp:8085 tcp:8085andUsbFfs tcp:48711 tcp:48711UsbFfs tcp:8085 tcp:8085stim android --deviceandstim stopon the phone. The machine's load average is about 1000, so I skipped the native build.pnpm run format:check,lint,build,typecheck,knip,test:runtimepass. Inpnpm test, 11 files failed at that load; 9 pass when rerun.server/device-host.test.ts(timeouts) andscripts/agent-benchmark/macos-processes.test.mjs(sandbox-exec) still fail, in files this change does not touch.Fixes #4132