Skip to content

feat(forward): ios forward --list (like adb forward --list) - #812

Open
danielpaulus wants to merge 4 commits into
mainfrom
feat/issue-681-forward-list
Open

feat(forward): ios forward --list (like adb forward --list)#812
danielpaulus wants to merge 4 commits into
mainfrom
feat/issue-681-forward-list

Conversation

@danielpaulus

Copy link
Copy Markdown
Owner

Based on #804 — stacked on fix/issue-378-forward-reconnect (same files); the diff includes that PR's commit until it merges.

Problem

There is no way to see which ports are currently forwarded on a host, like adb forward --list provides for Android (device serial + local/remote port of every active forward). (#681)

Design

A file-backed per-user registry of active forwards:

  • Every ios forward process registers each forward as one JSON file <pid>-<hostPort>.json (fields: udid, hostPort, devicePort, pid) under <user config dir>/go-ios/forwards (os.UserConfigDir()), and unregisters on clean shutdown.
  • ios forward --list reads that dir and prints the entries. Entries whose owning pid is no longer alive are pruned on read, so forwards that died without cleanup (SIGKILL, crash) disappear on the next list instead of lingering.
  • Listing needs no device, so it dispatches as a global command before device resolution — it works on a host with nothing attached.

Implementation

  • ios/forward/registry.go: Entry, Registry (Register/Unregister/List with prune), DefaultRegistryDir, portable pid-liveness probe (signal 0 on unix incl. EPERM-means-alive; os.FindProcess on Windows).
  • main.go: startForwarding/startMultiForwarding register each forward (best-effort — a registry failure never breaks the forward) and unregister on shutdown; runForwardListCommand prints JSON by default or adb-style <udid> tcp:<hostPort> tcp:<devicePort> pid:<pid> lines with --nojson.
  • cmd_global.go: global forward --list command; docopt usage gains [--list] on the forward pattern.

Options considered

  1. File-backed per-user state dir (chosen). No daemon required, works without the tunnel agent, survives agent restarts, and prune-on-read self-heals after unclean exits. Known limitation: pid reuse could briefly keep a stale entry alive, and the registry only sees forwards started via the ios forward CLI on this user account.
  2. Agent HTTP API. Register forwards with the go-ios tunnel agent and query it over HTTP. Gives live truth and cross-process eviction, but ios forward must then depend on a running agent (which iOS <17 setups don't need at all), needs new API surface/ports, and still misses forwards started when the agent is down.
  3. Port scanning / OS socket table. No state to keep, but cannot attribute a listening port to a device udid or device port, so it can't answer the actual question.

Test plan

Device-free unit tests in ios/forward/registry_test.go against a temp state dir:

  • TestRegistryRegisterListUnregister — register/list (sorted), unregister, idempotent double-unregister.
  • TestRegistryPrunesDeadPids — an entry owned by a genuinely dead pid (spawned helper process that exited) is dropped from the listing and its file is pruned; the live entry survives.
  • TestRegistryListMissingDirAndGarbage — never-created dir lists empty without error; invalid JSON entries are pruned; non-registry files are left alone.

Manual smoke test: ios forward --list (JSON and --nojson) with a live-pid and a dead-pid entry in the real state dir — live one listed in both formats, dead one pruned. go build ./..., go test -race ./... and gofmt -l are clean.

Fixes #681

🤖 Generated with Claude Code

https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk

ios forward captured the numeric usbmux device id once at startup. usbmuxd
assigns a fresh id every time a device is plugged in, so after a
disconnect/reconnect every new client connection failed with 'could not
connect to phone' while iproxy kept working in the same session.

Each accepted client connection still dials with the captured id first; when
that connect fails, the device is looked up again by udid (the serial survives
a replug) and the connect is retried once with the fresh id. The retry seam is
an injected resolver func, so the behavior is unit-tested device-free against
a fake usbmuxd on a temp unix socket. Also closes the usbmux socket on a
failed Connect instead of leaking it. The exported StartNewProxyConnection
signature is unchanged.

Fixes #378

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
@danielpaulus

Copy link
Copy Markdown
Owner Author

/test-devices

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 Running real-device tests on PR #812run.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

❌ Real-device tests failed — see run.

danielpaulus and others added 3 commits August 7, 2026 09:54
Register the temp-dir removal with t.Cleanup immediately after MkdirTemp
so a subsequent net.Listen failure (which t.Fatalf's) can no longer leak
the directory. Split the listener close into its own cleanup so ordering
stays correct (listener closed before its dir is removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
Adds a file-backed per-user registry of active forwards: every `ios forward`
process writes one JSON entry (udid, hostPort, devicePort, pid) per forward
under <user config dir>/go-ios/forwards and removes it on clean shutdown.
`ios forward --list` reads the registry and prints the active forwards as
JSON (or adb-style lines with --nojson), pruning entries whose owning process
is no longer alive so killed forwards don't linger. Listing needs no device
and is dispatched as a global command.

Fixes #681

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
Register wrote each entry with a plain os.WriteFile straight to the final
path. That truncates the file before rewriting it, so a concurrent
List — which prunes (os.Remove) any file it cannot json-unmarshal — could
observe the truncated intermediate, treat it as garbage, and delete a
live forward's entry, permanently dropping it from 'ios forward --list'.
Write to a temp file in the same dir and rename it into place so List
only ever sees a complete file (the temp lacks the .json suffix List
scans for). TestRegistryConcurrentRegisterList reproduces the drop
against the old non-atomic write and passes with the rename.

Also create the state dir 0700 and entries 0600 (was 0755/0644) so the
device udid, forwarded ports and pid of a per-user forward are not
readable by other local users, matching the repo's hardening posture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
@danielpaulus
danielpaulus force-pushed the feat/issue-681-forward-list branch from 8fb0452 to f7de563 Compare August 7, 2026 13:56
@danielpaulus

Copy link
Copy Markdown
Owner Author

Adversarial review (with codex cross-check). Verdict: ship-worthy after fix. One real data-loss race fixed + perms hardened, pushed to this branch. This branch was rebased so it stays exactly #804 (updated) + this feature.

Fixes applied (f7de563)

  1. Register/List race (data loss). Register wrote each entry with os.WriteFile straight to the final path — that truncates before rewriting. List prunes (os.Remove) any file it can't json.Unmarshal, so a List racing a Register could observe the truncated intermediate, treat it as garbage, and delete a live forward's entry, dropping it from ios forward --list permanently. Now written to a same-dir temp file and os.Renamed into place (temp lacks the .json suffix List scans). Added TestRegistryConcurrentRegisterList — it reproduces the drop against the old non-atomic write (got 35–37/40) and passes with the rename.
  2. Perms. State dir was 0755, entries 0644 — a per-user forward's udid/ports/pid were world-readable. Now 0700/0600 (matches the repo's hardening posture). Added TestRegistryPrivatePerms.

Reviewed, no change needed

  • forward --list global dispatch: matcher requires both forward and --list, dispatched before device resolution, so no collision with device-scoped forward or with apps --list. Correct — listing needs no device.
  • Unregister on shutdown: deferred on SIGINT/SIGTERM (clean) in both single- and multi-forward paths; SIGKILL/crash leaves the file, pruned on next List via pidAlive. Verified by TestRegistryPrunesDeadPids.
  • Missing-dir / garbage-file handling and best-effort registration (never breaks the forward) are correct.

Dismissed (codex flagged, accepted as-is)

  • PID reuse: a reused pid can make a stale entry look alive until that unrelated process exits. This is the same limitation adb-style tooling accepts; a robust fix needs per-OS process-start-time verification, out of scope for this PR. Worth a follow-up note, not a blocker.
  • os.UserConfigDir failure in --list: registration degrades silently but --list exitIfErrors. Defensible — if the config dir can't be resolved there's no registry to enumerate, and a clear error beats silently printing empty.

Verified on this branch (804+feature): go build ./..., go test -race ./..., gofmt -l . all clean.

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.

ios forward

1 participant