Remember a disconnected display instead of forgetting it on sight - #101
Remember a disconnected display instead of forgetting it on sight#101ncchen99 wants to merge 4 commits into
Conversation
|
One consequence of "forget the record if the re-apply doesn't take" that I should put on the record, because it happened to me while I was running the branch rather than in theory. I keep two records: the built-in panel, and an Acer that is cabled over HDMI but switched off. After a session of reboots, sleeps and dock replugs, the built-in record was intact (and had its So the honest shape of the tradeoff: a transient failure during a wake or replug storm silently costs the user the choice, and the symptom is exactly the annoyance the issue is about — disconnect it again. The alternative is keeping the record and retrying on the next refresh, which risks the other failure: the Reconnect row naming a display that is on screen, and your invariant gone. I left the patch as it is, since dropping is what preserves the invariant and it matches what |
|
Following up on my own comment above: the tradeoff was worse than I described, and I've pushed a fix. I framed dropping the record as costing the user their choice. It can cost them the display. That is what happened to the Acer I mentioned. Full sequence, now that I have it from the reporter's side rather than by inference:
Recovering it needed The fix reads the outcome from enumeration instead of from the transaction, which is what the file already does in the other direction: still online after the attempt, refusals included → forget the record, as before, so your invariant holds; off → keep it, whatever the result said, because in that state the record is the only handle on the display. That also answers the retry question from my last comment: no retry needed. The failure I was worried about — a transient error during a wake or replug storm — is now indistinguishable from success as far as the record is concerned, because both leave the display off, and off is the state the record exists to describe. Worth noting |
22bbf62 to
711e545
Compare
didriksg
left a comment
There was a problem hiding this comment.
Reviewed in full, and applied on top of #99 and #100 to check the combination builds: it does, with one trivial conflict in reconnect() where both sides add lines before setEnabled(true), your in-flight guard and my log line, and both are wanted.
The race fix is right. reconnectInFlight covers the window, reconcile skips it, reapplyRemembered re-checks on entry, and the record is cleared before the defer fires with no suspension point in between. Deciding the record's fate by enumeration rather than the return value is the right call, and your comment on why a dropped record strands the display is exactly the reasoning that belongs in this file.
One thing I would change before merging, and it comes from numbers measured today rather than from reading. After the disable, reapplyRemembered polls for 1 second before deciding. On direct-attached hardware here a disable reported success after 628 ms and an enable commit took 888 ms, so a healthy transaction already uses most of that second. This file budgets 4 seconds for the same wait in softReconnect, because a display link handshake runs 2 to 4 s. And setEnabled gives up at 10 s while the commit keeps running, which #33's capture shows is real on a hub: a 29.5 s WindowServer stall. Put those together on slow hardware and the sequence is: the transaction reports failure at 10 s, the 1 s poll sees the display still lit, the record is dropped, and then the commit lands and switches the display off with no record left. That is the stranding your own comment warns about, reached through the timeout path. Matching softReconnect's 4 s window and re-checking enumeration once more before dropping would close it. The long stall in #33 is on enable, and nobody has yet seen a disable block that long, so treat the 10 s branch as reasoned rather than reproduced; the 1 s against 2 to 4 s mismatch stands either way.
One decision I would rather see made than fall out as a side effect. If a remembered display is the only screen attached at boot, wouldLeaveNoActiveDisplay refuses, the display stays lit, and the record is dropped. A single boot in that configuration silently forgets the choice, which is the complaint #93 opened with. Keeping the invariant that the list never claims a display is off while it is on screen is defensible, but it deserves a sentence in the doc comment saying that is the intended trade.
Minor, for the description only: the wake path no longer awaits the re-disconnect and goes from two refreshes to one. It self-corrects through the reconfiguration callback, but it is a behaviour change.
Merge order on this file is #99, #100, then this, so it wants a rebase once those two land, plus the conflict above.
A disconnect is a choice about one particular display, and Crisp already stores it by UUID — but reconcile() dropped the record the moment that display showed up online again, so the choice only ever lasted until the next time macOS enumerated it. For a monitor that is cabled but switched off at the wall, that is every single boot: it enumerates as an entirely ordinary display (its EDID EEPROM and hot-plug detect stay powered in HDMI standby), takes a slot in the arrangement, and the same disconnect has to be redone by hand. See issue didriksg#93. reconcile() now re-applies the disconnect for a record whose display is back, rather than deleting the record, which covers launch, relaunch, reboot and replug through the one path DisplayManager already calls on every refresh. No new setting: the choice was made and stored when the user disconnected the display, and the Reconnect row is still the way to take it back. That does reverse the old "never disconnect on launch" default, so it keeps that promise a different way — nothing can be blacked out that the user did not disconnect themselves, wouldLeaveNoActiveDisplay still refuses to take the last screen, and restoreIfNoActiveDisplay is still underneath as the backstop. A re-apply that is refused, fails, or does not verifiably drop the display out of the online list forgets the record exactly as before, so reconcile keeps its invariant: no record for a display that is on screen. The re-apply is also verified by enumeration rather than by the API's return value, since a lying success would otherwise have it retry at every refresh. Intel keeps the old behaviour outright, having no working disconnect to re-apply. Two things follow from reconcile being able to configure displays now. reconnect() clears the record only once setEnabled(true) returns, and a reconfiguration callback inside that window runs refreshDisplays, and therefore reconcile, which would find the display online with its record still in place and switch it straight back off — the user clicks Reconnect and nothing happens. An in-flight set, the same shape as softReconnectInFlight, names the displays a reconnect is running on so reconcile reads intent from there rather than from a record that has not been cleared yet. restoreIfNoActiveDisplay goes through reconnect() too, so the backstop is covered by the same guard. And reapplyOnWake is gone. It re-disconnected whatever macOS had re-enabled during sleep, which is now just a special case of a display coming back online; the wake chain's own refreshDisplays runs reconcile like any other, so the second mechanism was doing the first one's work over again.
The re-apply decided the record's fate from the transaction: anything other than a verified disable dropped it. That is right for a display that is still lit — the list must not claim a display is disconnected while the user is looking at it — and wrong for the case that reads identically from the return value: a disable that reports an error and takes anyway. The display is then switched off at the window server with its record gone, so no Reconnect row names it, and replugging cannot undo it because the window server holds that state, not the cable. The display is stranded with no way back through the UI. Not theoretical: it happened to a monitor of mine while running this branch. It had been left disconnected, was cabled but switched off at the wall, and after a dock replug it turned up SLS-disabled with no record and nothing in the app able to reach it. Re-enabling it needed SLSConfigureDisplayEnabled from outside Crisp. So the outcome is now read from enumeration rather than from the result, which is what the rest of this file already does in the other direction (see verifyBackOnline). Still online after the attempt, refusals included: forget the record, same as before. Off: keep it, whatever the transaction claimed, because the record is the only handle on a display in that state.
The re-apply polled for 1 second after the disable and then read the record's fate from what it saw. This file budgets 4 seconds for the same wait in softReconnect, because a display link handshake runs 2-4s, and a healthy transaction already uses most of one second on direct-attached hardware. setEnabled's wrapper gives up at 10s without being able to cancel the commit underneath it, and didriksg#33 has WindowServer holding one for 29.5s, so on slow hardware the sequence was: failure reported at 10s, the 1s look finds the display still lit, the record is dropped, and then the commit lands and switches the display off with nothing left to name it. That is the stranding the comment two lines below warns about, reached through the timeout instead of through a lying error. The wait now matches softReconnect's 4 seconds, and the branch that forgets the record confirms with a second look before acting, since that is the direction with the expensive mistake. Also states the boot-time trade in reconcile's doc comment rather than leaving it to fall out: with the remembered display as the only screen attached, the last-screen guard refuses and the record is forgotten, so a single boot in that configuration loses the choice. Keeping the list from ever naming a display the user is looking at is worth that.
711e545 to
13f69db
Compare
|
Rebased onto #99, #100, #106, #109 and #110. The The 1 second window. Changed, and the reasoning holds up against the file rather than just against the numbers: What I would rather say outright than have you find: that narrows the window, it does not close it. Nothing bounds the commit, and the 29.5 s in #33 is longer than any wait that belongs on a refresh path. A disable slow enough still reports failure, still finds the display lit when the looks run out, and still lands afterwards on a record that is gone. The three cases that reach the drop branch are not equally ambiguous, though:
Only the third strands anything, and no length of window resolves it, because what would resolve it is knowing whether the commit is still running. The change that closes it properly is narrow: in that case only, keep the record and let the next I have not pushed that, for two reasons. It is your invariant to spend. And the timeout path is reasoned rather than reproduced, exactly as you framed it: the long stall in #33 is on enable, and nothing has yet shown a disable blocking that way. Say the word and it is small. The boot-with-one-screen trade is now stated in Description updated for the wake path: not just a deletion, the chain no longer awaits the re-disconnect and goes from two refreshes to one, so wake returns before the display is back off and a later re-enable is caught by the reconfiguration callback instead of by that second explicit pass. Pushed as a third commit rather than squashed into the other two, so the response to the review reads on its own.
|
|
Read the rebase and the third commit, and built it here: compile, strict lint and the x86_64 typecheck are green on 13f69db. The 4 s window plus the 2 s second look reads right, and the boot trade is better stated in the doc comment than it would have been in a review thread. One thing the rebase could not have known about, since #109 landed after your branch was written. On the ambiguous branch: yes, for the timeout only. Give I will run it on my desk once those are in: disconnect, relaunch, wake, with the other displays' modes checked each time. With one screen attached here right now the re-apply refuses by design, so that waits for the next time the lid is open. |
Two things from the review of the rebase. didriksg#109 landed after this branch was written. `disconnect()` now snapshots the other displays' modes before the disable and puts back any WindowServer moved when it applied its stored arrangement for the smaller display set (didriksg#108). `reapplyRemembered` calls `setEnabled(false)` directly, so a re-applied disconnect went through that same drop with nothing to put the modes back, and at boot it would have done so every time. It now takes the same snapshot and starts the same restore. Started right after the disable rather than after the verify, for the reason restoreModes polls instead of sleeping: the flip the user sees should be as short as it can be, and the verify can hold for seconds. And the ambiguous branch, for the timeout only. `setEnabled`'s 10s wrapper stops waiting without being able to cancel the commit, so its failure is the one that is not evidence: the transaction may still be in the window server's hands, and didriksg#33 has one held for 29.5s. That case now has its own `ToggleError` rather than collapsing into `configurationFailed(.failure)`, and the record survives it for the next refresh to decide. Everything else is unchanged: a disable that genuinely fails still drops the record, so a display that cannot be switched off cannot pull a fresh transaction out of every refresh.
|
Both in, as 48ad7c8. The modes. The ambiguous branch, timeout only, as you specified. Run here, since you are down to one screen: BenQ GW2780 over DisplayPort through the dock, built-in as the remembered display. Crisp quit, the built-in re-enabled from outside with So: the re-apply path works and the restore is wired into it, but I did not reproduce #108 on this pair — WindowServer never moved the BenQ when the built-in went off, so The timeout branch is unreproduced too, for the same reason it was reasoned rather than measured in your review: nothing here makes a disable block. With #110 in, disables on this hardware report success in about 570 ms.
|
|
A measured number for the timeout branch, which we both had down as reasoned rather than reproduced. It turned up while I was chasing the count question on #92, so it is from the wake path rather than from a hub. Two runs of the same sequence — built-in held disconnected, two externals, sleep, pull the dock while asleep, wake — and the same operation, Same machine, same display, sixteen times the duration. Two caveats. It is an enable, not a disable, so it is not direct evidence for the branch we changed. And the rescue is what issued it, not Nothing to change in the PR from this — the handling is already what you specified. Recording it because you asked for the 10 s branch to be treated as reasoned, and it now has a number next to it. Also, for whenever you get the lid open: the mode restore in |
What & why
Fixes #93.
A disconnect is a choice about one particular display, stored by UUID — but
reconciledropped the record the moment that display showed up online again, so the choice lasted only until the next time macOS enumerated it. For a monitor that is cabled but switched off at the wall that is every boot: it enumerates as an entirely ordinary display, takes a slot in the arrangement, and the same disconnect has to be redone by hand.reconcilenow re-applies the disconnect for a record whose display is back, instead of deleting the record. Launch, relaunch, reboot and replug all arrive through the pathDisplayManageralready calls on every refresh, so there is no second mechanism and no new setting.Per your note on the issue, the changed default rather than an opt-in.
The old "never disconnect on launch" promise is kept a different way:
wouldLeaveNoActiveDisplaystill refuses to take the last screen,restoreIfNoActiveDisplayis still the backstop;reconcilekeeps its invariant that no record exists for a display that is on screen;verifyBackOnlinegives: a lying success would otherwise have this retry at every refresh;The Reconnect race you caught.
reconnect()clears the record only oncesetEnabled(true)returns, and a reconfiguration callback inside that window runsrefreshDisplays, and soreconcile, which would find the display online with its record still in place and switch it straight back off.reconnectInFlight— the same shape assoftReconnectInFlight— names the displays a reconnect is running on, soreconcilereads intent from there rather than from a record that has not been cleared yet.restoreIfNoActiveDisplaygoes throughreconnect()too, so the backstop is covered by the same guard.Re-entrancy.
reapplyInFlightis per-UUID rather than a single flag: a burst of callbacks refreshes the display list several times over, and two records can legitimately be mid-re-apply at once. It isrestoreInFlight's job in the same place, just not shared between unrelated displays.reapplyOnWakeis gone. It re-disconnected whatever macOS had re-enabled during sleep, which is now just one case of a display coming back online; the wake chain's ownrefreshDisplaysrunsreconcilelike any other. One path instead of two.That is a behaviour change on the wake path and not only a deletion, so stating it plainly: the chain no longer awaits the re-disconnect, and it goes from two refreshes to one. The re-apply is now started by
reconcileand runs on its own, so wake returns before the display is back off, and a display macOS re-enabled later in the wake settle is caught by the reconfiguration callback's refresh rather than by that second explicit pass. It self-corrects either way, but the timing is not what it was.How tested
Live on a MacBook Pro (M5, macOS 26.6.1) with a BenQ GW2780 over DisplayPort through a j5create JCD552 dock, using the built-in panel as the remembered display. Crisp quit for the first half so the re-apply is the only thing that could have acted; the display was re-enabled from outside Crisp with
SLSConfigureDisplayEnabled, standing in for macOS bringing it back at boot:Then the same with Crisp already running (the display-appears case rather than the launch case): re-enabled externally, disconnected again inside a second, record intact.
Then the ordinary things, on a build installed and running for a day: full reboot, sleep and wake, and unplugging the dock and plugging it back in. The remembered display stayed out of the arrangement across all of them, the built-in came back on undock, and the external's stored HiDPI mode was reapplied as before.
make checkis green (SwiftLint strict, tests, localization keys).Not verified live: the Reconnect race itself. Hitting the window means landing a reconfiguration callback inside
reconnect's await on purpose, which I could not stage from outside the app; the guard is reasoned from the same MainActor orderingsoftReconnectInFlightrelies on.Checklist
./scripts/release.sh v0.0.0-ciequivalent viamake build)