fix: store pendingReconcileTask handle; skip real Carbon hotkey binding on self-test runs

AppModel gains pendingReconcileTask (the most recent watcher-reconcile Task
spawned by chooseTransport/chooseOneDriveFolder), which send() now awaits —
see the SendController.swift commit in this series. chooseTransport/
chooseOneDriveFolder store their Task's handle into it instead of firing an
untracked `Task { }`.

bootstrap() now also skips binding the real, process-wide Carbon global
capture hotkey on the same env-var-flagged self-test/headless runs that
already skip the update-check schedule (PickerSelfTest's phases,
PanelSnapshot, and the new ShotdeckTests launch-wiring regression test).
Real Carbon hotkey registration is not safe to exercise in an automated test
process — it can collide with ShotdeckCoreTests' own
HotkeyCenterCarbonTests running in the same test binary — and bootstrap()'s
hotkey step had never actually been exercised by any self-test before (none
of them call bootstrap() directly) until the new real-wiring test in this
series does. A real user launch never sets these env vars, so production
behavior is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
This commit is contained in:
2026-09-05 09:55:16 +04:00
co-authored by Claude Fable 5.1
parent d7984add2d
commit 04d1e73532
2 changed files with 31 additions and 10 deletions
+26 -8
View File
@@ -139,6 +139,14 @@ public final class AppModel {
/// lets the LAST choice win instead of applying stale, superseded work. Not /// lets the LAST choice win instead of applying stale, superseded work. Not
/// `@Observable`-relevant state pure internal bookkeeping, never read by a View. /// `@Observable`-relevant state pure internal bookkeeping, never read by a View.
var reconcileGeneration = 0 var reconcileGeneration = 0
/// The MOST RECENT watcher-reconcile Task spawned by chooseTransport/
/// chooseOneDriveFolder, if one is still (or was just) in flight. send() awaits
/// this BEFORE snapshotting transport/folder, so a toggle immediately followed by
/// Send can never race ahead of the reconcile it depends on (the watcher's
/// recordUncommented flag briefly lagging the just-chosen transport, for example).
/// `Task<Void, Never>` never throws; awaiting an already-completed task's `.value`
/// returns immediately. Not `@Observable`-relevant pure internal bookkeeping.
var pendingReconcileTask: Task<Void, Never>?
func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url } func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url }
/// True when a last-composed PDF path is known this run, or the newest /// True when a last-composed PDF path is known this run, or the newest
@@ -241,19 +249,29 @@ public final class AppModel {
setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.") setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not watch the return folder.")
} }
let pref = HotkeyPreference.load() // Env-var-flagged self-test/headless runs (PickerSelfTest's phases, PanelSnapshot,
captureHotkey = pref // and the new ShotdeckTests launch-wiring regression test) skip two real-world
if !bindCaptureHotkey(pref) { // side effects that are unsafe or meaningless in that context: the update-check
setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.") // schedule (a real network call), and binding the REAL, process-wide Carbon
} // global hotkey which is not safe to exercise in an automated/parallel test
// process (it can collide with ShotdeckCoreTests' own HotkeyCenterCarbonTests
let skipSchedule = // running in the same test binary) and was never meaningfully exercised by any
// self-test anyway. A real user launch never sets these env vars, so production
// behavior is unchanged.
let isSelfTestRun =
ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
|| ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
|| ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|| ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil || ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil
|| ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] != nil || ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] != nil
if !skipSchedule {
let pref = HotkeyPreference.load()
captureHotkey = pref
if !isSelfTestRun, !bindCaptureHotkey(pref) {
setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.")
}
if !isSelfTestRun {
updateChecker.startSchedule() updateChecker.startSchedule()
} }
} }
+5 -2
View File
@@ -255,6 +255,9 @@ extension AppModel: SettingsWindowPresenting {
/// the live value before every mutating step, so rapid toggling (this function or /// the live value before every mutating step, so rapid toggling (this function or
/// chooseOneDriveFolder, in any order) always lets the LAST choice win instead of an /// chooseOneDriveFolder, in any order) always lets the LAST choice win instead of an
/// earlier, superseded call applying its stale folder/flag after a later one already won. /// earlier, superseded call applying its stale folder/flag after a later one already won.
/// The Task's handle is stored in `pendingReconcileTask` so send() can await its
/// completion before snapshotting transport/folder closing the OTHER race, where a
/// toggle is immediately followed by Send before this reconcile has settled.
func chooseTransport(_ value: SendTransport) { func chooseTransport(_ value: SendTransport) {
guard !isSending else { guard !isSending else {
setStatus("Finish the current send first.") setStatus("Finish the current send first.")
@@ -274,7 +277,7 @@ extension AppModel: SettingsWindowPresenting {
reconcileGeneration += 1 reconcileGeneration += 1
let generation = reconcileGeneration let generation = reconcileGeneration
Task { pendingReconcileTask = Task {
guard generation == self.reconcileGeneration else { return } guard generation == self.reconcileGeneration else { return }
await watcher.setRecordUncommented(value == .airDrop) await watcher.setRecordUncommented(value == .airDrop)
guard generation == self.reconcileGeneration else { return } guard generation == self.reconcileGeneration else { return }
@@ -306,7 +309,7 @@ extension AppModel: SettingsWindowPresenting {
reconcileGeneration += 1 reconcileGeneration += 1
let generation = reconcileGeneration let generation = reconcileGeneration
Task { pendingReconcileTask = Task {
guard generation == self.reconcileGeneration else { return } guard generation == self.reconcileGeneration else { return }
do { do {
try await watcher.updateWatchFolder(url) try await watcher.updateWatchFolder(url)