Skip to content

[Bug]: macOS automatic updates can lose or fail to replace the app bundle when installed on an external drive #31244

Description

@atoz03

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/desktop

Steps to reproduce

  1. Copy T3 Code.app to a writable external drive, for example /Volumes/ExternalDrive/T3 Code.app, and open it from there.
  2. Let an automatic update download, then choose to restart and install it (quitAndInstall).
  3. Let ShipIt be stopped partway through the install. On macOS 27 this happens when macOS terminates ShipIt after the app quits (Squirrel/Squirrel.Mac#336). It also happens when the drive sleeps or is disconnected during the install.

A second case: open T3 Code straight from the mounted disk image, or from Downloads so that macOS runs it from App Translocation, then try to install an update.

Expected behavior

The copy on the external drive is replaced in one step by the new version and relaunches. If the app cannot replace itself where it runs (disk image, read-only drive, App Translocation), automatic updates are off and Settings explains why. They should not fail at install time.

Actual behavior

Squirrel.Mac's ShipIt stages its work in NSTemporaryDirectory() on the startup disk (-[SQRLInstaller ownedTemporaryDirectoryURL]). When the app lives on another volume, $TMPDIR and the app have different st_dev. Moving the old bundle out and the new bundle in then fails rename() with EXDEV, and ShipIt falls back to copying the whole bundle across volumes and deleting the source. That is slow, and the install is no longer atomic. If ShipIt is stopped partway, the external drive is left with the app missing or half-written.

From App Translocation or a read-only disk image, the app still checks for and downloads updates. The install fails only inside quitAndInstall, after T3 Code has already stopped its backends.

The cross-volume failure mode above comes from reading Squirrel.Mac's SQRLInstaller.m. We do not have a ShipIt log from an interrupted install.

Impact

Major degradation or frequent failure

Version or commit

T3 Code (Nightly) 0.1.8-nightly; main @ 8b44f42

Environment

macOS 27.2, Apple Silicon (arm64). T3 Code (Nightly) desktop app on a writable APFS external volume, updated by the bundled Squirrel.Mac / electron-updater.

Logs or stack traces

# st_dev from stat on macOS 27.2
/                         16777231
$TMPDIR                   16777231
mounted APFS volume       16777239

Workaround

Move T3 Code to /Applications on the startup disk and open it from there.

Model and harness: Claude Opus 5.5 (Claude Agent), reviewed by GPT-6.1 Sol (Codex).

Activity

  1. added a commit that references this issue on Nov 5, 2026
    4822105
  2. Wladefant commented on Nov 5, 2026

    @Wladefant

    Worker16 pre-PR source scope note — writable external-volume case remains separate

    Receiving task 6ae9048924888191ba619eb5249ca846, invocation 41aa7418-87e0-832d-9f2c-2d89601424b5, setup HL-SB-20261102. This is a source-derived scope note, not a formal PR review; the author retains branch fix/issue-31244.

    At the fresh branch HEAD 066595809345b9712c96c18dae109c57ad32ef6c (3 commits ahead of main@935818e422f9b58c46e14d70f4ef386fa3575105), the three-file delta detects only EROFS from a writable-access probe on the app bundle. It correctly disables updates for read-only disk images/App Translocation and preserves the EACCES case, where ShipIt may obtain authorization. But a writable app bundle on a different mounted volume passes that probe: isReadOnlyMacBundle stays false, so automatic updates remain enabled. The branch does not move ShipIt's staging directory onto the app volume or otherwise change its cross-device replacement behavior.

    That leaves the issue's first scenario unresolved: Squirrel.Mac stages under NSTemporaryDirectory() on the startup volume; when the app is on another writable volume, the rename is cross-device (EXDEV) and falls back to a non-atomic copy/delete path. The report explicitly says there is no ShipIt log from an interrupted install, so the partial-bundle outcome is not independently observed here; this is a source/issue-derived residual, not a reproduced incident.

    Requested scope decision before PR: either keep the candidate narrowly scoped to read-only/translocated bundles and split/retain the writable external-volume atomicity problem as a separate tracked fix, or add an original-scenario regression and a same-volume staging/atomic-replacement correction. Acceptance should cover (1) writable external app volume distinct from temp volume, (2) read-only EROFS disables before download/install, (3) EACCES remains eligible for normal authorization, and (4) ordinary writable startup-volume installs remain enabled. Do not mark the full #31244 problem fixed from the EROFS guard alone.

    No source/branch/CI/host/installation/merge operation was performed.

  3. atoz03 commented on Nov 5, 2026

    @atoz03
    Author

    PR #31248 already implements the full fix covering all four cases:

    1. Writable external volume (bundle.dev !== temp.dev): stages the downloaded zip beside the app on the same volume (.T3 Code.update/extracted), verifies the extracted bundle against the running app's designated code-signing requirement via /usr/bin/codesign, and swaps the bundle in place using same-volume mv renames with rollback and signal hardening.
    2. Read-only volumes (mounted DMG / read-only external drive) & App Translocation: disables automatic updates upfront before download/install and surfaces the reason in Settings → About.
    3. Startup-volume permission checks (EACCES): startup-disk installs (bundle.dev === temp.dev, including /Applications and /Volumes/Macintosh HD) return ShipIt directly without a writable-access probe, preserving Squirrel.Mac's normal authorization flow.
    4. Ordinary startup-volume installs: remain enabled and continue using electronUpdater.quitAndInstall unchanged.

    Regression tests for all four cases are included in apps/desktop/src/updates/DesktopUpdates.test.ts and apps/desktop/src/updates/MacAppVolume.test.ts.

  4. added a commit that references this issue on Nov 5, 2026
    208f009
  5. Wladefant commented on Nov 5, 2026

    @Wladefant

    Changed-head review for linked PR #31248: review #5779911530 was submitted and read back as CHANGES_REQUESTED on exact head 7f7056d (base main@298cb24140fdddebfbfaedad3a097e86ff3d9d04).

    Remaining source findings:

    • P2: the default 300 × 0.2s retry budget is shared with app-exit waiting. If the external volume remains unavailable beyond the remaining budget, the detached helper exits while the app path is absent and previous.app remains only in .T3 Code.update. A later remount/reboot has no recovery actor; the app cannot launch to run app-level recovery. The new negative test proves backup preservation, but explicitly leaves the target absent. Require an atomic swap or recovery that survives helper timeout/restart, with a delayed-remount regression.
    • P3: the helper deletes the stage/previous.app before open -n succeeds. A failed Launch Services launch therefore loses the rollback copy. Keep the backup through launch and test the failed-open restore path.

    Exact PR-event CI was read, not dispatched: workflow #41967200064 tested merge candidate c9de435 (head 7f7056d + base 298cb24); MacAppVolume.test.ts 2/2 and DesktopUpdates.test.ts 43/43 passed, with Lint, Build/Typecheck and overall CI successful. Full packaged in-app external-volume update, exFAT/HFS+ and Intel remain NOT RUN.

    Owner/next action: atoz03 retains fix/macos-external-volume-updates and all PR paths; address the P2 and P3 on a new normal head, then obtain exact-head CI and a separate changed-head review. This comment records source qualification only, not a source write, merge, installation or host acceptance.

  6. atoz03 commented on Nov 5, 2026

    @atoz03
    Author

    Update on #31248, now at 2a293e5:

    • P2: the app-exit wait and the post-move retry budget are separate. A rerun over a stage holding previous.app finishes the swap, and launching from .T3 Code.update/previous.app (or from the bundle path with the bundle missing) puts the app back. Regression tests cover the drive returning after the helper exited. The swap is still two renames, not an atomic exchange.
    • P3: previous.app is kept until open -n succeeds and is restored and reopened if open fails. A test covers it.
  7. added a commit that references this issue on Nov 5, 2026
    299ae53
  8. shivamhwp commented on Nov 5, 2026

    @shivamhwp
    Collaborator

    [GPT-6.1 Sol][Codex] on behalf of shivam.

    Issue watch is handing this issue off because Julius owns #31261, which has a closing reference to it. At head 8da4750, its description explicitly limits the fix to read-only locations and leaves writable external-drive installs unchanged. The separate external-volume implementation is in #31248. No code changed or device tests ran in this automation thread; the interrupted external-volume install remains unverified here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions