From 375e16170d4594fb37f73b2563176827dbb59758 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 20:07:12 +0000 Subject: [PATCH] refactor(macos): dedupe file watcher --- .../Sources/OpenClaw/CanvasFileWatcher.swift | 84 ++----------- .../OpenClaw/CoalescingFSEventsWatcher.swift | 111 +++++++++++++++++ .../Sources/OpenClaw/ConfigFileWatcher.swift | 113 ++++-------------- 3 files changed, 138 insertions(+), 170 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift diff --git a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift index 3cf800fd10..3ed0d67ffb 100644 --- a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift +++ b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift @@ -1,17 +1,13 @@ -import CoreServices import Foundation final class CanvasFileWatcher: @unchecked Sendable { - private let url: URL - private let queue: DispatchQueue - private var stream: FSEventStreamRef? - private var pending = false - private let onChange: () -> Void + private let watcher: CoalescingFSEventsWatcher init(url: URL, onChange: @escaping () -> Void) { - self.url = url - self.queue = DispatchQueue(label: "ai.openclaw.canvaswatcher") - self.onChange = onChange + self.watcher = CoalescingFSEventsWatcher( + paths: [url.path], + queueLabel: "ai.openclaw.canvaswatcher", + onChange: onChange) } deinit { @@ -19,76 +15,10 @@ final class CanvasFileWatcher: @unchecked Sendable { } func start() { - guard self.stream == nil else { return } - - let retainedSelf = Unmanaged.passRetained(self) - var context = FSEventStreamContext( - version: 0, - info: retainedSelf.toOpaque(), - retain: nil, - release: { pointer in - guard let pointer else { return } - Unmanaged.fromOpaque(pointer).release() - }, - copyDescription: nil) - - let paths = [self.url.path] as CFArray - let flags = FSEventStreamCreateFlags( - kFSEventStreamCreateFlagFileEvents | - kFSEventStreamCreateFlagUseCFTypes | - kFSEventStreamCreateFlagNoDefer) - - guard let stream = FSEventStreamCreate( - kCFAllocatorDefault, - Self.callback, - &context, - paths, - FSEventStreamEventId(kFSEventStreamEventIdSinceNow), - 0.05, - flags) - else { - retainedSelf.release() - return - } - - self.stream = stream - FSEventStreamSetDispatchQueue(stream, self.queue) - if FSEventStreamStart(stream) == false { - self.stream = nil - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } + self.watcher.start() } func stop() { - guard let stream = self.stream else { return } - self.stream = nil - FSEventStreamStop(stream) - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } -} - -extension CanvasFileWatcher { - private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in - guard let info else { return } - let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() - watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags) - } - - private func handleEvents(numEvents: Int, eventFlags: UnsafePointer?) { - guard numEvents > 0 else { return } - guard eventFlags != nil else { return } - - // Coalesce rapid changes (common during builds/atomic saves). - if self.pending { return } - self.pending = true - self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in - guard let self else { return } - self.pending = false - self.onChange() - } + self.watcher.stop() } } diff --git a/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift b/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift new file mode 100644 index 0000000000..7999123dbe --- /dev/null +++ b/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift @@ -0,0 +1,111 @@ +import CoreServices +import Foundation + +final class CoalescingFSEventsWatcher: @unchecked Sendable { + private let queue: DispatchQueue + private var stream: FSEventStreamRef? + private var pending = false + + private let paths: [String] + private let shouldNotify: (Int, UnsafeMutableRawPointer?) -> Bool + private let onChange: () -> Void + private let coalesceDelay: TimeInterval + + init( + paths: [String], + queueLabel: String, + coalesceDelay: TimeInterval = 0.12, + shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true }, + onChange: @escaping () -> Void + ) { + self.paths = paths + self.queue = DispatchQueue(label: queueLabel) + self.coalesceDelay = coalesceDelay + self.shouldNotify = shouldNotify + self.onChange = onChange + } + + deinit { + self.stop() + } + + func start() { + guard self.stream == nil else { return } + + let retainedSelf = Unmanaged.passRetained(self) + var context = FSEventStreamContext( + version: 0, + info: retainedSelf.toOpaque(), + retain: nil, + release: { pointer in + guard let pointer else { return } + Unmanaged.fromOpaque(pointer).release() + }, + copyDescription: nil) + + let paths = self.paths as CFArray + let flags = FSEventStreamCreateFlags( + kFSEventStreamCreateFlagFileEvents | + kFSEventStreamCreateFlagUseCFTypes | + kFSEventStreamCreateFlagNoDefer) + + guard let stream = FSEventStreamCreate( + kCFAllocatorDefault, + Self.callback, + &context, + paths, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0.05, + flags) + else { + retainedSelf.release() + return + } + + self.stream = stream + FSEventStreamSetDispatchQueue(stream, self.queue) + if FSEventStreamStart(stream) == false { + self.stream = nil + FSEventStreamSetDispatchQueue(stream, nil) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } + } + + func stop() { + guard let stream = self.stream else { return } + self.stream = nil + FSEventStreamStop(stream) + FSEventStreamSetDispatchQueue(stream, nil) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } +} + +extension CoalescingFSEventsWatcher { + private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in + guard let info else { return } + let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() + watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags) + } + + private func handleEvents( + numEvents: Int, + eventPaths: UnsafeMutableRawPointer?, + eventFlags: UnsafePointer? + ) { + guard numEvents > 0 else { return } + guard eventFlags != nil else { return } + guard self.shouldNotify(numEvents, eventPaths) else { return } + + // Coalesce rapid changes (common during builds/atomic saves). + if self.pending { return } + self.pending = true + self.queue.asyncAfter(deadline: .now() + self.coalesceDelay) { [weak self] in + guard let self else { return } + self.pending = false + self.onChange() + } + } +} + diff --git a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift b/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift index 23689f1fb9..4434443497 100644 --- a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift +++ b/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift @@ -1,23 +1,34 @@ -import CoreServices import Foundation final class ConfigFileWatcher: @unchecked Sendable { private let url: URL - private let queue: DispatchQueue - private var stream: FSEventStreamRef? - private var pending = false - private let onChange: () -> Void private let watchedDir: URL private let targetPath: String private let targetName: String + private let watcher: CoalescingFSEventsWatcher init(url: URL, onChange: @escaping () -> Void) { self.url = url - self.queue = DispatchQueue(label: "ai.openclaw.configwatcher") - self.onChange = onChange self.watchedDir = url.deletingLastPathComponent() self.targetPath = url.path self.targetName = url.lastPathComponent + let watchedDirPath = self.watchedDir.path + let targetPath = self.targetPath + let targetName = self.targetName + self.watcher = CoalescingFSEventsWatcher( + paths: [watchedDirPath], + queueLabel: "ai.openclaw.configwatcher", + shouldNotify: { _, eventPaths in + guard let eventPaths else { return true } + let paths = unsafeBitCast(eventPaths, to: NSArray.self) + for case let path as String in paths { + if path == targetPath { return true } + if path.hasSuffix("/\(targetName)") { return true } + if path == watchedDirPath { return true } + } + return false + }, + onChange: onChange) } deinit { @@ -25,94 +36,10 @@ final class ConfigFileWatcher: @unchecked Sendable { } func start() { - guard self.stream == nil else { return } - - let retainedSelf = Unmanaged.passRetained(self) - var context = FSEventStreamContext( - version: 0, - info: retainedSelf.toOpaque(), - retain: nil, - release: { pointer in - guard let pointer else { return } - Unmanaged.fromOpaque(pointer).release() - }, - copyDescription: nil) - - let paths = [self.watchedDir.path] as CFArray - let flags = FSEventStreamCreateFlags( - kFSEventStreamCreateFlagFileEvents | - kFSEventStreamCreateFlagUseCFTypes | - kFSEventStreamCreateFlagNoDefer) - - guard let stream = FSEventStreamCreate( - kCFAllocatorDefault, - Self.callback, - &context, - paths, - FSEventStreamEventId(kFSEventStreamEventIdSinceNow), - 0.05, - flags) - else { - retainedSelf.release() - return - } - - self.stream = stream - FSEventStreamSetDispatchQueue(stream, self.queue) - if FSEventStreamStart(stream) == false { - self.stream = nil - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } + self.watcher.start() } func stop() { - guard let stream = self.stream else { return } - self.stream = nil - FSEventStreamStop(stream) - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } -} - -extension ConfigFileWatcher { - private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in - guard let info else { return } - let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() - watcher.handleEvents( - numEvents: numEvents, - eventPaths: eventPaths, - eventFlags: eventFlags) - } - - private func handleEvents( - numEvents: Int, - eventPaths: UnsafeMutableRawPointer?, - eventFlags: UnsafePointer?) - { - guard numEvents > 0 else { return } - guard eventFlags != nil else { return } - guard self.matchesTarget(eventPaths: eventPaths) else { return } - - if self.pending { return } - self.pending = true - self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in - guard let self else { return } - self.pending = false - self.onChange() - } - } - - private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool { - guard let eventPaths else { return true } - let paths = unsafeBitCast(eventPaths, to: NSArray.self) - for case let path as String in paths { - if path == self.targetPath { return true } - if path.hasSuffix("/\(self.targetName)") { return true } - if path == self.watchedDir.path { return true } - } - return false + self.watcher.stop() } }