diff --git a/apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift b/apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift index a51eee43d7ea..9fec545456b6 100644 --- a/apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift +++ b/apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift @@ -18,12 +18,13 @@ enum SimpleTaskSupport { task: inout Task?, interval: TimeInterval, sleep: @escaping @Sendable (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) }, - operation: @escaping @Sendable () async -> Void) - { + operation: @escaping @Sendable () async -> Void + ) { guard task == nil else { return } task = Task.detached { await operation() while await self.waitForNextOperation(interval: interval, sleep: sleep) { + guard !Task.isCancelled else { break } await operation() } } @@ -33,18 +34,22 @@ enum SimpleTaskSupport { task: inout Task?, delay: TimeInterval, sleep: @escaping @Sendable (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) }, - operation: @escaping @MainActor @Sendable () async -> Void) - { + beforeOperationCheck: @escaping @Sendable () -> Void = {}, + operation: @escaping @MainActor @Sendable () async -> Void + ) { task?.cancel() task = Task { guard await self.waitForNextOperation(interval: delay, sleep: sleep) else { return } + beforeOperationCheck() + guard !Task.isCancelled else { return } await operation() } } nonisolated static func waitForNextOperation( interval: TimeInterval, - sleep: @escaping @Sendable (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) }) async + sleep: @escaping @Sendable (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) } + ) async -> Bool { guard !Task.isCancelled else { return false } diff --git a/apps/macos/Tests/OpenClawIPCTests/SimpleTaskSupportTests.swift b/apps/macos/Tests/OpenClawIPCTests/SimpleTaskSupportTests.swift index b00b6d53380c..5502c5861244 100644 --- a/apps/macos/Tests/OpenClawIPCTests/SimpleTaskSupportTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/SimpleTaskSupportTests.swift @@ -1,12 +1,12 @@ -import Testing @testable import OpenClaw +import Testing private actor SimpleTaskSignal { private var signaled = false private var waiters: [CheckedContinuation] = [] func signal() { - self.signaled = true + signaled = true let waiters = self.waiters self.waiters.removeAll() for waiter in waiters { @@ -15,7 +15,7 @@ private actor SimpleTaskSignal { } func wait() async { - if self.signaled { + if signaled { return } await withCheckedContinuation { continuation in @@ -28,11 +28,11 @@ private actor SimpleTaskOperationProbe { private var recordedCalls: [String] = [] func recordCall(_ value: String) { - self.recordedCalls.append(value) + recordedCalls.append(value) } func calls() -> [String] { - self.recordedCalls + recordedCalls } } @@ -53,7 +53,8 @@ struct SimpleTaskSupportTests { }, operation: { await operation.recordCall("loop") - }) + } + ) await sleepStarted.wait() guard let runningTask = task else { @@ -84,7 +85,8 @@ struct SimpleTaskSupportTests { }, operation: { await operation.recordCall("first") - }) + } + ) await firstSleepStarted.wait() guard let firstTask = task else { @@ -98,7 +100,8 @@ struct SimpleTaskSupportTests { sleep: { _ in }, operation: { await operation.recordCall("second") - }) + } + ) guard let secondTask = task else { Issue.record("replacement task did not start") return @@ -111,4 +114,36 @@ struct SimpleTaskSupportTests { #expect(await operation.calls() == ["second"]) #expect(task == nil) } + + @Test + @MainActor + func `cancelling after sleep completes does not run the scheduled operation`() async { + let operation = SimpleTaskOperationProbe() + var task: Task? + + SimpleTaskSupport.schedule( + task: &task, + delay: 0, + sleep: { _ in }, + beforeOperationCheck: { + withUnsafeCurrentTask { currentTask in + currentTask?.cancel() + } + }, + operation: { + await operation.recordCall("stale") + } + ) + + guard let runningTask = task else { + Issue.record("scheduled task did not start") + return + } + + await runningTask.value + SimpleTaskSupport.stop(task: &task) + + #expect(await operation.calls().isEmpty) + #expect(task == nil) + } }