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:
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user