Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Sources/CodeCaps/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe
DispatchQueue.main.async { [weak self] in self?.navigatingPlanWarning = false }
}

private var snapshotTerminationPending = false

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard !snapshotTerminationPending else { return .terminateLater }
snapshotTerminationPending = true
Comment thread
kody-ai[bot] marked this conversation as resolved.
Resolved
model.stop()
Task { @MainActor in
_ = await model.flushSnapshotWrites()
sender.reply(toApplicationShouldTerminate: true)
}
return .terminateLater
}

func applicationWillTerminate(_ notification: Notification) { model.stop() }
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
Expand Down
15 changes: 11 additions & 4 deletions Sources/CodeCaps/MonitorModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,14 @@ final class MonitorModel: ObservableObject {
clockTimer?.invalidate()
}

/// Suspend instead of blocking the main actor, which queued completions need.
/// The caller may terminate after the deadline even if filesystem I/O is stuck.
func flushSnapshotWrites(timeout: Duration = .seconds(2)) async -> Bool {
let chain = snapshotWriteChain
snapshotWriteChain = nil
return await SnapshotWriteDrain.wait(for: chain, timeout: timeout)
Comment thread
kody-ai[bot] marked this conversation as resolved.
Resolved
Comment thread
swu-echo[bot] marked this conversation as resolved.
Resolved
}

/// Fires only while something is at risk, so the forecast keeps measuring a
/// rate from fresh samples instead of a stale gap.
///
Expand Down Expand Up @@ -1781,7 +1789,7 @@ final class MonitorModel: ObservableObject {
localWindows = []
updateActiveAnomalies([])
activePlanChanges = []
if !skipsSnapshotIOForTesting { try? LocalQuotaSnapshot.remove() }
if !skipsSnapshotIOForTesting { enqueueHandoffWrite(windows: nil) }
rebuildLocalState(recordSamples: false)
}
refresh()
Expand Down Expand Up @@ -2811,14 +2819,13 @@ final class MonitorModel: ObservableObject {
guard !skipsSnapshotIOForTesting else { return }
let marks = exportedCustomMarks()
let stamp = now
let order = platformOrder
enqueueSnapshotWrite({
try LocalQuotaSnapshot.writeWidgetSnapshot(windows: windows, customMarks: marks, now: stamp)
}, completion: { [weak self] succeeded in
guard let self else { return }
if succeeded {
self.widgetSharingError = nil
UserDefaults(suiteName: LocalQuotaSnapshot.appGroupId)?.set(order, forKey: "platformOrder")
UserDefaults(suiteName: LocalQuotaSnapshot.appGroupId)?.set(self.platformOrder, forKey: "platformOrder")
#if canImport(WidgetKit)
WidgetCenter.shared.reloadAllTimelines()
#endif
Expand Down Expand Up @@ -2869,7 +2876,7 @@ final class MonitorModel: ObservableObject {

/// Runs `work` on a background executor after every previously enqueued
/// write, then reports success back on the main actor.
private func enqueueSnapshotWrite(_ work: @escaping @Sendable () throws -> Void,
func enqueueSnapshotWrite(_ work: @escaping @Sendable () throws -> Void,
completion: @escaping @MainActor @Sendable (Bool) -> Void) {
let previous = snapshotWriteChain
snapshotWriteChain = Task.detached(priority: .utility) {
Expand Down
36 changes: 36 additions & 0 deletions Sources/CodeCaps/SnapshotWriteDrain.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

/// A deadline bounds how long termination waits, not the underlying disk write.
/// Unstructured waiters intentionally do not join the possibly stuck task on timeout.
@MainActor
enum SnapshotWriteDrain {
private final class ResultGate {
var continuation: CheckedContinuation<Bool, Never>?
var timeoutTask: Task<Void, Never>?

func finish(_ drained: Bool) {
guard let continuation else { return }
self.continuation = nil
timeoutTask?.cancel()
timeoutTask = nil
continuation.resume(returning: drained)
}
}

static func wait(for chain: Task<Void, Never>?, timeout: Duration) async -> Bool {
guard let chain else { return true }
return await withCheckedContinuation { continuation in
let gate = ResultGate()
gate.continuation = continuation
Task { @MainActor in
await chain.value
gate.finish(true)
}
gate.timeoutTask = Task { @MainActor in
do { try await Task.sleep(for: timeout) }
catch { return }
gate.finish(false)
}
}
}
}
93 changes: 93 additions & 0 deletions Tests/CodeCapsTests/SnapshotWriteDrainTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import Foundation
import XCTest
@testable import CodeCaps

@MainActor
final class SnapshotWriteDrainTests: XCTestCase {
func testEmptyQueueDoesNotWait() async {
let drained = await SnapshotWriteDrain.wait(for: nil, timeout: .seconds(2))
XCTAssertTrue(drained)
}

func testMainActorCompletionRunsBeforeDrainReturns() async {
var completed = false
let chain = Task { @MainActor in
await Task.yield()
completed = true
}
let drained = await SnapshotWriteDrain.wait(for: chain, timeout: .seconds(2))
XCTAssertTrue(drained)
XCTAssertTrue(completed)
}

func testStopAndFlushWaitForRealQueuedFileAndMainActorCompletion() async throws {
let name = "cc196-snapshot-tests-" + UUID().uuidString
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defaults.set(false, forKey: "localEnabled")
defer { defaults.removePersistentDomain(forName: name) }
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appendingPathComponent("snapshot.txt")
let model = MonitorModel(defaults: defaults)
model.skipsSnapshotIOForTesting = true
var completion = false
model.enqueueSnapshotWrite({ try Data("ordered snapshot".utf8).write(to: file) },
completion: { succeeded in completion = succeeded })
model.stop()
let drained = await model.flushSnapshotWrites()
XCTAssertTrue(drained)
XCTAssertTrue(completion)
XCTAssertEqual(try String(contentsOf: file), "ordered snapshot")
}

func testDeadlineDoesNotJoinStuckWriteAndLateCompletionIsSafe() async {
var release: CheckedContinuation<Void, Never>?
var completed = false
let chain = Task { @MainActor in
await withCheckedContinuation { release = $0 }
completed = true
}
while release == nil { await Task.yield() }
let clock = ContinuousClock()
let start = clock.now
let drained = await SnapshotWriteDrain.wait(for: chain, timeout: .milliseconds(20))
XCTAssertFalse(drained)
XCTAssertLessThan(start.duration(to: clock.now), .seconds(1))
XCTAssertFalse(completed)
release?.resume()
await chain.value
XCTAssertTrue(completed)
}
}

final class SnapshotTerminationIntegrationTests: XCTestCase {
private func source(_ file: String) throws -> String {
let root = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
return try String(contentsOf: root.appendingPathComponent("Sources/CodeCaps/" + file))
}

func testTerminationDefersReplyUntilAsyncFlushWithoutBlockingMainActor() throws {
let delegate = try source("AppDelegate.swift")
XCTAssertTrue(delegate.contains("func applicationShouldTerminate("))
XCTAssertTrue(delegate.contains("_ = await model.flushSnapshotWrites()"))
XCTAssertTrue(delegate.contains("sender.reply(toApplicationShouldTerminate: true)"))
XCTAssertTrue(delegate.contains("return .terminateLater"))
let model = try source("MonitorModel.swift")
XCTAssertTrue(model.contains("timeout: Duration = .seconds(2)"))
XCTAssertTrue(model.contains("await SnapshotWriteDrain.wait(for: chain, timeout: timeout)"))
XCTAssertFalse(model.contains("group.wait("))
XCTAssertFalse(model.contains("sem.wait("))
}

func testDisableQueuesRemovalAndWidgetUsesCompletionTimeOrder() throws {
let model = try source("MonitorModel.swift")
let disable = try XCTUnwrap(model.range(of: "func setLocalEnabled("))
let next = try XCTUnwrap(model.range(of: "func setProviderChecksEnabled("))
let block = String(model[disable.lowerBound..<next.lowerBound])
XCTAssertTrue(block.contains("enqueueHandoffWrite(windows: nil)"))
XCTAssertTrue(model.contains(".set(self.platformOrder, forKey: \"platformOrder\")"))
XCTAssertFalse(model.contains("let order = platformOrder"))
}
}
Loading