Skip to content

FocusCommand: fix 'is already unbound' crash when two focus commands race - #2228

Open
hernloh wants to merge 3 commits into
nikitabobko:mainfrom
hernloh:fix-1311-upstream
Open

FocusCommand: fix 'is already unbound' crash when two focus commands race#2228
hernloh wants to merge 3 commits into
nikitabobko:mainfrom
hernloh:fix-1311-upstream

Conversation

@hernloh

@hernloh hernloh commented Aug 15, 2026

Copy link
Copy Markdown

Fixes the "MacWindow is already unbound" crash in the focus command over floating windows (#1311). Root cause, evidence, and a CLI reproducer are in the commit messages (and in more detail in my comment on #1311).

Up-front disclosure: this PR — the patch, the tests, and this description — was written by an AI agent (Claude Code) that I operate; I'm the human in the loop. I root-caused the crash from a production runtime-error dump, the new regression tests crash unpatched sources and pass with the fix, and I've been running the patched build as my daily window manager — the reproducer no longer crashes it. If you'd rather not review AI-authored patches, feel free to close this without comment — the diagnosis on #1311 stands on its own, and whatever you decide to do with this is entirely your call.

PR checklist

  • Explain your changes in the relevant commit messages rather than in the PR description. The PR description must not contain more information than the commit messages (except for images and other media).
  • Each commit must explain what/why/how and motivation in its description. https://cbea.ms/git-commit/
  • Don't forget to link the appropriate issues/discussions in commit messages (if applicable).
  • Each commit must be an atomic change (a PR may contain several commits). Don't introduce new functional changes together with refactorings in the same commit.
  • ./test.sh exits with non-zero exit code.
  • Avoid merge commits, always rebase and force push.

hernloh and others added 3 commits August 15, 2026 15:46
TestWindow.getAxRect returns without ever suspending, while in production the AX read is dispatched to the app AX
thread and the caller is resumed via a continuation. It means that tests can't reproduce anything that happens while
a command is suspended in the middle of an AX read.

Add a one-shot hook that turns a single getAxRect call into a controlled suspension point. Together with
AwaitableOneTimeBroadcastLatch it lets a test pin two concurrent commands to an exact interleaving, without sleeps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…race

nikitabobko#1311

makeFloatingWindowsSeenAsTiling iterates over workspace.floatingWindows and awaits window.getCenter before it calls
window.unbindFromParent(). getCenter is a real suspension point, and light sessions are not serialized: every hotkey
press starts its own unstructured task. So a second focus command can enter the very same loop while the first one is
suspended, unbind the very same floating window, and leave the first one to unbind an already unbound node.

Re-validate after each of the two suspension points of the loop that the window is still a child of
floatingWindowsContainer, and skip it otherwise. Both checks are needed, and the regression tests pin them
separately. The second check is not reached at all when there is no tiling window under the floating window center,
which is exactly the case in the report, where all the windows are floating: that scenario still crashes with only
the second check in place. And when the second check is reached, the window could have been unbound after the first
check has already passed: that scenario still crashes with only the first check in place.

The recorded "stacktrace where it was unbound" below is the same stack as the die site itself: the same
makeFloatingWindowsSeenAsTiling async resume function at the same offset, under refreshSessionEvent: hotkeyBinding.
In other words, the window was unbound by another instance of this very loop, and not by window closing or GC.

    ##### AeroSpace Runtime Error #####

    Message:
        AppBundle.MacWindow is already unbound. The stacktrace where it was unbound:
        0   AeroSpace                           0x000000010500ed24 $s6Common19getStringStacktraceSSyF + 36
        1   AeroSpace                           0x0000000104e59820 $s9AppBundle8TreeNodeC13unbindIfBound33_C0BD111AB6D9D19A3A8697678A2C07EFLLAA11BindingDataVSgyF + 340
        2   AeroSpace                           0x0000000104e59a18 $s9AppBundle8TreeNodeC16unbindFromParentAA11BindingDataVyF + 28
        3   AeroSpace                           0x0000000104dc06d0 $s9AppBundle31makeFloatingWindowsSeenAsTiling33_5E41CC7E5F0BE51AD60C091A166A5AF8LL9workspaceSayAA0D10WindowDataACLLVGAA9WorkspaceC_tYaFTQ1_ + 884
        4   AeroSpace                           0x0000000104dbf6a9 $s9AppBundle12FocusCommandV3runy6Common14BinaryExitCodeOAA6CmdEnvV_AA0J2Io_ptYaFTQ2_ + 1
        5   AeroSpace                           0x0000000104dc1179 $s9AppBundle12FocusCommandVAA0D0A2aDP3runy1T_12ExitCodeTypeQZAA6CmdEnvV_AA0I2Io_ptYaFTWTQ0_ + 1
        6   AeroSpace                           0x0000000104e52849 $s9AppBundle5ShellOA2A7Command_pRszlE3runy6Common13Int32ExitCodeVAA6CmdEnvV_AA0J2Io_ptYaFTf4nen_nAA0jL4ImplC_Tg5TQ1_ + 1
        7   AeroSpace                           0x0000000104deb2c5 $s9AppBundle27activateMode_nonCancellableyySSSgYaFyycfU0_yyYaKcfU_yyYaYbKScMYcXEfU_TQ2_ + 1
        8   AeroSpace                           0x0000000104d9856d $s9AppBundle15runLightSession__4bodyx6Common07RefreshE5EventO_AA03RunE5GuardVxyYaYbKScMYcXEtYaKlFxyYaKXEfU_Sb_TG5TATQ0_ + 1
        ...
    Version: 0.21.3-Beta
    Git hash: d56e163
    refreshSessionEvent: hotkeyBinding
    Date: 2026-08-13 14:15:55 +0000
    Is main thread: true
    axTaskLocalAppThreadToken: nil
    macOS version: Version 26.5.2 (Build 25F84)
    Coordinate: AppBundle/TreeNode.swift:122:32 unbindFromParent()
    cli: false
    die: true
    Monitor count: 3
    Displays have separate spaces: true

The crash reproduces on a workspace with a few floating windows within seconds:

    for i in {1..12}; do
      aerospace focus --boundaries all-monitors-outer-frame --boundaries-action wrap-around-all-monitors left &
      aerospace focus --boundaries all-monitors-outer-frame --boundaries-action wrap-around-all-monitors right &
    done; wait

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nikitabobko#1311

The tests force the poisonous schedule deterministically, without sleeps: the command 'a' is suspended inside the AX
read of makeFloatingWindowsSeenAsTiling, the command 'b' runs the whole loop meanwhile and unbinds the same floating
window, and only then 'a' is resumed. They differ in where 'a' is parked, and in what the workspace looks like:

- The first one parks 'a' in the first getCenter, in a workspace that also has a tiling window. It crashes when both
  checks are dropped
- The second one parks 'a' in the second getCenter, after the first check has already passed. It crashes when only
  the first check is in place
- The third one is the all floating workspace from the report. There is no tiling window under the floating window
  center, so the second getCenter is never reached. It crashes when only the second check is in place

All of them also assert that the tree is healthy in the end: every floating window is bound back to
floatingWindowsContainer, none is lost, duplicated, or left behind in the tiling tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the not-actionable By default, all PRs have this label. If the PR makes sense, the label will be removed. label Aug 15, 2026
ericreeves added a commit to ericreeves/AeroSpace that referenced this pull request Aug 28, 2026
…abobko#2228 (focus race unbound crash)

Both mergeable and validated locally: full assembly (2174 + 2232 + 2228 + 5
local patches) merges clean and swift build --target AppBundle is green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

not-actionable By default, all PRs have this label. If the PR makes sense, the label will be removed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant