Skip to content

fix(updater): stop the update prompt from blocking the main actor - #837

Open
nishantkumar1292 wants to merge 3 commits into
altic-dev:mainfrom
nishantkumar1292:fix/update-alert-blocks-main-thread
Open

fix(updater): stop the update prompt from blocking the main actor#837
nishantkumar1292 wants to merge 3 commits into
altic-dev:mainfrom
nishantkumar1292:fix/update-alert-blocks-main-thread

Conversation

@nishantkumar1292

@nishantkumar1292 nishantkumar1292 commented Aug 11, 2026

Copy link
Copy Markdown

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 @MainActor job queues behind it — including the dictation callbacks
GlobalHotkeyManager.triggerDictationMode() posts from its CGEventTap thread
(GlobalHotkeyManager.swift:1843). FluidVoice is a menu bar app that is almost never
frontmost, 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 NSPanel styled like the
install 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 without
stealing 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.

sample of the wedged process — 1744 of 1744 samples on the main thread:

1744 Thread_9557897   DispatchQueue_1: com.apple.main-thread  (serial)
+ 1744 completeTaskWithClosure(swift::AsyncContext*, swift::SwiftError*)  (in libswift_Concurrency.dylib)
+   1744 closure #1 in AppDelegate.checkForUpdatesAutomatically()  (in FluidVoice)
+     1744 AppDelegate.showUpdateNotification(version:)  (in FluidVoice)
+       1744 -[NSAlert runModal]  (in AppKit)
+         1744 -[NSApplication runModalForWindow:]  (in AppKit)
+           1744 -[NSApplication _doModalLoop:peek:]  (in AppKit)

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 @MainActor job starves.

Matching cutover in ~/Library/Logs/Fluid/Fluid.log:

[10:50:11.370] [INFO] [AppDelegate] ✅ Update available: v1.6.8
[10:50:11.370] [INFO] [AppDelegate] Showing update notification for version v1.6.8
  • Before that line: hotkey presses served normally, 6 completed ASR sessions.
  • After it, until the app was force-restarted 89 minutes later: 30 hotkey presses
    logged by the event tap, 30 matching "Transcription release stop deferred until recording
    starts", and zero log lines from ASRService or ContentView
    — no main-actor work of any
    kind ran.

Every dead press looks like this, with nothing following it:

[12:15:33.759] [INFO] [GlobalHotkeyManager] Transcription modifier held (hold mode) - starting
[12:15:33.857] [INFO] [GlobalHotkeyManager] Transcription modifier released (hold mode) - stopping
[12:15:33.857] [DEBUG] [GlobalHotkeyManager] Transcription release stop deferred until recording starts

Reproduction

  1. Run FluidVoice as a menu bar app with automatic update checks enabled, while a release
    newer than the running build exists.
  2. Keep another app frontmost and let the automatic check fire.
  3. Press the dictation hotkey. Nothing happens; the log records the press and nothing else.
  4. sample FluidVoice shows the main thread parked in showUpdateNotificationrunModal.
  5. Activating FluidVoice reveals the hidden "Update Available" dialog; dismissing it restores
    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:

AppDelegate.checkForUpdatesAutomatically()
  AppDelegate.showUpdateNotification(version:)
    -[NSAlert runModal]

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:

  1. Rebased onto main (33 commits, no conflicts). Checked against the updater work that
    landed 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.

  2. Hardened the two remaining unattended runModal() call sites (second commit). The
    update prompt was not the only alert an automatic path could reach:

    • showMLXUpgradeOffer() fires 1.2s after launch, or on first activation when the launch
      was 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 offered
      update's install attempt fails, because Install Now routes through
      checkForUpdatesManually(). So a failed install from the new panel could still wedge
      the app on a hidden alert.

    The panel presentation is now extracted into
    presentFloatingPrompt(title:message:actions:) and both call sites go through it. Button
    labels, order and handlers are unchanged, as are clearUpdateSnooze /
    snoozeUpdatePrompt and the MLX coordinator calls. The panel sizes its width to the
    buttons, 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 its NSApp.activate(...) — the reason changed (that path's
    failure 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

  • 🐞 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 🧹 Chore
  • 📝 Documentation update

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 a
Discussion 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

  • Tested on Intel Mac
  • Tested on Apple Silicon Mac
  • Tested on macOS version: 26.6
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources
  • Ran formatter locally: swiftformat --config .swiftformat Sources
  • Ran tests locally: xcodebuild test -scheme Fluid (CI's skip list) — 277 tests, 0 failures

Re-verified after the review fixes (third commit): BUILD SUCCEEDED, TEST SUCCEEDED
(277 tests, 0 failures), SwiftLint --strict 0 violations, SwiftFormat --lint clean. The
panel's focus and keyboard behaviour was checked with a harness built from FloatingPromptPanel
and FloatingPromptButton taken verbatim from the source: presenting leaves the frontmost app
and 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 build on
    macOS 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-testing for the flaky Tiny Whisper E2E: TEST SUCCEEDED, 277 tests,
    0 failures.
  • SwiftLint 0.63.2 --strict via the same container image CI uses: 0 violations in 154 files.
  • SwiftFormat 0.62.1 --lint: no changes required.
  • The bug was reproduced and diagnosed twice on macOS 26.6 — with v1.6.7 prompting for v1.6.8
    (sample + logs above), and again with v1.6.8 prompting for v1.6.9 (Update section).
  • Ran the patched Debug build on macOS 26.6 (Apple Silicon): the panel presents at floating
    level without activating the app (screenshots below), and Install Now correctly invokes the
    existing manual update path.
  • No unit test added: Tests/FluidDictationIntegrationTests has no existing pattern for
    driving AppDelegate or 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:

Light Dark
Update panel — light mode Update panel — dark mode

In context — floating above the frontmost window without activating the app:

Update panel floating over another window

The second commit adds no new visual design. showMLXUpgradeOffer() and the
"No Updates" / "Update Check Failed" alerts now render through the same
presentFloatingPrompt shown above, keeping their existing titles, messages and button
labels — only the container changes, from a system NSAlert to 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.app bundle id (and therefore UserDefaults, the event tap and the local API
port) with the running install on this machine. Happy to add captures if a maintainer wants
them.

Notes

  • All three prompts an unattended code path can reach are now panels:
    showUpdateNotification, showMLXUpgradeOffer, showUpdateAlert.
  • runModal() elsewhere is deliberately left alone. Every remaining call is reachable only
    from an explicit click inside an already-visible window — SettingsView,
    CustomDictionaryView, TranscriptionHistoryView, AIEnhancementSettingsViewModel, and
    the menu items in MenuBarManager. The app is active and frontmost in all of those, so the
    alert is visible and short-lived, and a modal there is the expected macOS behaviour.
  • The panel does not activate the app, by design: a dictation app should not steal focus
    mid-typing. Visibility comes from the floating window level instead. FloatingPromptButton
    overrides acceptsFirstMouse so a single click works while another app is frontmost. The
    flip 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.
  • The update prompt's informative text dropped "Would you like to install it now?" (redundant
    beside the buttons, and the blank line made the panel unnecessarily tall). Easy to restore
    if preferred.
  • Prompts queue rather than replace each other (third commit, from review). An earlier
    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 the
    prepared flag once the app version no longer matches its 1.6.3 offer version. The
    collision 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.
  • Keyboard access is preserved (third commit, from review): FloatingPromptPanel overrides
    canBecomeKey — a .nonactivating panel can take key from a click without activating the
    app — the first action takes Return, and cancelOperation(_:) routes Escape to the last
    action 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

@github-actions github-actions Bot added the needs screenshots Pull request needs screenshot or video evidence. label Aug 11, 2026
@github-actions

Copy link
Copy Markdown

The PR Policy check is blocking this PR because required template information is missing.

Please update the PR description with:

  • Screenshots / Video

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-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown

Greptile Summary

Replaces unattended modal updater and MLX alerts with non-activating floating prompts so main-actor work remains responsive.

  • Adds a bounded, title-deduplicated prompt queue.
  • Preserves update, snooze, MLX-upgrade, and keyboard-cancellation actions.
  • Gives visible prompts keyboard focus when FluidVoice becomes active.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Reviews (3): Last reviewed commit: "fix(updater): queue floating prompts and..." | Re-trigger Greptile

nishantkumar1292 added a commit to nishantkumar1292/FluidVoice that referenced this pull request Aug 11, 2026
nishantkumar1292 added a commit to nishantkumar1292/FluidVoice that referenced this pull request Aug 11, 2026
nishantkumar1292 added a commit to nishantkumar1292/FluidVoice that referenced this pull request Aug 11, 2026
@github-actions github-actions Bot removed the needs screenshots Pull request needs screenshot or video evidence. label Aug 11, 2026
@nishantkumar1292

Copy link
Copy Markdown
Author

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 SimpleUpdater panel pattern; lint, PR Policy, and Greptile are all green. Happy to rework the approach if you'd prefer something different — just let me know.

@bookernath

Copy link
Copy Markdown

@altic-dev please review, this is the main cause of crashes for me

nishantkumar1292 and others added 2 commits August 18, 2026 14:05
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>
@nishantkumar1292
nishantkumar1292 force-pushed the fix/update-alert-blocks-main-thread branch from 6e1e66f to 9f60acd Compare August 18, 2026 08:45

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread Sources/Fluid/AppDelegate.swift Outdated
DebugLogger.shared.debug("Prompt \"\(title)\" already on screen, skipping duplicate", source: "AppDelegate")
return
}
self.dismissFloatingPrompt()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Sources/Fluid/AppDelegate.swift Outdated
Comment on lines +589 to +592
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
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.

2 participants