Skip to content

Add file watcher API and Linux implementation#15342

Open
meyraud705 wants to merge 5 commits intolibsdl-org:mainfrom
meyraud705:file_watcher
Open

Add file watcher API and Linux implementation#15342
meyraud705 wants to merge 5 commits intolibsdl-org:mainfrom
meyraud705:file_watcher

Conversation

@meyraud705
Copy link
Copy Markdown
Contributor

Description

Add 1 public function: bool SDL_WatchFileForChanges(const char *path) and 2 event types: SDL_EVENT_FILE_WATCH_ERROR and SDL_EVENT_FILE_CHANGED.

SDL_UpdateFileWatch() is called when pumping event to update file watch. Resources are cleared in SDL_QuitFilesystem(void).

Implement this API for Linux using inotify. I put it in SDL_sysfsops.c, I don't know how other platforms work so that may not be the correct place.

Existing Issue(s)

Implement #15070 for Linux.

Comment thread src/filesystem/posix/SDL_sysfsops.c Outdated
@Semphriss
Copy link
Copy Markdown
Contributor

A design consideration: would it be possible to use a callback instead of an event? If a given program listens to different paths at different places in the code, events can make it difficult to transfer the signal from the event loop down to the specific part of the code that needs that event. That was my reasoning when developing the file dialog subsystem; using events would've split the part of the code that invokes the dialog from the part that handles the subsequent file operations, which can make a repo quite messy if not carefully managed.

@slouken
Copy link
Copy Markdown
Collaborator

slouken commented Apr 21, 2026

A design consideration: would it be possible to use a callback instead of an event? If a given program listens to different paths at different places in the code, events can make it difficult to transfer the signal from the event loop down to the specific part of the code that needs that event. That was my reasoning when developing the file dialog subsystem; using events would've split the part of the code that invokes the dialog from the part that handles the subsequent file operations, which can make a repo quite messy if not carefully managed.

I'd also like to see this be callback based rather than event based. You have more local control, and it's always possible to generate an event from a callback.

@icculus, thoughts?

@icculus
Copy link
Copy Markdown
Collaborator

icculus commented Apr 21, 2026

Some counter-arguments here:

  • if you do a callback, then you have threading questions to deal with. You're probably going to end up synchronizing file changes on another thread until around the time you are pumping the event loop anyhow.
  • "hey this file changed" is maybe something you do want to deal with once a frame, possibly with several updates collapsed into a single event.
  • I'm not certain that all platforms will give this to you immediately, so a callback might not offer instant-response in all cases.

But, ultimately:

  • events are useless if you don't use the event subsystem, but even in a command line tool with no window, waiting for file changes could be useful.

@meyraud705
Copy link
Copy Markdown
Contributor Author

You have more local control,

With event, you only need to make your callback public and have a dispatch function in the event loop. Doesn't look messy to me and you know when and from which thread you reload the file.

SDL_Event e;
while (SDL_PollEvent(&e)) {
    if (e.type == SDL_EVENT_FILE_CHANGED) {
        if (SDL_strncmp(e->file_watch.path, "./shader/", 9) == 0) {
            ReloadShader(&e->file_watch.path[9]);
        } else if (...) {
            ...
        }
    }
}

and it's always possible to generate an event from a callback.

It is also possible to generate a callback from an event with SDL_AddEventWatch() (callback needs to check if it cares about the file path though).

@Semphriss
Copy link
Copy Markdown
Contributor

For simple enough use cases, I don't expect events to be too complicated to manage, but for more advanced uses, I see a number of problems arising:

  • Handling path changes requires passing every watch through the event loop. This isn't much of a problem when the file watching logic plays with global information, but anything deeply nested in the application logic would need to expose a function and rely on the main loop to invoke it, which creates tight coupling between the various components in the code.
  • If the use case is too complex for the path to be watched to be described by a string literal (for example, in case the path to be watched can be dynamically specified), the if/else block has to keep track of every registered path to be watched and know which callback(s) to invoke based on it.
  • As stated, the downside with SDL_AddEventWatch is that every callback is invoked on every file change. This requires checking the file path in each callback, which in turn requires keeping track of the watched paths for each callback if they're not constant. I'm not sure what would be the performance impact as the total number of watched paths and corresponding callbacks increase.
  • Making object wrappers for file watchers become more difficult to implement, since they have to be bound to the event loop in some way.

IMO, it would be easier to emit an event from within a callback than to simulate a callback from the event loop, since it requires less refactoring. With callbacks, the code requires only to add one function in the same file where the watching is registered:

void callback(const char* path)
{
  SDL_Event event;
  SDL_zero(event);
  event.file_watch.type = SDL_EVENT_FILE_CHANGED;
  event.file_watch.timestamp = SDL_GetTicksNS();
  event.file_watch.path = path;
  SDL_PushEvent(&event);
}

void fn()
{
  // ...

  SDL_WatchFileForChanges("/path/to/file", callback);

  // ...
}

Conversely, wiring callbacks from events will require keeping track of path-callback pairs, sending them to the main loop, and adding logic in the main loop to find which callback to invoke when a path notification arises. It's feasible, just more complicated.

@meyraud705
Copy link
Copy Markdown
Contributor Author

You convinced me, I'll make another PR with callback.

@slouken
Copy link
Copy Markdown
Collaborator

slouken commented Apr 21, 2026

You convinced me, I'll make another PR with callback.

There's no reason we can't do both, where the callback is optional and we always send a file changed event.

Comment thread include/SDL3/SDL_filesystem.h Outdated
* \param path path of file that was modified.
* \param userdata what was passed as `userdata` to SDL_WatchFileForChanges().
*/
typedef void (SDLCALL *SDL_FileWatchCallback)(const char *path, void *userdata);
Copy link
Copy Markdown
Collaborator

@slouken slouken Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically userdata will be the first argument to the callback.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would also be helpful to know the thread safety situation for this callback. (Is it guaranteed to be called on a specific thread, etc.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It run on its own thread, but I don't know how other platforms will handle this so I documented it can run on any thread.

Comment thread include/SDL3/SDL_events.h Outdated
Comment on lines +263 to +264
SDL_EVENT_FILE_WATCH_ERROR = 0x1500, /**< Watched files may have been modified, but the events are lost. */
SDL_EVENT_FILE_CHANGED, /**< A watched file was written. */
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest flipping this, so the normal change event comes first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread include/SDL3/SDL_filesystem.h Outdated
*
* \sa SDL_FileWatchEvent
*/
extern SDL_DECLSPEC bool SDLCALL SDL_WatchFileForChanges(const char *path, SDL_FileWatchCallback callback, void *userdata);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to add a separate API for watching an entire directory?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or if this function handles both, would it make sense to be called WatchPathForChanges?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inotify handles both so does this function. You only get event when a file is modified though.

Comment thread include/SDL3/SDL_events.h Outdated
Comment thread include/SDL3/SDL_filesystem.h Outdated
Comment thread src/filesystem/SDL_sysfilesystem.h Outdated
Comment thread src/filesystem/SDL_sysfilesystem.h Outdated
meyraud705 and others added 2 commits April 21, 2026 23:59
Co-authored-by: Sam Lantinga <slouken@libsdl.org>
Copy link
Copy Markdown
Collaborator

@slouken slouken left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me, @icculus, thoughts?

@slouken slouken added this to the 3.6.0 milestone Apr 21, 2026
*
* \sa SDL_FileWatchEvent
*/
extern SDL_DECLSPEC bool SDLCALL SDL_WatchPathForChanges(const char *path, SDL_FileWatchCallback callback, void *userdata);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to remove a file watcher?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yes, this should be AddPathWatch() with a corresponding RemovePathWatch()

Comment on lines +499 to +501
if (watch_entry->path[slen - 1] == '/') {
watch_entry->path[slen - 1] = '\0';
}
Copy link
Copy Markdown
Contributor

@madebr madebr Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this remove all trailing slashes?

Suggested change
if (watch_entry->path[slen - 1] == '/') {
watch_entry->path[slen - 1] = '\0';
}
while (slen > 0 && watch_entry->path[slen - 1] == '/') {
watch_entry->path[--slen] = '\0';
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, only last slash is removed because it is added back when concatenating directory path and file name.

The reason for not removing all trailing slashes is that the path your receive in event is the same as the one you pass to SDL_WatchPathForChanges(), so that you can strncmp them.

const char *path = "/path/.///";
SDL_WatchPathForChanges(path, NULL, NULL) ;
/* in event loop: */
if (SDL_strncmp(event.file_watch.path, path, SDL_strlen(path))) {
    const char *file_name = &event.file_watch.path[SDL_strlen(path)];
    ...
}

Maybe we should add SDL_CanonicalizePath() or SDL_ComparePath().

} WatchEntry;
static SDL_HashTable *watch_descriptor_table = NULL; // stores WatchEntry for a watch descriptor

static int SDL_FileWatchThread(void *user_data);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
static int SDL_FileWatchThread(void *user_data);
static int SDLCALL SDL_FileWatchThread(void *user_data);

@madebr
Copy link
Copy Markdown
Contributor

madebr commented Apr 21, 2026

Can/should this feature have a test?

watch_entry->user_data = user_data;
SDL_memcpy(watch_entry->path, path, slen + 1);
// remove separator at the end of the path
if (watch_entry->path[slen - 1] == '/') {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also needs a test for slen > 0.
This also begs the question: is adding a file watcher for the path "" valid?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we should check and reject "" as a path.

Comment thread include/SDL3/SDL_events.h

/* File watch events */
SDL_EVENT_FILE_CHANGED = 0x1500, /**< A watched file was written. */
SDL_EVENT_FILE_WATCH_ERROR, /**< Watched files may have been modified, but the events are lost. */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about SDL_EVENT_FILE_WATCH_OVERFLOW?
Or do file watchers have multiple error conditions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inotify uses a queue which can overflow. I don't know error for other platform so I kept he name generic.

Comment on lines +545 to +548
* This function adds a file watcher that will fires an app-provided callback
* and send an SDL_EVENT_FILE_CHANGED event every time the file is modified. If
* path is a directory, the callback will be called for every file modified in
* that directory.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the callback will be called for every file modified in that directory

Does this include the directory? Or only its entries?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about file creation and deletion? I suppose we also get notified about those.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modify means a call to write() on the file. You cannot write to directory. If the documentation is not clear please propose another wording.

Directory watch are not recursive.

There is no notification for file and directory creation, deletion or rename. I could add these if you want them.

@icculus
Copy link
Copy Markdown
Collaborator

icculus commented Apr 22, 2026

This looks good to me, @icculus, thoughts?

I'm fine with this once @madebr's feedback is resolved.

(And obviously don't cherry-pick this to 3.4.x, this is clearly 3.6.0 work.)

@slouken
Copy link
Copy Markdown
Collaborator

slouken commented Apr 22, 2026

This looks good to me, @icculus, thoughts?

I'm fine with this once @madebr's feedback is resolved.

(And obviously don't cherry-pick this to 3.4.x, this is clearly 3.6.0 work.)

Yup. :)

Comment thread include/SDL3/SDL_events.h
SDL_EVENT_CAMERA_DEVICE_DENIED, /**< A camera device has been denied for use by the user. */

/* File watch events */
SDL_EVENT_FILE_CHANGED = 0x1500, /**< A watched file was written. */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this event includes creation and deletion, I'm not sure the name (and doc string) covers them all.

Correctly launch and stop file watch thread.

while (remain > 0) {
const WatchEntry *watch_entry;
if (SDL_FindInHashTable(watch_descriptor_table, (void *)(intptr_t)buf.event.wd, (const void **)&watch_entry)) {
Copy link
Copy Markdown
Contributor

@madebr madebr Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a check that read had read a complete event. Can't this access uninitialized or stale memory?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

static int SDL_FileWatchThread(void *userdata)
{
while (SDL_GetAtomicInt(&quit_watch_file) == 0) {
SDL_Delay(100);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is polled reading used for a reason? Would blocked I/O work?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use poll() or select() with a timeout if you think it is better.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know either. I'm just asking questions :)
The joystick inotify code runs on the main thread so cannot block, which is not the case here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is select needed? We're just waiting on one file, not multiple. I'm not sure what to do about destruction and writing/reading to/from the descriptor in multiple threads.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It worked like the joystick system at the beginning because there was only event. But now there is callback so it needs to work even if you don't use the event subsystem.

You need a timeout to check if we should quit the thread.

All operations on descriptor are protected by a mutex.

@TerensTare
Copy link
Copy Markdown

Can we also get an equivalent API for directories? Reasons:

  1. This makes the API easier to use in the common case (ie. one watcher for everything in assets vs one watcher for every image/shader in the directory.
  2. AFAIK, ReadDirectoryChangesW (Windows) and FSEvent (macOS) work on directories by default (even inotify supports this), then you "filter" your files of interest.
  3. The "watch file" API can then be written with the "watch directory" API, as following:
// rough sketch
void watch_file(const char *path, Callback cb) {
    const char *dir = GetFolder(path); // get the folder where this file is
    SDL_Hashtable *dir_table = INTERNAL_WatchDirAndGetTable(dir); // keep a table of (file -> callback) internally, per folder?
                                             // ^ This just returns the corresponding table if this directory was already registered
    Table-Insert(dir_table,  FileHandle(path), cb); // add callback to our table, to be called later
    // ^ actually this can be an array too, how many files can you possibly register individually within the same directory?
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants