|
| 1 | +package engineering.swat.watch.impl.mac; |
| 2 | + |
| 3 | +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.FILE_EVENTS; |
| 4 | +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.NO_DEFER; |
| 5 | +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCreateFlag.WATCH_ROOT; |
| 6 | +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_CREATED; |
| 7 | +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_INODE_META_MOD; |
| 8 | +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_MODIFIED; |
| 9 | +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.ITEM_REMOVED; |
| 10 | +import static engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamEventFlag.MUST_SCAN_SUB_DIRS; |
| 11 | +import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; |
| 12 | +import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; |
| 13 | +import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; |
| 14 | +import static java.nio.file.StandardWatchEventKinds.OVERFLOW; |
| 15 | + |
| 16 | +import java.io.Closeable; |
| 17 | +import java.io.IOException; |
| 18 | +import java.nio.file.Path; |
| 19 | + |
| 20 | +import org.checkerframework.checker.nullness.qual.Nullable; |
| 21 | + |
| 22 | +import com.sun.jna.Memory; |
| 23 | +import com.sun.jna.Native; |
| 24 | +import com.sun.jna.Pointer; |
| 25 | +import com.sun.jna.platform.mac.CoreFoundation; |
| 26 | +import com.sun.jna.platform.mac.CoreFoundation.CFArrayRef; |
| 27 | +import com.sun.jna.platform.mac.CoreFoundation.CFIndex; |
| 28 | +import com.sun.jna.platform.mac.CoreFoundation.CFStringRef; |
| 29 | + |
| 30 | +import engineering.swat.watch.impl.mac.apis.DispatchObjects; |
| 31 | +import engineering.swat.watch.impl.mac.apis.DispatchQueue; |
| 32 | +import engineering.swat.watch.impl.mac.apis.FileSystemEvents; |
| 33 | +import engineering.swat.watch.impl.mac.apis.FileSystemEvents.FSEventStreamCallback; |
| 34 | + |
| 35 | +// Note: This file is designed to be the only place in this package where JNA is |
| 36 | +// used and/or the native APIs are called. If the need to do so arises outside |
| 37 | +// this file, consider extending this file to offer the required services |
| 38 | +// without exposing JNA and/or the native APIs. |
| 39 | + |
| 40 | +/** |
| 41 | + * <p> |
| 42 | + * Stream of native events for a path, issued by macOS. |
| 43 | + * </p> |
| 44 | + * |
| 45 | + * <p> |
| 46 | + * Note: Methods {@link #open()} and {@link #close()} synchronize on this object |
| 47 | + * to avoid races. The synchronization overhead is expected to be negligible, as |
| 48 | + * these methods are expected to be rarely called. |
| 49 | + * </p> |
| 50 | + */ |
| 51 | +public class NativeEventStream implements Closeable { |
| 52 | + |
| 53 | + // Native APIs |
| 54 | + private static final CoreFoundation CF = CoreFoundation.INSTANCE; |
| 55 | + private static final DispatchObjects DO = DispatchObjects.INSTANCE; |
| 56 | + private static final DispatchQueue DQ = DispatchQueue.INSTANCE; |
| 57 | + private static final FileSystemEvents FSE = FileSystemEvents.INSTANCE; |
| 58 | + |
| 59 | + // Native memory (automatically deallocated when set to `null`) |
| 60 | + private volatile @Nullable FSEventStreamCallback callback; |
| 61 | + private volatile @Nullable Pointer stream; |
| 62 | + private volatile @Nullable Pointer queue; |
| 63 | + |
| 64 | + private final Path path; |
| 65 | + private final NativeEventHandler handler; |
| 66 | + private volatile boolean closed; |
| 67 | + |
| 68 | + public NativeEventStream(Path path, NativeEventHandler handler) throws IOException { |
| 69 | + this.path = path.toRealPath(); // Resolve symbolic links |
| 70 | + this.handler = handler; |
| 71 | + this.closed = true; |
| 72 | + } |
| 73 | + |
| 74 | + public synchronized void open() { |
| 75 | + if (!closed) { |
| 76 | + throw new IllegalStateException("Stream already open"); |
| 77 | + } else { |
| 78 | + closed = false; |
| 79 | + } |
| 80 | + |
| 81 | + // Allocate native memory. (Checker Framework: The local variables are |
| 82 | + // `@NonNull` copies of the `@Nullable` fields.) |
| 83 | + var callback = this.callback = createCallback(path, handler); |
| 84 | + var stream = this.stream = createFSEventStream(path, callback); |
| 85 | + var queue = this.queue = createDispatchQueue(); |
| 86 | + |
| 87 | + // Start the stream |
| 88 | + FSE.FSEventStreamSetDispatchQueue(stream, queue); |
| 89 | + FSE.FSEventStreamStart(stream); |
| 90 | + } |
| 91 | + |
| 92 | + private static FSEventStreamCallback createCallback(Path path, NativeEventHandler handler) { |
| 93 | + return new FSEventStreamCallback() { |
| 94 | + @Override |
| 95 | + public void callback(Pointer streamRef, Pointer clientCallBackInfo, |
| 96 | + long numEvents, Pointer eventPaths, Pointer eventFlags, Pointer eventIds) { |
| 97 | + // This function is called each time native events are issued by |
| 98 | + // macOS. The purpose of this function is to perform the minimal |
| 99 | + // amount of processing to hide the native APIs from downstream |
| 100 | + // consumers, who are offered native events via `handler`. |
| 101 | + |
| 102 | + var paths = eventPaths.getStringArray(0, (int) numEvents); |
| 103 | + var flags = eventFlags.getIntArray(0, (int) numEvents); |
| 104 | + |
| 105 | + for (var i = 0; i < numEvents; i++) { |
| 106 | + var context = path.relativize(Path.of(paths[i])); |
| 107 | + |
| 108 | + // Note: Multiple "physical" native events might be merged |
| 109 | + // into a single "logical" native event, so the following |
| 110 | + // series of checks should be if-statements (instead of |
| 111 | + // if/else-statements). |
| 112 | + if (any(flags[i], ITEM_CREATED.mask)) { |
| 113 | + handler.handle(ENTRY_CREATE, context); |
| 114 | + } |
| 115 | + if (any(flags[i], ITEM_REMOVED.mask)) { |
| 116 | + handler.handle(ENTRY_DELETE, context); |
| 117 | + } |
| 118 | + if (any(flags[i], ITEM_MODIFIED.mask | ITEM_INODE_META_MOD.mask)) { |
| 119 | + handler.handle(ENTRY_MODIFY, context); |
| 120 | + } |
| 121 | + if (any(flags[i], MUST_SCAN_SUB_DIRS.mask)) { |
| 122 | + handler.handle(OVERFLOW, null); |
| 123 | + } |
| 124 | + } |
| 125 | + } |
| 126 | + |
| 127 | + private boolean any(int bits, int mask) { |
| 128 | + return (bits & mask) != 0; |
| 129 | + } |
| 130 | + }; |
| 131 | + } |
| 132 | + |
| 133 | + private static Pointer createFSEventStream(Path path, FSEventStreamCallback callback) { |
| 134 | + try ( |
| 135 | + var pathsToWatch = new Strings(path.toString()); |
| 136 | + ) { |
| 137 | + var allocator = CF.CFAllocatorGetDefault(); |
| 138 | + var context = Pointer.NULL; |
| 139 | + var sinceWhen = FSE.FSEventsGetCurrentEventId(); |
| 140 | + var latency = 0.15; |
| 141 | + var flags = NO_DEFER.mask | WATCH_ROOT.mask | FILE_EVENTS.mask; |
| 142 | + return FSE.FSEventStreamCreate(allocator, callback, context, pathsToWatch.toCFArray(), sinceWhen, latency, flags); |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + private static Pointer createDispatchQueue() { |
| 147 | + var label = "engineering.swat.watch"; |
| 148 | + var attr = Pointer.NULL; |
| 149 | + return DQ.dispatch_queue_create(label, attr); |
| 150 | + } |
| 151 | + |
| 152 | + // -- Closeable -- |
| 153 | + |
| 154 | + @Override |
| 155 | + public synchronized void close() { |
| 156 | + if (closed) { |
| 157 | + throw new IllegalStateException("Stream is already closed"); |
| 158 | + } else { |
| 159 | + closed = true; |
| 160 | + } |
| 161 | + |
| 162 | + // Stop the stream |
| 163 | + if (stream != null) { |
| 164 | + var streamNonNull = stream; // Checker Framework: `@NonNull` copy of `@Nullable` field |
| 165 | + FSE.FSEventStreamStop(streamNonNull); |
| 166 | + FSE.FSEventStreamSetDispatchQueue(streamNonNull, Pointer.NULL); |
| 167 | + FSE.FSEventStreamInvalidate(streamNonNull); |
| 168 | + FSE.FSEventStreamRelease(streamNonNull); |
| 169 | + } |
| 170 | + if (queue != null) { |
| 171 | + DO.dispatch_release(queue); |
| 172 | + } |
| 173 | + |
| 174 | + // Deallocate native memory |
| 175 | + callback = null; |
| 176 | + stream = null; |
| 177 | + queue = null; |
| 178 | + } |
| 179 | +} |
| 180 | + |
| 181 | +/** |
| 182 | + * Array of strings in native memory, needed to create a new native event stream |
| 183 | + * (i.e., the {@code pathsToWatch} argument of {@code FSEventStreamCreate} is an |
| 184 | + * array of strings). |
| 185 | + */ |
| 186 | +class Strings implements AutoCloseable { |
| 187 | + |
| 188 | + // Native APIs |
| 189 | + private static final CoreFoundation CF = CoreFoundation.INSTANCE; |
| 190 | + |
| 191 | + // Native memory |
| 192 | + private final CFStringRef[] strings; |
| 193 | + private final CFArrayRef array; |
| 194 | + |
| 195 | + private volatile boolean closed = false; |
| 196 | + |
| 197 | + public Strings(String... strings) { |
| 198 | + // Allocate native memory |
| 199 | + this.strings = createCFStrings(strings); |
| 200 | + this.array = createCFArray(this.strings); |
| 201 | + } |
| 202 | + |
| 203 | + public CFArrayRef toCFArray() { |
| 204 | + if (closed) { |
| 205 | + throw new IllegalStateException("Paths already deallocated"); |
| 206 | + } |
| 207 | + return array; |
| 208 | + } |
| 209 | + |
| 210 | + private static CFStringRef[] createCFStrings(String[] pathsToWatch) { |
| 211 | + var n = pathsToWatch.length; |
| 212 | + |
| 213 | + var strings = new CFStringRef[n]; |
| 214 | + for (int i = 0; i < n; i++) { |
| 215 | + strings[i] = CFStringRef.createCFString(pathsToWatch[i]); |
| 216 | + } |
| 217 | + return strings; |
| 218 | + } |
| 219 | + |
| 220 | + private static CFArrayRef createCFArray(CFStringRef[] strings) { |
| 221 | + var n = strings.length; |
| 222 | + var size = Native.getNativeSize(CFStringRef.class); |
| 223 | + |
| 224 | + // Create a temporary array of pointers to the strings (automatically |
| 225 | + // freed when `values` goes out of scope) |
| 226 | + var values = new Memory(n * size); |
| 227 | + for (int i = 0; i < n; i++) { |
| 228 | + values.setPointer(i * size, strings[i].getPointer()); |
| 229 | + } |
| 230 | + |
| 231 | + // Create a permanent array based on the temporary array |
| 232 | + var alloc = CF.CFAllocatorGetDefault(); |
| 233 | + var numValues = new CFIndex(n); |
| 234 | + var callBacks = Pointer.NULL; |
| 235 | + return CF.CFArrayCreate(alloc, values, numValues, callBacks); |
| 236 | + } |
| 237 | + |
| 238 | + // -- AutoCloseable -- |
| 239 | + |
| 240 | + @Override |
| 241 | + public void close() { |
| 242 | + if (closed) { |
| 243 | + throw new IllegalStateException("Paths already deallocated"); |
| 244 | + } else { |
| 245 | + closed = true; |
| 246 | + } |
| 247 | + |
| 248 | + // Deallocate native memory |
| 249 | + for (var s : strings) { |
| 250 | + if (s != null) { |
| 251 | + s.release(); |
| 252 | + } |
| 253 | + } |
| 254 | + if (array != null) { |
| 255 | + array.release(); |
| 256 | + } |
| 257 | + } |
| 258 | +} |
0 commit comments