// Our copy of the stdlib module, for File_Index. Changes: pending changes are deduplicated through a // table; OVERFLOW reports lost events and IS_FOLDER a known folder; on Linux, enumerate_on_add can // skip the recursive walk (the caller adds every folder itself), process_changes drains the whole // inotify queue, IN_IGNORED drops its watch, and running out of watches (ENOSPC) is recorded in // unwatched instead of failing; on macOS, the last event ID is kept, a stream can replay from a // saved one (since_event_id), and dropped events or an unusable history arrive as OVERFLOW. // // At startup, call init() to create one or more watchers. // Start watching directories by calling add_directories(). This will watch this directory and (and all subdirectories). // Call deinit() to stop watching. // Periodically call process_changes to get notified of any changes that have happened since the last call. // Your callback will be called for every change. File_Change :: struct { full_path: string; // The fully-pathed filename. events: File_Change_Event; // @ToDo: We should probably replace this with Apollo_Time time_of_last_change := 0.0; // What time it last changed. It's a bad idea to use floating-point numbers for time, but, we did that here. This time is computed by Basic.seconds_since_init(). Maybe we should let you override this. time_of_first_change := 0.0; // When this pending change was first seen; see MAX_SETTLE_SECONDS. } // A path that keeps changing (a log being appended to, a download, a browser database) never goes // quiet for merge_window_seconds, so it is emitted anyway once it has been pending this long. MAX_SETTLE_SECONDS :: 1.0; // The pool holding pending changes is reset when nothing is pending. While changes keep arriving it // never empties, so it is rebuilt with only the pending changes at most this often. POOL_REBUILD_SECONDS :: 10.0; File_Change_Event :: enum_flags { ADDED; MODIFIED; // You might get two MOVED events if a file gets renamed, one for the old path and one for the new path. // File_Watcher will add the modifiers MOVED_FROM or MOVED_TO to a MOVED event if it knows that this event is for the old path or new path, respectively. // But for example on macOS you might get just two MOVED events and have to check the full_path and see if that file still exists to know which part of the move the event belongs to. // Also, you might only get events for part of the move if the file gets moved from an unwatched folder into a watched folder or vice versa. MOVED; MOVED_FROM; // Will be combined with MOVED if we know that the MOVED event is for the old path. MOVED_TO; // Will be combined with MOVED if we know that the MOVED event is for the new path. ATTRIBUTES_CHANGED; REMOVED; SCAN_CHILDREN; // Indicates that you might need to check all children of this directory for changes. If a directory gets added/moved we don't automatically get change notifications for all of its children. OVERFLOW; // Events were lost (Linux IN_Q_OVERFLOW); anything watched may have changed. full_path is empty. IS_FOLDER; // The platform said the path is a folder (Linux IN_ISDIR, macOS ItemIsDir). ALL_EVENTS :: ADDED | MODIFIED | MOVED | ATTRIBUTES_CHANGED | REMOVED; } DEFAULT_BUFFER_SIZE :: 8000; File_Watcher :: struct (User_Data: Type = void) { File_Watcher_Callback :: #type (watcher: *File_Watcher(User_Data), change: *File_Change, user_data: *User_Data); callback: File_Watcher_Callback; user_data: *User_Data; events_to_watch: File_Change_Event; merge_window_seconds: float; watch_recursively: bool; enumerate_on_add: bool; // Linux: add_directories also watches every subfolder it finds verbose: bool; buffer_size: s32; pending_index: Table(string, s64); // full_path to its index in changes // @Stability: pool needs to be dynamically allocated since we don't control the lifetime of File_Watcher. // If pool were a direct member of File_Watcher and that gets copied and discarded (eg by adding it to an array), then arrays like "changes" could still point to the old copy of Pool. // -rluba, 2022-06-06 pool: *Pool; changes: [..] File_Change; pool_reset_at: float64; // seconds_since_init() pool_allocator: Allocator; // what pool was made with (the caller's at init); rebuild_pool makes and frees with it too #if OS == .WINDOWS { directories: [..] *Directory_Info; external_probe: bool; // the caller checks each directory's path and reports it with report_directory } else #if OS_HAS_INOTIFY { inotify_instance: s32 = -1; inotify_data: *void; inotify_bytes_read: s64; inotify_cursor: s32; path_pool: *Pool; // A second pool to avoid one allocation per directory in the tree we watch watch_paths: Table(s32, string); watched_paths: Table(string, s32); // the reverse of watch_paths, sharing its strings // Watches count against fs.inotify.max_user_watches, which every program of the user shares. // Past max_watches (0: no cap) a new folder is reported in unwatched instead. max_watches: s64; watch_limit_reached: bool; // max_watches or fs.inotify.max_user_watches was reached unwatched: [..] string; // the folders that could not be watched since the caller last cleared it } else #if OS == .MACOS { event_streams: [..] FSEventStreamRef; last_event_id: u64; since_event_id: u64 = kFSEventStreamEventIdSinceNow; // set before add_directories to replay from a saved last_event_id history_done: bool; // the replay since since_event_id has been delivered // Set before add_directories: folders below the ones added whose changes FSEvents should not // deliver. FSEvents takes at most FSEVENTS_MAX_EXCLUSIONS per stream; the rest are ignored. exclusion_paths: [] string; } else #if OS == .PS5 || OS == .NN_SWITCH2 { } else { #assert(false); } } // Use events_to_watch to specify what kinds of events you're interested in. (But you might still get events from categories you didn't ask for, depending on the platform.) // event_buffer_size is allocated _once_ on Linux but _once per directory_ on Windows. (It’s not used at all on macOS.) Therefore, using the same value on Linux and Windows might result in very different total memory usage. init :: (watcher: *$T/File_Watcher, callback: T.File_Watcher_Callback, user_data: *T.User_Data = null, events_to_watch: File_Change_Event = .ALL_EVENTS, merge_window_seconds: float = 0.1, watch_recursively := true, verbose := false, event_buffer_size: s32 = DEFAULT_BUFFER_SIZE, enumerate_on_add := true) -> success: bool { assert(merge_window_seconds >= 0); watcher.callback = callback; watcher.user_data = user_data; watcher.events_to_watch = events_to_watch; watcher.merge_window_seconds = merge_window_seconds; watcher.watch_recursively = watch_recursively; watcher.enumerate_on_add = enumerate_on_add; watcher.verbose = verbose; watcher.buffer_size = event_buffer_size; watcher.pool_allocator = context.allocator; watcher.pool = New(Pool); set_allocators(watcher.pool); // watcher.pool.overwrite_memory = true; // Enable this for basic memory debugging. watcher.changes.allocator.proc = pool_allocator_proc; watcher.changes.allocator.data = watcher.pool; // Not the pool: add_change pushes it, and the pool is reset while the table lives on. watcher.pending_index.allocator = context.default_allocator; #if OS_HAS_INOTIFY { watcher.inotify_instance = inotify_init(IN_NONBLOCK); if watcher.inotify_instance == -1 { log_error("Could not init inotify: %", errno()); free(watcher.pool); return false; } watcher.inotify_data = alloc(event_buffer_size); watcher.path_pool = New(Pool); set_allocators(watcher.path_pool); watcher.unwatched.allocator = context.default_allocator; } #if OS == .MACOS { if !watch_recursively { log_error("Ignoring \"watch_recursively = false\" flag since macOS always watches directories recursively."); } } return true; } add_directories :: (using watcher: *$T/File_Watcher, dir_paths: ..string) -> bool { assert(callback != null, "You must call init() on the File_Watcher before add_directories()!"); #if OS == .WINDOWS { return add_directories_windows(watcher, ..dir_paths); } else #if OS_HAS_INOTIFY { return add_directories_linux(watcher, ..dir_paths); } else #if OS == .MACOS { return add_directories_macos(watcher, ..dir_paths); } else #if OS == .PS5|| OS == .NN_SWITCH2 { return false; } else { assert(false); return false; } } deinit :: (using watcher: $T/File_Watcher) { #if OS == .WINDOWS { for directories { if it.lost { release(it.*); free(it); continue; } // CancelIoEx, not CancelIo: deinit can run on another thread than the one that issued the // read (the app's shutdown thread), and CancelIo cancels only the calling thread's I/O, which // left the wait below blocked until the directory next changed. success := CancelIoEx(it.handle, *it.overlapped); if success { // The cancelled read completes asynchronously, and the kernel writes its buffer and // OVERLAPPED until it does, so wait before freeing them. bytes: s32; GetOverlappedResult(it.handle, *it.overlapped, *bytes, TRUE); } else if GetLastError() != ERROR_NOT_FOUND { // not found: no read was pending System :: #import "System"; error, error_string := System.get_error_value_and_string(); log_error("Couldn't cancel the async io on %: error % (%)", it.name, error, error_string); } release(it.*); free(it); } array_free(directories); } else #if OS_HAS_INOTIFY { close(inotify_instance); free(inotify_data); table_deinit(*watch_paths); table_deinit(*watched_paths); release(path_pool); free(path_pool); } else #if OS == .MACOS { for event_streams { FSEventStreamStop(it); FSEventStreamInvalidate(it); // Remove stream from all run loops FSEventStreamRelease(it); } array_free(event_streams); } else { assert(false); } table_deinit(*pending_index); release(pool); free(pool); } // Process changes of any of the watched files. // If you call this periodically, you can ignore the needs_wait and wait_seconds return values. // But you can use POSIX poll(…) on the watcher’s inotify_instance to wait until changes are pending. // In that case, you should check needs_wait. If it’s true you should call this function again after // wait_seconds have elapsed, even if no further poll changes have happened. process_changes :: (using watcher: *File_Watcher) -> changed: bool, needs_wait: bool, wait_seconds: float64 { assert(callback != null, "You must call init() on the File_Watcher before process_changes()!"); #if OS == .WINDOWS { changed, needs_wait, wait_seconds := process_changes_windows(watcher); } else #if OS_HAS_INOTIFY { changed, needs_wait, wait_seconds := process_changes_linux(watcher); } else #if OS == .MACOS { changed, needs_wait, wait_seconds := process_changes_macos(watcher); } else #if OS == .PS5 || OS == .NN_SWITCH2 { changed := false; needs_wait := false; wait_seconds := 0.0; assert(!watcher.changes); } else { changed := false; needs_wait := false; wait_seconds := 0.0; assert(false); } return changed, needs_wait, wait_seconds; } #if OS == .WINDOWS { Directory_Identity :: struct { volume_serial: u32; index_high: u32; index_low: u32; } // Opens the path, so on an unreachable network share it can block for a long time. directory_identity :: (path: string) -> Directory_Identity, bool { identity, ok := path_identity(path); return identity, ok; } // A bare drive ("C:") names the process's current directory on that drive, not its root, so a // watched drive is always opened and named as its root ("C:/"). watched_name :: (path: string) -> string { name := ifx path.count == 2 && path[1] == #char ":" then tprint("%/", path) else copy_string(path,, temp); path_overwrite_separators(name, #char "/"); return name; } // For a watcher with external_probe set, which never opens a watched directory's path by itself: // the caller checks each path off the thread that processes changes (with directory_identity) // and reports the result here. A watched directory whose path is gone or now names another one // is lost; a lost one is opened again once it is present. report_directory :: (using watcher: *File_Watcher, path: string, present: bool, identity: Directory_Identity) { name := watched_name(path); for info: directories { if info.name != name continue; if info.lost { if present { info.retry_at = 0; retry_lost_directory(watcher, info); } } else if !present || !same_identity(identity, info.identity) { CancelIoEx(info.handle, *info.overlapped); bytes: s32; GetOverlappedResult(info.handle, *info.overlapped, *bytes, TRUE); lose_directory(watcher, info, ifx present then cast(u32) 0 else ERROR_FILE_NOT_FOUND); } } } } FSEVENTS_MAX_EXCLUSIONS :: 8; // macOS: the most File_Watcher.exclusion_paths FSEvents takes #scope_file add_change ::(using watcher: *File_Watcher, full_path: string, events: File_Change_Event) { push_allocator(pool_allocator_proc, pool); now := seconds_since_init(); found, index := table_find(*pending_index, full_path); if found { change := *changes[index]; if watcher.verbose log("Deduplicating changes for %: % + %", full_path, change.events, events); change.events |= events; change.time_of_last_change = xx now; return; } change: File_Change; change.full_path = copy_string(full_path); change.events = events; change.time_of_last_change = xx now; change.time_of_first_change = xx now; array_add(*changes, change); table_add(*pending_index, change.full_path, changes.count - 1); if watcher.verbose log("Change for '%' issued at time %.", full_path, now); } // The pending changes, copied into a fresh pool; the old one also held every change emitted since it // was last reset. rebuild_pool :: (using watcher: *$T/File_Watcher) { // Not the heap: init made the pool with whatever allocator was current, an arena on the app's test threads. push_allocator(pool_allocator); fresh := New(Pool); set_allocators(fresh); rebuilt: [..] File_Change; rebuilt.allocator.proc = pool_allocator_proc; rebuilt.allocator.data = fresh; { push_allocator(pool_allocator_proc, fresh); array_reserve(*rebuilt, changes.count); for changes { change := it; change.full_path = copy_string(it.full_path); array_add(*rebuilt, change); } } release(pool); free(pool); pool = fresh; changes = rebuilt; table_reset(*pending_index); for changes table_add(*pending_index, it.full_path, it_index); } emit_changes :: (using watcher: *$T/File_Watcher) -> changed: bool, needs_wait: bool, wait_seconds: float64 { assert(watcher.merge_window_seconds >= 0); if !changes.count return false, false, 0; now := seconds_since_init(); notified := false; earliest_wait_time: float64 = merge_window_seconds; for * change: changes { #if OS == .MACOS { wait_time : float64 = 0; // The FSEventStream already batches up events for us } else { delta := now - change.time_of_last_change; wait_time := merge_window_seconds - delta; wait_time = min(wait_time, MAX_SETTLE_SECONDS - (now - change.time_of_first_change)); } // if watcher.verbose log("Wait time for %: %", change.*, wait_time); if wait_time <= 0 { if callback callback(watcher, change, user_data); remove change; notified = true; } else if wait_time < earliest_wait_time { earliest_wait_time = wait_time; } } // remove moved the last change into each emitted one's slot, so the indices are rebuilt, in the // table's own slots: this runs on every poll while changes wait. table_reset_keeping_memory(*pending_index); for changes table_add(*pending_index, it.full_path, it_index); if changes.count == 0 { // Nothing to handle, so, we can reset our memory. Note that right now we could in theory grow without bounds if someone keeps throwing // changes at us such that there is always something < RELOAD_DELAY. We could fix this // by having two pools and swapping between them every once in a while, but it doesn't // seem worth it right now as this is meant for friendly production environments. -jblow, 1 March 2021. array_reset(*changes); reset(pool); pool_reset_at = now; } else if now - pool_reset_at >= POOL_REBUILD_SECONDS { rebuild_pool(watcher); pool_reset_at = now; } return notified, (changes.count > 0), earliest_wait_time; } #if OS == .WINDOWS { TRUE :: 1; FALSE :: 0; Directory_Info :: struct { name: string; bytes_returned: s32; overlapped: OVERLAPPED; handle: HANDLE; notify_information: *void; // The read could not be issued or completed, usually because the directory was deleted. // The handle is closed, so the directory can actually go away, and reopening is retried. lost: bool; retry_at: float64; // seconds_since_init(); also when a watched directory is next checked // The handle follows a directory that is renamed or moved, and its reads keep succeeding, so // the path is checked every WATCH_RETRY_SECONDS to still name the same directory. identity: Directory_Identity; } WATCH_RETRY_SECONDS :: 5.0; ERROR_NOTIFY_ENUM_DIR :: 1022; // BY_HANDLE_FILE_INFORMATION, with its FILETIMEs as DWORD pairs to keep the C layout. Watch_File_Information :: struct { attributes: u32; times: [6] u32; volume_serial: u32; size_high: u32; size_low: u32; link_count: u32; index_high: u32; index_low: u32; } #assert size_of(Watch_File_Information) == 52; GetFileInformationByHandle :: (hFile: HANDLE, lpFileInformation: *Watch_File_Information) -> BOOL #foreign watcher_kernel32; CancelIoEx :: (hFile: HANDLE, lpOverlapped: *OVERLAPPED) -> BOOL #foreign watcher_kernel32; ERROR_NOT_FOUND :: 1168; watcher_kernel32 :: #library,system "kernel32"; same_identity :: (a: Directory_Identity, b: Directory_Identity) -> bool { return a.volume_serial == b.volume_serial && a.index_high == b.index_high && a.index_low == b.index_low; } handle_identity :: (handle: HANDLE) -> Directory_Identity, bool { info: Watch_File_Information; if !GetFileInformationByHandle(handle, *info) return .{}, false; return .{info.volume_serial, info.index_high, info.index_low}, true; } path_identity :: (path: string) -> Directory_Identity, bool { handle := CreateFileW(utf8_to_wide(long_path(watched_name(path)),, temp), FILE_READ_ATTRIBUTES, FILE_SHARE_READ|FILE_SHARE_DELETE|FILE_SHARE_WRITE, null, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, null); if handle == INVALID_HANDLE_VALUE return .{}, false; defer CloseHandle(handle); identity, ok := handle_identity(handle); return identity, ok; } release :: (info: Directory_Info) { CloseHandle(info.overlapped.hEvent); if info.handle != INVALID_HANDLE_VALUE CloseHandle(info.handle); free(info.name); free(info.notify_information); } add_directories_windows :: (using watcher: *File_Watcher, dir_paths: ..string) -> bool { for dir_path: dir_paths { // Init and allocate. We allocate one buffer of File_Watcher.buffer_size size for each directory, // which might become big if there are many many directories. This may be something you'd want to tune. event := CreateEventW(null, FALSE, FALSE, null); if event == null return false; name := watched_name(dir_path); handle := open_directory(name); if handle == INVALID_HANDLE_VALUE { System :: #import "System"; error_code, description := System.get_error_value_and_string(); log_error("os_create_directory_notification encountered an error in CreateFileW: %, %\n", error_code, description); CloseHandle(event); return false; } info := New(Directory_Info); info.overlapped.hEvent = event; info.overlapped.Offset = 0; info.handle = handle; info.identity = handle_identity(handle); info.retry_at = seconds_since_init() + WATCH_RETRY_SECONDS; info.name = copy_string(name); // Allocated on the global heap. info.notify_information = alloc(watcher.buffer_size); // Start the first async read right away issued, error := issue_one_read(watcher, info); if !issued lose_directory(watcher, info, error); array_add(*directories, info); } return true; } // As File_Index's long_path: \\?\ lifts MAX_PATH but skips normalization, so only long absolute // paths get it. long_path :: (path: string) -> string { if path.count < MAX_PATH - 12 || begins_with(path, "\\\\?\\") return path; result := copy_string(path,, temp); path_overwrite_separators(result, #char "\\"); if result.count >= 3 && result[1] == #char ":" && result[2] == #char "\\" return tprint("\\\\?\\%", result); if begins_with(result, "\\\\") return tprint("\\\\?\\UNC\\%", advance(result, 2)); return path; } open_directory :: (path: string) -> HANDLE { return CreateFileW(utf8_to_wide(long_path(path),, temp), FILE_LIST_DIRECTORY, FILE_SHARE_READ|FILE_SHARE_DELETE|FILE_SHARE_WRITE, null, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS|FILE_FLAG_OVERLAPPED, null); } // Logs once per loss. The directory is reported as changed, so its contents are checked // against the disk, and again when it is watched again. lose_directory :: (using watcher: *File_Watcher, info: *Directory_Info, error: u32) { if !info.lost { retry := ifx external_probe then "watching it again once it is back" else tprint("retrying every % s", WATCH_RETRY_SECONDS); if error log_error("Stopped watching % (error %); %.\n", info.name, error, retry); else log("Stopped watching %, which was moved or replaced; %.\n", info.name, retry); } info.lost = true; info.retry_at = seconds_since_init() + WATCH_RETRY_SECONDS; if info.handle != INVALID_HANDLE_VALUE { CloseHandle(info.handle); info.handle = INVALID_HANDLE_VALUE; } add_change(watcher, info.name, File_Change_Event.MODIFIED | .SCAN_CHILDREN); } retry_lost_directory :: (using watcher: *File_Watcher, info: *Directory_Info) { if seconds_since_init() < info.retry_at return; info.retry_at = seconds_since_init() + WATCH_RETRY_SECONDS; handle := open_directory(info.name); if handle == INVALID_HANDLE_VALUE return; info.handle = handle; info.identity = handle_identity(handle); issued := issue_one_read(watcher, info); if !issued { CloseHandle(handle); info.handle = INVALID_HANDLE_VALUE; return; } info.lost = false; log("Watching % again.\n", info.name); add_change(watcher, info.name, File_Change_Event.MODIFIED | .SCAN_CHILDREN); } issue_one_read :: (using watcher: *File_Watcher, info: *Directory_Info) -> issued: bool, error: u32 { assert(info.handle != INVALID_HANDLE_VALUE); notify_filter: s32; if (events_to_watch & (.ADDED | .MOVED | .REMOVED)) { notify_filter |= FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_CREATION; } if (events_to_watch & .MODIFIED) { notify_filter |= FILE_NOTIFY_CHANGE_SIZE | FILE_NOTIFY_CHANGE_LAST_WRITE; } if (events_to_watch & .ATTRIBUTES_CHANGED) { notify_filter |= FILE_NOTIFY_CHANGE_SECURITY | FILE_NOTIFY_CHANGE_ATTRIBUTES; } info.bytes_returned = 0; success := ReadDirectoryChangesW(info.handle, info.notify_information, watcher.buffer_size, cast(s32) watcher.watch_recursively, notify_filter, null, *info.overlapped, null); if !success return false, cast(u32) GetLastError(); return true, 0; } process_changes_windows :: (using watcher: *File_Watcher) -> changed: bool, needs_wait: bool, wait_seconds: float64 { for info: directories { if info.lost { if !external_probe retry_lost_directory(watcher, info); continue; } if !external_probe && seconds_since_init() >= info.retry_at { info.retry_at = seconds_since_init() + WATCH_RETRY_SECONDS; identity, found := path_identity(info.name); error := ifx found then 0 else cast(u32) GetLastError(); if !found || !same_identity(identity, info.identity) { // Pending I/O must finish before the buffer and OVERLAPPED are reused. CancelIoEx(info.handle, *info.overlapped); bytes: s32; GetOverlappedResult(info.handle, *info.overlapped, *bytes, TRUE); lose_directory(watcher, info, error); continue; } } handle := info.handle; assert(handle != INVALID_HANDLE_VALUE); if !HasOverlappedIoCompleted(info.overlapped) continue; // Handle the change.... bytes_transferred: s32; success := GetOverlappedResult(handle, *info.overlapped, *bytes_transferred, FALSE); if success && verbose log("%: bytes_transferred == % (struct size %)\n", info.name, bytes_transferred, size_of(FILE_NOTIFY_INFORMATION)); if !success { error := cast(u32) GetLastError(); if error != ERROR_NOTIFY_ENUM_DIR { lose_directory(watcher, info, error); continue; } bytes_transferred = 0; // too many changes to report: handled as an overflow } // Issue the next read once we're done processing this one defer { issued, error := issue_one_read(watcher, info); if !issued lose_directory(watcher, info, error); } if bytes_transferred == 0 { // Buffer has overflowed and we lost all the events. Add event for the whole directory and set SCAN_CHILDREN. add_change(watcher, info.name, File_Change_Event.MODIFIED | .SCAN_CHILDREN); continue; } notify := cast(*FILE_NOTIFY_INFORMATION) info.notify_information; // This is actually a list of events... while notify != null { defer notify = move_info_forward(notify); events: File_Change_Event; if notify.Action == { case FILE_ACTION_ADDED; events = .ADDED; case FILE_ACTION_MODIFIED; events = .MODIFIED; case FILE_ACTION_RENAMED_OLD_NAME; events = File_Change_Event.MOVED | .MOVED_FROM; case FILE_ACTION_RENAMED_NEW_NAME; events = File_Change_Event.MOVED | .MOVED_TO; case FILE_ACTION_REMOVED; events = .REMOVED; case; if verbose log("discarded case %\n", notify.Action); // Ignore RENAMED_OLD_NAME file actions. continue; } name, success := wide_to_utf8(cast(*u16) notify.FileName.data, notify.FileNameLength / 2,, temp); if !name { if verbose log_error("WideCharToMultiByte returned empty string.\n"); continue; } path_overwrite_separators(name, #char "/"); full_path := tprint("%/%", trim_right(info.name, "/"), name); // A folder is MODIFIED whenever its entries change, which is not a sign of lost events, // so only the overflow paths above mark SCAN_CHILDREN. add_change(watcher, full_path, events); } } changed, needs_wait, wait_seconds := emit_changes(watcher); return changed, needs_wait, wait_seconds; } move_info_forward :: (info: *FILE_NOTIFY_INFORMATION) -> *FILE_NOTIFY_INFORMATION { if info.NextEntryOffset == 0 return null; return cast(*FILE_NOTIFY_INFORMATION)((cast(*u8) info) + info.NextEntryOffset); } } #if OS_HAS_INOTIFY { add_directories_linux :: (using watcher: *$T/File_Watcher, dir_paths: .. string) -> bool { for dir_paths { if !add_single_directory_linux(watcher, it) return false; } if watcher.watch_recursively && watcher.enumerate_on_add { Data :: struct { watcher: *T; success: bool; } data: Data; data.watcher = watcher; data.success = true; for path: dir_paths { is_dir, success := is_directory(path); if !success { if watcher.verbose log("Couldn’t check if \"%\" is a directory", path); continue; } if is_dir { success := visit_files(path, true, *data, (info: *File_Visit_Info, data: *Data) { if !add_single_directory_linux(data.watcher, info.full_name) { data.success = false; } }, visit_files = false, visit_directories = true, visit_symlinks = false, follow_directory_symlinks = false); if !success || !data.success { log_error("Couldn’t enumerate dirs in \"%\"", path); return false; } } } } return true; } add_single_directory_linux :: (using watcher: *File_Watcher, dir_path: string) -> bool { push_allocator(pool_allocator_proc, path_pool); c_path := temp_c_string(dir_path); flags: u32; if (events_to_watch & .ADDED) { flags |= IN_CREATE; } if (events_to_watch & .MODIFIED) { flags |= IN_MODIFY | IN_CLOSE_WRITE; } if (events_to_watch & .MOVED) { flags |= IN_MOVED_TO | IN_MOVE_SELF; } if (events_to_watch & .ATTRIBUTES_CHANGED) { flags |= IN_ATTRIB; } if (events_to_watch & .REMOVED) { flags |= IN_DELETE | IN_DELETE_SELF | IN_MOVED_FROM; } // Out of watches: the caller rescans these instead, so this is not a failure. if max_watches && watch_paths.count >= max_watches && !table_contains(*watched_paths, dir_path) { watch_limit_reached = true; array_add(*unwatched, copy_string(dir_path)); return true; } watch := inotify_add_watch(inotify_instance, c_path, flags); if watch == -1 { error := errno(); if error == ENOSPC { watch_limit_reached = true; array_add(*unwatched, copy_string(dir_path)); return true; } log_error("Could not watch path \"%\": %", dir_path, error); return false; } previous_watch_path := table_find_pointer(*watch_paths, watch); if previous_watch_path { if watcher.verbose log("Watch % for \"%\" already exists", watch, previous_watch_path.*); if compare(previous_watch_path.*, dir_path) != 0 { if watcher.verbose log("Replacing path for watch %: \"%\" -> \"%\"", watch, previous_watch_path.*, dir_path); table_remove(*watched_paths, previous_watch_path.*); free(previous_watch_path.*); previous_watch_path.* = copy_string(dir_path); table_add(*watched_paths, previous_watch_path.*, watch); } } else { if watcher.verbose log("Adding watch % for \"%\"", watch, dir_path); path := copy_string(dir_path); table_add(*watch_paths, watch, path); table_add(*watched_paths, path, watch); } return true; } process_changes_linux :: (using watcher: *File_Watcher) -> changed: bool, needs_wait: bool, wait_seconds: float64 { assert(inotify_instance != -1); // One read returns at most buffer_size bytes, so read until the queue is empty. while true { inotify_bytes_read = read(inotify_instance, inotify_data, cast(u64) watcher.buffer_size); if inotify_bytes_read == -1 { if errno() != EAGAIN { log_error("Could not read inotify events: %", errno()); } break; } if inotify_bytes_read == 0 break; inotify_cursor = 0; notify := cast(*inotify_event) inotify_data; // This is actually a list of events... while notify != null { defer notify = move_data_forward(watcher, notify); if watcher.verbose log("Received notify: % (mask: 0x%)", notify.*, formatInt(notify.mask, base = 16)); if notify.mask & IN_Q_OVERFLOW { add_change(watcher, "", .OVERFLOW); continue; } if notify.mask & IN_IGNORED { // The watch is gone: its folder was deleted or unmounted, or the watch removed. gone := table_find_pointer(*watch_paths, notify.wd); if gone table_remove(*watched_paths, gone.*); table_remove(*watch_paths, notify.wd); continue; } c_name := notify.name.data; // we cant just do this because notify.name can be null padded arbitrarily // and notify.len includes these pads. Thanks Linux... -josh Feb 22, 2017 // length_in_bytes := notify.len; name: string; name.data = c_name; if notify.len { name.count = c_style_strlen(c_name); } else { name.count = 0; } events: File_Change_Event; if notify.mask & IN_CREATE events |= .ADDED; if notify.mask & (IN_MODIFY | IN_CLOSE_WRITE) events |= .MODIFIED; if notify.mask & (IN_MOVED_FROM) events |= File_Change_Event.MOVED | .MOVED_FROM; if notify.mask & (IN_MOVED_TO | IN_MOVE_SELF) events |= File_Change_Event.MOVED | .MOVED_TO; if notify.mask & IN_ATTRIB events |= .ATTRIBUTES_CHANGED; if notify.mask & (IN_DELETE | IN_DELETE_SELF) events |= .REMOVED; if !events { if watcher.verbose log("discarded mask % for \"%\"", notify.mask, name); continue; } is_folder := (notify.mask & IN_ISDIR) != 0; if is_folder events |= .IS_FOLDER; if watcher.verbose log("Action '%' on name '%':", events, name); full_path, success := get_full_name(watcher, notify.wd, name); if !success { log_error("Could not assemble full name for \"%\" from event %", name, notify.*); continue; } was_created_or_moved := events & (File_Change_Event.ADDED | File_Change_Event.MOVED_TO); if watch_recursively && was_created_or_moved && is_folder { add_directories(watcher, full_path); // Tell the callback that he has missed events for the children of this directory. events |= .SCAN_CHILDREN; } add_change(watcher, full_path, events); } } changed, needs_wait, wait_seconds := emit_changes(watcher); return changed, needs_wait, wait_seconds; } move_data_forward :: (using watcher: *File_Watcher, event: *inotify_event) -> *inotify_event { // Our inotify_event struct is too large because the "name" array has non-zero size. INOTIFY_EVENT_OVERSIZE :: 4; step: s32 = cast(s32) (size_of(inotify_event) - INOTIFY_EVENT_OVERSIZE + event.len); if (inotify_cursor + step) >= inotify_bytes_read return null; inotify_cursor += step; return cast(*inotify_event) ((cast(*u8) inotify_data) + inotify_cursor); } get_full_name :: (using watcher: *File_Watcher, watch: s32, name: string) -> string, success: bool { dir_path := table_find_pointer(*watch_paths, watch); if !dir_path return "", false; if name { return tprint("%/%", dir_path.*, name), true; } else { return dir_path.*, true; } } } #if OS == .MACOS { add_directories_macos :: (using watcher: *File_Watcher, dir_paths: .. string) -> bool { assert(watcher.merge_window_seconds >= 0); paths_to_watch := create_cfstring_array(dir_paths); defer CFRelease(paths_to_watch); fsinfo: FSEventStreamContext; fsinfo.info = watcher; // So a session without a single change still leaves a point to replay from. start_id := ifx watcher.since_event_id == kFSEventStreamEventIdSinceNow then FSEventsGetCurrentEventId() else watcher.since_event_id; if start_id > watcher.last_event_id watcher.last_event_id = start_id; event_stream := FSEventStreamCreate(null, event_stream_callback, _context=*fsinfo, paths_to_watch, watcher.since_event_id, latency=watcher.merge_window_seconds, kFSEventStreamCreateFlagFileEvents | FS_CREATE_FLAG_WATCH_ROOT); if exclusion_paths.count { excluded := array_view(exclusion_paths, 0, min(exclusion_paths.count, FSEVENTS_MAX_EXCLUSIONS)); excluded_array := create_cfstring_array(excluded); defer CFRelease(excluded_array); if FSEventStreamSetExclusionPaths(event_stream, excluded_array) == 0 { log_error("[File_Watcher] FSEvents did not accept the excluded folders %; their changes still arrive and are dropped.", excluded); } } FSEventStreamScheduleWithRunLoop(event_stream, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode); FSEventStreamStart(event_stream); array_add(*event_streams, event_stream); return true; } FS_ATTRIBUTE_CHANGES :: kFSEventStreamEventFlagChangeOwner | kFSEventStreamEventFlagItemInodeMetaMod | kFSEventStreamEventFlagItemFinderInfoMod; // From FSEvents.h; the macOS module does not declare these. FS_EVENT_FLAG_USER_DROPPED :: 0x00000002; FS_EVENT_FLAG_KERNEL_DROPPED :: 0x00000004; FS_EVENT_FLAG_EVENT_IDS_WRAPPED :: 0x00000008; FS_EVENT_FLAG_HISTORY_DONE :: 0x00000010; FS_EVENT_FLAG_ROOT_CHANGED :: 0x00000020; FS_CREATE_FLAG_WATCH_ROOT :: 0x00000004; // The macOS module keeps its CoreServices library to itself. CoreServices_For_Watcher :: #library,system "CoreServices"; FSEventsGetCurrentEventId :: () -> u64 #foreign CoreServices_For_Watcher; FSEventStreamSetExclusionPaths :: (streamRef: FSEventStreamRef, pathsToExclude: CFArrayRef) -> u8 #foreign CoreServices_For_Watcher; // Boolean // Events were lost, or a replay's history cannot be trusted: the caller must rescan. FS_EVENTS_LOST :: FS_EVENT_FLAG_USER_DROPPED | FS_EVENT_FLAG_KERNEL_DROPPED | FS_EVENT_FLAG_EVENT_IDS_WRAPPED | FS_EVENT_FLAG_ROOT_CHANGED; event_stream_callback :: (streamRef: FSEventStreamRef, clientCallBackInfo: *void, numEvents: u64, eventPaths: *void, eventFlags: *FSEventStreamEventFlags, eventIds: *FSEventStreamEventId) #c_call { using watcher := cast(*File_Watcher) clientCallBackInfo; push_context { paths := cast(**u8)eventPaths; for 0..numEvents-1 { flags := eventFlags[it]; event_id := eventIds[it]; full_path := to_string(paths[it]); if verbose log("Received event id % with flags 0x%", event_id, formatInt(flags, base=16)); if flags & FS_EVENT_FLAG_HISTORY_DONE { history_done = true; continue; } if flags & FS_EVENTS_LOST { add_change(watcher, "", .OVERFLOW); continue; } events: File_Change_Event; if flags & kFSEventStreamEventFlagItemCreated events |= .ADDED; if flags & kFSEventStreamEventFlagItemModified events |= .MODIFIED; if flags & kFSEventStreamEventFlagItemRenamed events |= .MOVED; if flags & kFSEventStreamEventFlagItemRemoved events |= .REMOVED; if flags & kFSEventStreamEventFlagMustScanSubDirs events |= .SCAN_CHILDREN; if flags & FS_ATTRIBUTE_CHANGES events |= .ATTRIBUTES_CHANGED; if !events { if verbose log("Discarding events % for %", flags, full_path); continue; } if flags & kFSEventStreamEventFlagItemIsDir { events |= .IS_FOLDER; if events & (File_Change_Event.ADDED | File_Change_Event.MOVED) { // Tell the callback that he has missed events for the children of this directory. events |= .SCAN_CHILDREN; } } if event_id > last_event_id last_event_id = event_id; add_change(watcher, full_path, events); } } } process_changes_macos :: (using watcher: *File_Watcher) -> changed: bool, needs_wait: bool, wait_seconds: float64 { CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0, false); changed, needs_wait, wait_seconds := emit_changes(watcher); return changed, needs_wait, wait_seconds; } create_cfstring_array :: (strings: [] string) -> CFArrayRef { temp := NewArray(strings.count, CFStringRef); defer { for temp CFRelease(it); array_free(temp); } for strings { cfstring := CFStringCreateWithBytes(null, it.data, it.count, .UTF8, false); temp[it_index] = cfstring; } cfarray := CFArrayCreate(null, cast(**void) temp.data, temp.count, *kCFTypeArrayCallBacks); return cfarray; } } #import "Basic"; #import "File_Utilities"; #import "Hash_Table"; #import "Pool"; #import "String"; OS_HAS_INOTIFY :: OS == .LINUX || OS == .ANDROID; #if OS == .WINDOWS { #import "Windows"; #import "Windows_Utf8"; } #if OS_HAS_INOTIFY { #import "Linux"; #import "POSIX"; } #if OS == .MACOS { #import "macOS"; #import "POSIX"; }