Skip to content

fix(fileservice): don't hang on ListDirectory failures; don't leave zero-byte files on failed pulls (iOS 26.5.x app-group, #784) - #802

Open
danielpaulus wants to merge 2 commits into
mainfrom
fix/issue-784-fileservice-hang-zero-byte-pull
Open

fix(fileservice): don't hang on ListDirectory failures; don't leave zero-byte files on failed pulls (iOS 26.5.x app-group, #784)#802
danielpaulus wants to merge 2 commits into
mainfrom
fix/issue-784-fileservice-hang-zero-byte-pull

Conversation

@danielpaulus

Copy link
Copy Markdown
Owner

Problem

On iOS 26.5.x, file operations against App Group containers (--app-group) fail device-side with RemoteServices error 11007 "File paths cannot contain '..'." even though the paths contain no .. (#784). That is an Apple daemon bug — reproduced identically with Apple's own xcrun devicectl on the same devices — but go-ios failed badly around it:

  • ios file ls --app-group=… hung forever with no output.
  • A failed ios file pull left a zero-byte local file behind, which scripted pipelines can mistake for a successfully pulled empty file.

Root cause

  • ListDirectory sends RetrieveDirectoryList and then blocks in ReceiveOnClientServerStream() with no timeout. Every other control response (CreateSession, RetrieveFile, ProposeFile) arrives on the server→client stream — when the device reports a RetrieveDirectoryList failure there instead of sending the listing, the error was silently lost and the call blocked indefinitely.
  • cmd_device_files.go ran os.Create(localPath) before PullFile, so any pull failure (including the device-side 11007 rejection, which happens before any data streaming) left a truncated 0-byte destination file.

Fix

  • ios/fileservice: ListDirectory now receives on both streams concurrently under a receive timeout (default 30s) and surfaces failures as typed errors: *fileservice.DeviceError (carries the device's EncodedError + LocalizedDescription) and fileservice.ErrTimeout. A pending control-stream read left by a successful listing is tracked on the Connection and consumed by the next control receive (createSession/PullFile/PushFile), so the control stream never has two concurrent readers. extractError now returns *DeviceError everywhere (same message format as before).
  • cmd_device_files.go: the pull path is extracted into a testable pullToLocalFile wrapper that removes the local file when the download fails — no zero-byte/partial files remain. Success behavior (including pulling genuinely empty files) is unchanged.
  • Interface seam: Connection.conn is now a minimal controlConnection interface (satisfied by *xpc.Connection) so the control protocol is unit-testable without a device. No change to the wire protocol or ios/xpc.
  • Docs: ios file ls help text in main.go now notes the iOS 26.5.x App Group limitation and the --app workaround.

Options considered

  1. Timeout-only on the list stream, check control stream after expiry — simplest, but the device's 11007 error would only surface after the full timeout instead of immediately, and the error could still be lost. Rejected.
  2. Concurrent receive on both streams with a pending-read handoff (chosen) — surfaces the device error immediately, keeps a single reader per stream, and reduces to the old behavior on the happy path. Slightly more state (pendingControl), fully covered by tests.
  3. Modify ios/xpc to support deadlines/contexts — cleaner long-term, but a much larger blast radius across all XPC consumers; out of scope for a targeted hardening.
  4. For the pull: create the local file only after the control response (needs restructuring PullFile's writer contract and breaks empty-file pulls) vs. unlink on error (chosen) — minimal, and also cleans up partially-written files on mid-stream failures.

Test plan

  • New device-free unit tests that hang (and fail via a 5s test deadline) without the fix:
    • ios/fileservice/fileservice_test.go against a fake XPC connection: control-stream EncodedError → typed *DeviceError, no hang; device never responds → ErrTimeout with short injected timeout; happy-path listing; pending control-read handoff to PullFile.
    • cmd_device_files_test.go: erroring puller (immediate and mid-stream) → no local file remains; successful pull writes content and returns size.
  • Verified the ListDirectory tests fail against the pre-fix receive logic (both hang), and pass with the fix.
  • go build ./..., go test ./..., go test -race on changed packages, gofmt -l clean.

Improves #784 — the go-ios-side hang and zero-byte-file behaviors are fixed here, but the underlying App Group 11007 rejection is an Apple iOS 26.5.x daemon bug (Apple's devicectl fails identically), so the issue stays open to track that limitation.

🤖 Generated with Claude Code

https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk

…ero-byte file on failed pull

On iOS 26.5.x the device-side daemon rejects file operations on App Group
containers with RemoteServices error 11007 ("File paths cannot contain
'..'.") even for valid paths (issue #784, reproduced with Apple's own
devicectl). go-ios cannot fix the Apple bug, but it failed badly around it:

- `ios file ls` hung forever: ListDirectory blocked in
  ReceiveOnClientServerStream while the device reported the failure on the
  control (server->client) stream, so the error was silently lost.
  ListDirectory now receives on both streams concurrently with a receive
  timeout and surfaces failures as typed errors (*DeviceError, ErrTimeout)
  instead of hanging. A pending control-stream read is tracked on the
  Connection and consumed by the next control receive so the stream never
  has two readers.
- A failed `ios file pull` left a zero-byte local file because os.Create
  ran before PullFile. The pull path now removes the local file when the
  download fails, so pipelines can't mistake a failed pull for a
  successful empty file.

Adds a controlConnection interface seam so the control protocol is unit
tested against a fake XPC connection (control-stream error, unresponsive
device, happy path, pending-read handoff), plus tests for the pull
wrapper. Documents the iOS 26.5.x --app-group limitation in the CLI help.

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 #802run.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

❌ Real-device tests failed — see run.

pullToLocalFile promised no zero-byte or partial file is left behind on a
failed pull, but only cleaned up when the pull callback returned an error.
A Stat or Close failure after a seemingly-successful pull returned an error
while leaving the incomplete file on disk. Close is exactly where buffered
write errors such as a full disk surface, so this was the case most likely
to leave a truncated file a pipeline could mistake for success.

Move cleanup into a deferred, error-guarded os.Remove so every failure path
after os.Create removes the file, and add a regression test for the
post-pull finalize-failure path.

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

Copy link
Copy Markdown
Owner Author

Adversarial review (with a second-opinion pass via codex). Verdict: the core design is sound; I pushed one follow-up commit for a real cleanup gap and dismissed the rest as false positives or documented-by-contract.

Fixed (commit 932e266)

pullToLocalFile left a partial file behind on Stat/Close failure. The wrapper documents that a failed pull never leaves a zero-byte/partial file, but it only removed the file when the pull callback errored. If pull succeeded yet Stat or Close failed, it returned an error while leaving the incomplete file on disk. Close is precisely where buffered write errors (e.g. a full disk) surface, so this was the case most likely to leave a truncated file a pipeline mistakes for success. Cleanup now runs via a deferred, error-guarded os.Remove on every post-os.Create failure path, plus a regression test for the finalize-failure path.

Reviewed and dismissed

  • Goroutine leak / protocol desync when the control stream wins or on timeout. Real in the abstract, but the losing goroutine only leaks until Close(), and both ErrTimeout and the ListDirectory doc mandate closing the connection after an error/timeout. The sole caller (cmd_device_files.go) creates a fresh connection per CLI invocation and always defers Close(), so the reuse-after-error desync never materializes. Not a bug given the documented contract.
  • Data race on pendingControl / conn. No race: all pendingControl access is under c.mu; conn is set once in New and never reassigned (only Close()d). go test -race ./ios/fileservice/ is clean.
  • TOCTOU on os.Remove / truncating a pre-existing destination. The "another process swaps the path between Close and Remove" scenario is not a realistic threat for a user-specified CLI download target, and os.Create truncating an existing destination matches the prior behavior on main and every download tool. No change warranted.

Test status

go build ./..., go test ./..., and go test -race ./ios/fileservice/ all pass; gofmt -l clean.

@danielpaulus

Copy link
Copy Markdown
Owner Author

Thanks for tackling the #784 fileservice hang! Heads-up from a CI triage pass: the real-device e2e is failing here for a reason that looks like it needs another look at this change, not an infra flake.

TestFileLsCrash and TestFileLsTemp fail identically on both the macOS and Linux runners (two different healthy iOS 17 devices — the file-roundtrip push/pull subtest passes on both, so the devices are fine), with:

ListDirectory: no response within 30s: timed out waiting for a response from the device

That 30s timeout is the new code path this PR introduces in ios/fileservice/fileservice.go — the concurrent list-stream + control-stream reader racing the receiveTimeout. It looks like the normal (success) path of ListDirectory now regresses: neither the list channel nor the control channel fires, so it always hits the timeout branch. The most likely culprit is the concurrent control-stream reader (or a cached pendingControl read) consuming/stealing the directory-list response the main read is waiting for.

So the fix for the hang-on-failure case seems to have introduced a hang on the happy path. Could you take another look at the ListDirectory rewrite so a normal successful listing still returns? Happy to help verify on the device farm once updated. 🙏

pull Bot pushed a commit to dolfly/go-ios that referenced this pull request Aug 12, 2026
Add real-device e2e tests for the instruments FPS/network streaming
commands (PR danielpaulus#806) and strengthen file ls coverage (PR danielpaulus#802), which
previously had only device-free unit tests.

- harness: StreamNDJSON runs a self-terminating (--duration-bounded)
  streaming command, asserts it exits on its own within a timeout
  (a command that hangs past --duration is a real bug, not killed away),
  and decodes each stdout line as a JSON object.
- tunnel suite (iOS 17+): TestInstrumentsFPS asserts >=1 well-formed
  {"fps": <number>} sample; TestInstrumentsNetwork asserts >=1
  {"type": <number>, "data": ...} envelope. Real samples are t.Log'd.
- preios17 suite: same two commands over usbmuxd + DDI (no tunnel).
- file ls: assert every entry is a non-empty filename string and require
  a non-empty listing for the crash-logs domain root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
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