summaryrefslogtreecommitdiff
path: root/src/macos/Sources/FileWatcher.swift
blob: 1fee2fd83137d4a5107badd978c3753dbf46202c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
// Per-path live reload for the native app. One source watches the file for
// in-place writes; another watches its parent because editors normally save by
// renaming a new inode over the old one. The file source is rearmed after a
// quiet edge so it follows that replacement rather than the unlinked old inode.

import Darwin
import Dispatch
import Foundation

private final class WatchedPath {
    let pane: UInt8
    let generation: UInt32
    private let path: String
    private let directorySource: DispatchSourceFileSystemObject
    private var fileSource: DispatchSourceFileSystemObject?
    private var active = true
    private var eventSerial: UInt64 = 0

    init?(pane: UInt8, generation: UInt32, path: String) {
        let directory = URL(fileURLWithPath: path)
            .deletingLastPathComponent().path
        let descriptor = directory.withCString {
            Darwin.open($0, O_EVTONLY | O_CLOEXEC)
        }
        guard descriptor >= 0 else { return nil }

        self.pane = pane
        self.generation = generation
        self.path = path
        directorySource = DispatchSource.makeFileSystemObjectSource(
            fileDescriptor: descriptor,
            eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke],
            queue: .main)
        directorySource.setCancelHandler { _ = Darwin.close(descriptor) }
        directorySource.setEventHandler { [weak self] in self?.pathChanged() }
        reopenFileSource()
        directorySource.activate()
    }

    /// The directory source catches rename-over saves, while the file source
    /// catches programs which truncate/write the existing inode. Reopen the
    /// latter after every quiet edge because an atomic save leaves it attached
    /// to the unlinked old inode forever otherwise.
    private func reopenFileSource() {
        if let old = fileSource { old.cancel() }
        fileSource = nil
        guard active else { return }
        let descriptor = path.withCString { Darwin.open($0, O_EVTONLY | O_CLOEXEC) }
        guard descriptor >= 0 else { return }
        let next = DispatchSource.makeFileSystemObjectSource(
            fileDescriptor: descriptor,
            eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke],
            queue: .main)
        next.setCancelHandler { _ = Darwin.close(descriptor) }
        next.setEventHandler { [weak self] in self?.pathChanged() }
        fileSource = next
        next.activate()
    }

    /// Vnode sources can emit several edges for one save. Reset a small quiet
    /// period on every edge; generation plus exact Zig-side identity checks
    /// remain authoritative, so this delay is latency control rather than
    /// correctness state.
    private func pathChanged() {
        guard active else { return }
        eventSerial &+= 1
        let scheduled = eventSerial
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(45)) { [weak self] in
            guard let self, self.active, self.eventSerial == scheduled else { return }
            self.reopenFileSource()
            pardes_watch_changed(self.pane, self.generation)
        }
    }

    func cancel() {
        guard active else { return }
        active = false
        eventSerial &+= 1
        if let old = fileSource { old.cancel() }
        fileSource = nil
        directorySource.cancel()
    }

    deinit { cancel() }
}

private final class FileWatchRegistry {
    static let shared = FileWatchRegistry()
    private var slots: [UInt8: WatchedPath] = [:]

    func set(pane: UInt8, generation: UInt32, path: String?) {
        if let old = slots.removeValue(forKey: pane) { old.cancel() }
        guard let path,
              let watched = WatchedPath(
                pane: pane, generation: generation, path: path)
        else { return }
        slots[pane] = watched
    }
}

/// Called only by libpardes's main-thread effect drain. A nil pointer stops the
/// slot. The byte count avoids a second sentinel copy in Zig; Foundation paths
/// are Unicode, so malformed UTF-8 is rejected rather than watched under a
/// replacement spelling that could name a different file.
@_cdecl("pardes_host_watch_file")
func pardesHostWatchFile(
    _ pane: UInt8,
    _ generation: UInt32,
    _ path: UnsafePointer<UInt8>?,
    _ pathLength: Int
) {
    guard let path, pathLength > 0 else {
        FileWatchRegistry.shared.set(pane: pane, generation: generation, path: nil)
        return
    }
    let bytes = UnsafeBufferPointer(start: path, count: pathLength)
    guard let value = String(bytes: bytes, encoding: .utf8) else {
        FileWatchRegistry.shared.set(pane: pane, generation: generation, path: nil)
        return
    }
    FileWatchRegistry.shared.set(pane: pane, generation: generation, path: value)
}