fix(updater): stop the update prompt from blocking the main actor - #837
fix(updater): stop the update prompt from blocking the main actor#837nishantkumar1292 wants to merge 3 commits into
Conversation
|
The PR Policy check is blocking this PR because required template information is missing. Please update the PR description with:
Screenshots or video are required for UI, UX, settings, onboarding, overlay, menu bar, or visual behavior changes. If this PR has no visual changes, check the no-visual-change box in the template. If this remains incomplete for 48 hours after opening, the PR may be closed. |
Greptile SummaryReplaces unattended modal updater and MLX alerts with non-activating floating prompts so main-actor work remains responsive.
|
|
Friendly bump on this one. The reason I'd love a maintainer's eyes: this bug silently kills dictation for everyone on the current release whenever a new version ships — the update alert opens invisibly behind other windows and blocks the main actor, so the app looks dead until you stumble on the hidden dialog. It bit me twice in a single day, and the two earlier reports of it (#564, #745) were auto-closed as stale without a fix. The diff is small (+158/−14, one file) and reuses the existing |
|
@altic-dev please review, this is the main cause of crashes for me |
The "Update Available" prompt used NSAlert.runModal(), which does not return until the alert is dismissed. It runs inside a main actor job, so every other @mainactor job queues behind it - including the dictation callbacks GlobalHotkeyManager posts from its event tap. FluidVoice is a menu bar app that is rarely frontmost, so the alert also came up unactivated behind other windows. The result is that dictation stops working the moment a release ships and stays dead until the user finds the hidden dialog. Present the offer from a floating, non-activating panel styled like the install status panel in SimpleUpdater. The main actor stays free, the prompt floats above the frontmost app without stealing focus, and the Install Now / Later behaviour is unchanged. Guard against duplicate panels, since the hourly check can now actually run while the prompt is on screen. Refs altic-dev#564, altic-dev#745 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The update prompt was not the only NSAlert.runModal() an unattended code path could reach. showMLXUpgradeOffer() fires 1.2s after launch (or on the first activation, when the launch was a login item), and showUpdateAlert(title:message:) is reached without a click whenever the offered update's install attempt fails, because Install Now goes through checkForUpdatesManually(). Either one parks the main actor in a modal loop behind an unactivated window of a menu bar app, which is the same deadlock the update prompt caused: dictation callbacks queue forever and the app looks dead. Extract the panel presentation from showUpdateNotification(version:) into presentFloatingPrompt(title:message:actions:) and route both remaining call sites through it. Actions keep NSAlert's ordering, so button labels, order and handlers are unchanged, as are clearUpdateSnooze / snoozeUpdatePrompt and the MLX upgrade coordinator calls. The panel now sizes its width to the buttons, since the MLX offer's two buttons are wider than Install Now / Later. The re-entrancy guard is keyed on the prompt title rather than on the window being nil: the same prompt asked twice is still dropped, but a different prompt replaces it instead of being swallowed, so a failed install still reports itself. Dismissal moved into the button wrapper so a handler can present the next prompt. runModal() calls left alone are the ones only reachable from a click inside an already visible window - Settings, the custom dictionary, transcription history, and the menu bar items in MenuBarManager. The app is active and frontmost there, so the alert is visible and short-lived. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6e1e66f to
9f60acd
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9f60acda5a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| DebugLogger.shared.debug("Prompt \"\(title)\" already on screen, skipping duplicate", source: "AppDelegate") | ||
| return | ||
| } | ||
| self.dismissFloatingPrompt() |
There was a problem hiding this comment.
Preserve the MLX offer when another prompt arrives
When an eligible 1.6.3 user also has automatic update checking enabled, the MLX prompt is scheduled after 1.2 seconds and a successful update check can present “Update Available” after 3 seconds; this unconditional dismissal closes the MLX prompt without invoking either handler that records the offer as handled. If the user then installs the newer release, prepareOfferIfNeeded() clears the prepared flag because the app version no longer matches the one-time offer version, so the MLX upgrade is never offered again. Queue the new prompt or otherwise preserve the displaced prompt instead of silently closing it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 42bf890 — prompts now sit in a FIFO whose head is the panel on screen, instead of a new prompt replacing the visible one. Answering a prompt runs its handler and then shows whatever queued behind it, so the MLX offer is always recorded as handled by beginUpgrade() or keepCurrentModel() and can no longer be lost when prepareOfferIfNeeded() later clears the prepared flag. Duplicates are still dropped, now by title against the whole queue, and the queue is capped so a looping caller cannot grow it without bound.
| let panel = NSPanel( | ||
| contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight), | ||
| styleMask: [.borderless, .nonactivatingPanel], | ||
| backing: .buffered, |
There was a problem hiding this comment.
Preserve keyboard access to prompt actions
When a user operates the app without a mouse, this nonactivating panel is only ordered to the front and is never made key, while FloatingPromptButton provides neither key equivalents nor responder-based default/cancel handling. The former modal NSAlert focused its controls and allowed the default action to be triggered from the keyboard, but these replacement prompts can now be dismissed only by clicking a button. Provide a keyboard path to the actions while retaining the desired nonactivating behavior.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 42bf890 — the panel is now a FloatingPromptPanel that overrides canBecomeKey, so it can take key from a click without activating the app (a borderless window refuses by default, which is what blocked the keyboard entirely). The first action gets a Return key equivalent and the panel routes Escape through cancelOperation(_:) to the last action, so a single-button prompt answers to both keys the way its NSAlert did. Presentation still never takes key; the panel is made key only when FluidVoice is already frontmost, or on applicationDidBecomeActive, so activating the app lands a keyboard-only user on the prompt.
Addresses two P2 review findings on the prompt panel. Queue instead of replace. When a prompt arrived while another was on screen the visible one was closed without either handler running. For the one-time MLX offer that is destructive: neither beginUpgrade() nor keepCurrentModel() records the offer as handled, and prepareOfferIfNeeded() clears the prepared flag as soon as the app version stops matching its 1.6.3 offer version, so the upgrade is never offered again. That collision is reachable - the MLX offer is scheduled 1.2s after launch and an automatic update check can present at 3s. Prompts now sit in a FIFO whose head is the panel on screen; answering one shows whatever queued behind it. Duplicates are still dropped, now by title against the whole queue rather than just the visible prompt, and the queue is capped so a looping caller cannot grow it without bound. Restore keyboard access. The panel is borderless, so it refused to become key at all, and the buttons carried no key equivalents - the alert this replaced could be answered from the keyboard and the panel could not. FloatingPromptPanel now overrides canBecomeKey; a .nonactivatingPanel can take key from a click without activating the app, so this costs nothing in focus. The first action gets Return, and the panel's cancelOperation(_:) routes Escape to the last action, which keeps a single-button prompt answering to both keys the way its alert did. The panel is made key only when FluidVoice is already frontmost at presentation, and in applicationDidBecomeActive, so a keyboard-only user who activates the app lands on the prompt. Presentation itself still never takes key. Verified against a harness built from these two types verbatim: presenting leaves the frontmost app and key window untouched, mouse clicks and Escape fire the right actions, and Return fires the default action once the panel is key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Description
The "Update Available" prompt is an
NSAlert.runModal()on the main actor.runModal()does not return until the alert is dismissed, and it runs inside a main actor job, so every
other
@MainActorjob queues behind it — including the dictation callbacksGlobalHotkeyManager.triggerDictationMode()posts from its CGEventTap thread(
GlobalHotkeyManager.swift:1843). FluidVoice is a menu bar app that is almost neverfrontmost, so the alert also comes up unactivated behind other windows.
Both halves together mean that when a release ships, dictation silently stops working and
stays dead until the user stumbles on the hidden dialog. The event tap keeps running on its
own thread, so the app still logs every hotkey press — it just can never act on one.
This PR presents the same offer from a floating, non-activating
NSPanelstyled like theinstall status panel in
SimpleUpdater.showUpdateInstallStatus(SimpleUpdater.swift:471).The main actor stays free, and
.floating+orderFrontRegardless()+[.canJoinAllSpaces, .fullScreenAuxiliary]puts the prompt above the frontmost app withoutstealing focus — so it is visible without interrupting an in-flight dictation (#745).
Install Now / Later semantics are unchanged: Install Now clears the snooze and runs the
existing manual update path, Later snoozes the version for 24 hours. Two small related
changes: a re-entrancy guard, because the hourly check can now actually run while the prompt
is on screen and would otherwise stack panels; and
NSApp.activate(ignoringOtherApps:)before the manual install call, because that path still reports failures through a modal
alert which must not end up hidden behind other windows.
Deadlock evidence
macOS 26.6 (25G70), FluidVoice 1.6.7 (build 18), Apple Silicon, prompt for v1.6.8.
sampleof the wedged process — 1744 of 1744 samples on the main thread:The main thread is inside a Swift concurrency job that never returns, which is why the modal
run loop keeps pumping AppKit events while every queued
@MainActorjob starves.Matching cutover in
~/Library/Logs/Fluid/Fluid.log:logged by the event tap, 30 matching "Transcription release stop deferred until recording
starts", and zero log lines from
ASRServiceorContentView— no main-actor work of anykind ran.
Every dead press looks like this, with nothing following it:
Reproduction
newer than the running build exists.
sample FluidVoiceshows the main thread parked inshowUpdateNotification→runModal.dictation immediately.
Update (2026-08-18)
The bug bit again on the shipped build, and v1.6.9 as released still contains it.
The installed v1.6.8 sat wedged for 4+ hours on the invisible v1.6.9 prompt. Same
signature as above, sampled live on the hung process:
Recording and the HUD were both dead for the whole window; dismissing the hidden dialog
restored them instantly. So this is not a one-off: it reproduces on every release that ships
while the previous build is running, and it will keep doing so on v1.6.9.
Two changes since the first review:
Rebased onto
main(33 commits, no conflicts). Checked against the updater work thatlanded in the meantime — Prevent duplicate updater prompts and installs #782's duplicate-prompt guard and the signing-transition change —
and the panel composes with both; neither touched
showUpdateNotification.Hardened the two remaining unattended
runModal()call sites (second commit). Theupdate prompt was not the only alert an automatic path could reach:
showMLXUpgradeOffer()fires 1.2s after launch, or on first activation when the launchwas a login item. Completely unattended, and the exact same deadlock — it was the next
instance of this bug waiting to happen.
showUpdateAlert(title:message:)is reached without a click whenever the offeredupdate's install attempt fails, because Install Now routes through
checkForUpdatesManually(). So a failed install from the new panel could still wedgethe app on a hidden alert.
The panel presentation is now extracted into
presentFloatingPrompt(title:message:actions:)and both call sites go through it. Buttonlabels, order and handlers are unchanged, as are
clearUpdateSnooze/snoozeUpdatePromptand the MLX coordinator calls. The panel sizes its width to thebuttons, since the MLX offer's buttons are wider than Install Now / Later. The
re-entrancy guard is now keyed on the prompt title instead of "a window exists": the same
prompt asked twice is still dropped, but a different prompt replaces it rather than
being swallowed, so a failed install still reports itself.
installOfferedUpdate()keeps itsNSApp.activate(...)— the reason changed (that path'sfailure alert is no longer modal) but installing restarts the app anyway, and it keeps the
updater's own install status window in front.
Type of Change
Related Issue or Discussion
Relates to #564 ("Update-check popup blocks app/hotkey access") and #745 ("Automatic update
prompt interrupts active dictation and can discard transcript"). Both were auto-closed as
stale rather than fixed; the root cause is still present on
main. Happy to open aDiscussion first if maintainers prefer that route — filing this with the thread sample and
logs attached since the cause is pinned to a specific line.
Testing
swiftlint --strict --config .swiftlint.yml Sourcesswiftformat --config .swiftformat Sourcesxcodebuild test -scheme Fluid(CI's skip list) — 277 tests, 0 failuresRe-verified after the review fixes (third commit): BUILD SUCCEEDED, TEST SUCCEEDED
(277 tests, 0 failures), SwiftLint
--strict0 violations, SwiftFormat--lintclean. Thepanel's focus and keyboard behaviour was checked with a harness built from
FloatingPromptPaneland
FloatingPromptButtontaken verbatim from the source: presenting leaves the frontmost appand the key window untouched, mouse clicks and Escape fire the right actions, and Return fires
the default action once the panel is key.
Verification performed (re-run after the rebase and the second commit):
xcodebuild -project Fluid.xcodeproj -scheme Fluid -configuration Debug buildonmacOS 26.6 (Apple Silicon): BUILD SUCCEEDED, no new warnings.
xcodebuild test -project Fluid.xcodeproj -scheme Fluid -destination 'platform=macOS,arch=arm64'with CI's
-skip-testingfor the flaky Tiny Whisper E2E: TEST SUCCEEDED, 277 tests,0 failures.
--strictvia the same container image CI uses: 0 violations in 154 files.--lint: no changes required.(sample + logs above), and again with v1.6.8 prompting for v1.6.9 (Update section).
level without activating the app (screenshots below), and Install Now correctly invokes the
existing manual update path.
Tests/FluidDictationIntegrationTestshas no existing pattern fordriving
AppDelegateor the updater, and the behaviour here is AppKit window presentation.Screenshots / Video
Captured from the patched Debug build on macOS 26.6 (Apple Silicon), 2x retina:
In context — floating above the frontmost window without activating the app:
The second commit adds no new visual design.
showMLXUpgradeOffer()and the"No Updates" / "Update Check Failed" alerts now render through the same
presentFloatingPromptshown above, keeping their existing titles, messages and buttonlabels — only the container changes, from a system
NSAlertto the panel pictured here.I did not capture a live screenshot of those two prompts: neither has a debug hook to force
it, and exercising them would mean running a second instance of the app, which shares the
com.FluidApp.appbundle id (and thereforeUserDefaults, the event tap and the local APIport) with the running install on this machine. Happy to add captures if a maintainer wants
them.
Notes
showUpdateNotification,showMLXUpgradeOffer,showUpdateAlert.runModal()elsewhere is deliberately left alone. Every remaining call is reachable onlyfrom an explicit click inside an already-visible window —
SettingsView,CustomDictionaryView,TranscriptionHistoryView,AIEnhancementSettingsViewModel, andthe menu items in
MenuBarManager. The app is active and frontmost in all of those, so thealert is visible and short-lived, and a modal there is the expected macOS behaviour.
mid-typing. Visibility comes from the floating window level instead.
FloatingPromptButtonoverrides
acceptsFirstMouseso a single click works while another app is frontmost. Theflip side is that a stray first click over the panel acts immediately; if that trade-off is
unwanted, dropping the override makes the first click focus the panel instead.
beside the buttons, and the blank line made the panel unnecessarily tall). Easy to restore
if preferred.
revision replaced the visible prompt, which closed it without running either handler — for
the one-time MLX offer that loses it permanently, since
prepareOfferIfNeeded()clears theprepared flag once the app version no longer matches its
1.6.3offer version. Thecollision is reachable: the MLX offer is scheduled at ~1.2s after launch and the automatic
update check can present at ~3s. The queue is deduped by title and capped, so a looping
caller cannot grow it without bound.
FloatingPromptPaneloverridescanBecomeKey— a.nonactivatingpanel can take key from a click without activating theapp — the first action takes Return, and
cancelOperation(_:)routes Escape to the lastaction so a single-button prompt answers to both keys, as its alert did. Presentation
still never takes key; the panel is made key only when FluidVoice is already frontmost, or
when the app is activated. Note Return only lights up once the panel is key, because AppKit
disables a window's default button cell while it is not key — that is why the activation
hook exists.
🤖 Generated with Claude Code