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)
}
|