4
\$\begingroup\$

Looking for feedback, bugs, performance reports, and general code review of this simple file-watcher.

Basic algorithm: The MacOS version just sets the relevant paths to be watched from the CLI arguments. The Linux version watches the whole filesystem and filters out irrelevant paths.

#if !defined(__APPLE__)
  #define _GNU_SOURCE// required for open_by_handle_at()
  #include <sys/fanotify.h>
#endif
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>
#include <limits.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>

#if defined(__APPLE__)
  #include <CoreServices/CoreServices.h>
  #include <dispatch/dispatch.h>
  char gPathBuffer[PATH_MAX];
  static void callback(
    ConstFSEventStreamRef streamRef,
    void *clientCallBackInfo,
    size_t numEvents,
    void *eventPaths,
    const FSEventStreamEventFlags eventFlags[],
    const FSEventStreamEventId eventIds[]
  ) {
    for (unsigned long i=numEvents;i--;) {
      CFStringGetCString(CFArrayGetValueAtIndex(eventPaths,i),gPathBuffer,sizeof(gPathBuffer),kCFStringEncodingUTF8);
#ifdef DEBUG
      printf("%u %s%s\n",(unsigned int)eventFlags[i],gPathBuffer,eventFlags[i]&kFSEventStreamEventFlagItemIsDir?"/":"");
#endif
      fputs(gPathBuffer, stdout);
      if (eventFlags[i] & kFSEventStreamEventFlagItemIsDir) putchar('/');
      putchar('\n');
    }
    fflush(stdout);
  }
#endif

void die(char *msg) { perror(msg); fprintf(stderr, " (%d)\n", errno); exit(1); }

int main(int argc, char *argv[]) {
  // print help, if requested
  for (int i = 1; i < argc; i++) {
    if (0 == strcmp("--help", argv[i])) {
      printf(
"jp-notify v0.8.0\n"
"\n"
"Usage:\n"
"jp-notify [PATH1] [PATH2] ...\n"
"\n"
"Description:\n"
"This command recursively watches the specified paths for changes to the files and/or directories they point to and their attributes. It prints the path when a change occurs. If no paths are specified it will use the current working directory.\n"
"\n"
"Note:\n"
"Must run as superuser on Linux systems. This is a limitation of the Linux kernel's fanotify system.\n"
      );
      return 0;
    }
  }

  int paths_n = argc;// number of paths
  char **paths = argv;// first path always ignored
#ifdef DEBUG
  printf("argc=%d\n", argc);
#endif

  // deal with no supplied args
  if (1 == argc) {
    paths_n = 2; 
    paths = (char **)malloc(sizeof(char *) * paths_n);
    paths[1] = ".";
  }

  {
    // convert all paths to absolute paths (so we can handle path args like "../../")
    char p[PATH_MAX];// absolute path
    for (int i = 1; i < paths_n; i++) {
      (void)realpath(paths[i], p);// doesn't seem to return null ever
      paths[i] = (char *)malloc(strlen(p) + 1);
      strcpy(paths[i], p);
    }
  }

#ifdef DEBUG
  for (int i = 1; i < paths_n; i++) printf("paths[%d]=%s\n", i, paths[i]);
  fflush(stdout);
#endif

#if defined(__APPLE__)
    // put paths params into Core Foundation (CF) Array
    CFMutableArrayRef cf_paths = CFArrayCreateMutable(kCFAllocatorDefault, paths_n, &kCFTypeArrayCallBacks);
    for (int i = 1; i < paths_n; i++) {
      CFStringRef p = CFStringCreateWithCStringNoCopy(kCFAllocatorDefault, paths[i], kCFStringEncodingUTF8, kCFAllocatorDefault);
      CFArrayAppendValue(cf_paths, p);
    }

    // monitor the path recursively (and also keep track of new files/folders created within it)
    FSEventStreamRef stream = FSEventStreamCreate(
      NULL,// memory allocator (NULL=default)
      &callback,// FSEventStreamCallback
      NULL,// context
      cf_paths,// paths to watch
      kFSEventStreamEventIdSinceNow,// since when
      0,// latency (seconds)
      kFSEventStreamCreateFlagUseCFTypes// The framework will invoke your callback function with CF types rather than raw C types (i.e., a CFArrayRef of CFStringRefs, rather than a raw C array of raw C string pointers). See FSEventStreamCallback.
      | kFSEventStreamCreateFlagFileEvents// Request file-level notifications. Your stream will receive events about individual files in the hierarchy you're watching instead of only receiving directory level notifications. Use this flag with care as it will generate significantly more events than without it.
    );
    dispatch_queue_t d = dispatch_queue_create("jp-watch", NULL);
    FSEventStreamSetDispatchQueue(stream, d);
    FSEventStreamStart(stream);

#ifdef DEBUG
    sleep(10);// sleep so can see the results of "leaks" for detecting memory leaks
#else
    pause();// The pause function suspends program execution until a signal arrives whose action is either to execute a handler function, or to terminate the process.
#endif

    // free memory and resources
    //   paths
    for (int i = 0; i < paths_n - 1; i++) CFRelease(CFArrayGetValueAtIndex(cf_paths, i));
    CFRelease(cf_paths);
    //   stream
    dispatch_release(d);
    FSEventStreamStop(stream);
    FSEventStreamInvalidate(stream);
    FSEventStreamRelease(stream);
#else
  // fanotify init
  int fd = fanotify_init(FAN_REPORT_FID, O_RDONLY);
  if (-1 == fd) die("Fanotify init failed");
  
  // fanotify mark
  if (-1 == fanotify_mark(
    fd,
    FAN_MARK_ADD// add new marks
    | FAN_MARK_FILESYSTEM// monitor the whole filesystem
    , FAN_MODIFY
    | FAN_ATTRIB// since Linux 5.1
    | FAN_CREATE// since Linux 5.1
    | FAN_DELETE// since Linux 5.1
    | FAN_MOVE// since Linux 5.1, = FAN_MOVED_FROM | FAN_MOVED_TO
    | FAN_ONDIR// require in order to create events when subdirectory entries are modified (i.e., mkdir/rmdir)
    | FAN_EVENT_ON_CHILD// events for the immediate children of marked directories shall be created
    , AT_FDCWD
    , "/"// path to monitor
  )) {
    if (1 == errno) fputs("Must run as superuser on Linux systems.\n", stderr);
    die("Fanotify mark failed");
  };

  // fanotify process events
  char b[8192];// byte buffer (docs recommend a big one e.g. 4096)
  char p[PATH_MAX];// /proc/self/fd/%d path
  char f[PATH_MAX];// filename
  ssize_t n;
  while ((n = read(fd, &b, sizeof(b))) > 0) {
    struct fanotify_event_metadata *m = (struct fanotify_event_metadata *)b;
    while (FAN_EVENT_OK(m, n)) {
      struct fanotify_event_info_fid *fid = (struct fanotify_event_info_fid *)(m + 1);
      // get fd from handle
      int event_fd = open_by_handle_at(AT_FDCWD, (struct file_handle *)fid->handle, O_RDONLY);

      // get filename from fd
      sprintf(p, "/proc/self/fd/%d", event_fd);
      int l = readlink(p, f, PATH_MAX);
      f[l] = '\0';

      // filter by paths
      int i;
      for (i = 1; i < paths_n; i++) {
        if (strncmp(f, paths[i], strlen(paths[i])) == 0) break;// matching prefix found, so stop comparing
      }
      if (paths_n != i) {// in the path set?
        fputs(f, stdout);
        if (m->mask & FAN_ONDIR) putchar('/');
        putchar('\n');
        fflush(stdout);
        //if (m->mask & FAN_ACCESS) puts("FAN_ACCESS");
        //if (m->mask & FAN_MODIFY) puts("FAN_MODIFY");
        //if (m->mask & FAN_ATTRIB) puts("FAN_ATTRIB");
        //if (m->mask & FAN_CREATE) puts("FAN_CREATE");
        //if (m->mask & FAN_DELETE) puts("FAN_DELETE");
        //if (m->mask & FAN_MOVE) puts("FAN_MOVE");
        //if (m->mask & FAN_ONDIR) { puts("/"); puts("FAN_ONDIR"); } else { putchar('\n'); }
        //if (m->mask & FAN_EVENT_ON_CHILD) puts("FAN_EVENT_ON_CHILD");
      }

      m = FAN_EVENT_NEXT(m, n);
    }
  }
  die("Fanotify read failed");
#endif

  if (1 == argc) free(paths);

  return 0;
}
\$\endgroup\$
4
  • 1
    \$\begingroup\$ Was there something about kqueue() / epoll() that made them unsuitable? \$\endgroup\$
    – J_H
    Commented Feb 23, 2023 at 6:35
  • \$\begingroup\$ From what I gather, kqueue and epoll are similar in that you need to recursively add the contents of paths at the start to be watched, and manually add new paths to be watched if they are added after the app starts. This added complexity, more bug potential and more maintenance. \$\endgroup\$
    – Jonathan
    Commented Feb 23, 2023 at 7:20
  • \$\begingroup\$ The MacOS FSStream monitors the whole tree which could potentially lead to greater performance as its a higher level API that can be optimised in the future and may use some MacOS specific optimisations. The Linux fanotify implementation has constant memory usage, O(1), and instant startup times. inotify needs to add all the paths recursively to be watched and also uses memory for this process giving it O(n) memory usage. \$\endgroup\$
    – Jonathan
    Commented Feb 23, 2023 at 7:21
  • \$\begingroup\$ On the downside, Linux's fanotify requires superuser (CAP_SYS_ADMIN) privledges. \$\endgroup\$
    – Jonathan
    Commented Feb 23, 2023 at 7:23

1 Answer 1

4
\$\begingroup\$

It isn't clear why you decided these 2 programs should have the same main other than they perform similar functions on different platforms. Barely any of the code is shared between the implementations. The only code that is common is the command line parsing and converting the paths to watch to absolute paths.

The one thing that is clear is that either version is doing too much in main() and not enough in functions called by main(). This is especially true in the Linux version where the only local function called is void die(char* msg).

Another thing that is obvious is that this code really isn't ready to handle command line arguments, there is only one flag supported and that is the --help flag. One of the functions that should be added is parsing the command line. Based on the complexity of the one flag that is implemented I suggest that a second function that helps the command line parser should be added, that function should probably be called usage. Usage should be different for the 2 different implementations, there is no need to indicate that one needs to be a super user on a Linux system when one is running on Apple.

Commented out code is code that is not ready for review, there is no need to see this in the file

                //if (m->mask & FAN_ACCESS) puts("FAN_ACCESS");
                //if (m->mask & FAN_MODIFY) puts("FAN_MODIFY");
                //if (m->mask & FAN_ATTRIB) puts("FAN_ATTRIB");
                //if (m->mask & FAN_CREATE) puts("FAN_CREATE");
                //if (m->mask & FAN_DELETE) puts("FAN_DELETE");
                //if (m->mask & FAN_MOVE) puts("FAN_MOVE");
                //if (m->mask & FAN_ONDIR) { puts("/"); puts("FAN_ONDIR"); } else { putchar('\n'); }
                //if (m->mask & FAN_EVENT_ON_CHILD) puts("FAN_EVENT_ON_CHILD");

This code should be a function in the Linux version of the program:

    // fanotify process events
    char b[8192];// byte buffer (docs recommend a big one e.g. 4096)
    char p[PATH_MAX];// /proc/self/fd/%d path
    char f[PATH_MAX];// filename
    ssize_t n;
    while ((n = read(fd, &b, sizeof(b))) > 0) {
        struct fanotify_event_metadata* m = (struct fanotify_event_metadata*)b;
        while (FAN_EVENT_OK(m, n)) {
            struct fanotify_event_info_fid* fid = (struct fanotify_event_info_fid*)(m + 1);
            // get fd from handle
            int event_fd = open_by_handle_at(AT_FDCWD, (struct file_handle*)fid->handle, O_RDONLY);

            // get filename from fd
            sprintf(p, "/proc/self/fd/%d", event_fd);
            int l = readlink(p, f, PATH_MAX);
            f[l] = '\0';

            // filter by paths
            int i;
            for (i = 1; i < paths_n; i++) {
                if (strncmp(f, paths[i], strlen(paths[i])) == 0) break;// matching prefix found, so stop comparing
            }
            if (paths_n != i) {// in the path set?
                fputs(f, stdout);
                if (m->mask & FAN_ONDIR) putchar('/');
                putchar('\n');
                fflush(stdout);
                //if (m->mask & FAN_ACCESS) puts("FAN_ACCESS");
                //if (m->mask & FAN_MODIFY) puts("FAN_MODIFY");
                //if (m->mask & FAN_ATTRIB) puts("FAN_ATTRIB");
                //if (m->mask & FAN_CREATE) puts("FAN_CREATE");
                //if (m->mask & FAN_DELETE) puts("FAN_DELETE");
                //if (m->mask & FAN_MOVE) puts("FAN_MOVE");
                //if (m->mask & FAN_ONDIR) { puts("/"); puts("FAN_ONDIR"); } else { putchar('\n'); }
                //if (m->mask & FAN_EVENT_ON_CHILD) puts("FAN_EVENT_ON_CHILD");
            }

            m = FAN_EVENT_NEXT(m, n);
        }
    }

Note, ssize_t is not portable.

Test for Possible Memory Allocation Errors

In modern high-level languages such as C++, memory allocation errors throw an exception that the programmer can catch. This is not the case in the C programming language. While it is rare in modern computers because there is so much memory, memory allocation can fail, especially if the code is working in a limited memory application such as embedded control systems. In the C programming language when memory allocation fails, the functions malloc(), calloc() and realloc() return NULL. Referencing any memory address through a NULL pointer results in undefined behavior (UB).

Possible unknown behavior in this case can be a memory page error (in Unix this would be call Segmentation Violation), corrupted data in the program and in very old computers it could even cause the computer to reboot (corruption of the stack pointer).

To prevent this undefined behavior a best practice is to always follow the memory allocation statement with a test that the pointer that was returned is not NULL.

Convention When Using Memory Allocation in C

When using malloc(), calloc() or realloc() in C a common convention is to sizeof(*PTR) rather sizeof(PTR_TYPE), this make the code easier to maintain and less error prone, since less editing is required if the type of the pointer changes.

There is no need to cast the results of malloc() to the proper type.

The current code rewritten to follow the above best practices:

    // deal with no supplied args
    if (1 == argc) {
        paths_n = 2;
        paths = malloc(sizeof(*paths) * paths_n);
        if (!paths)
        {
            fprintf(stderr, "malloc of paths failed! %s exiting", argv[0]);
            return EXIT_FAILURE;
        }
        paths[1] = ".";
    }

    {
        // convert all paths to absolute paths (so we can handle path args like "../../")
        char p[PATH_MAX];// absolute path
        for (int i = 1; i < paths_n; i++) {
            (void)realpath(paths[i], p);// doesn't seem to return null ever
            paths[i] = malloc(strlen(p) + 1);
            if (!paths[i])
            {
                fprintf(stderr, "malloc of paths[%d] failed! %s exiting", i, argv[0]);
                return EXIT_FAILURE;
            }
            strcpy(paths[i], p);
        }
    }
\$\endgroup\$
4
  • \$\begingroup\$ True there isn't much shared code now - at the start of the project there was more. The idea was to make the overall maintenance of the code lower and less prone to human error to have it in one file. \$\endgroup\$
    – Jonathan
    Commented Feb 24, 2023 at 2:49
  • \$\begingroup\$ With regards to ssize_t I'm now using: #ifdef __ssize_t_defined ssize_t n; #else int n; #endif \$\endgroup\$
    – Jonathan
    Commented Feb 24, 2023 at 4:02
  • 1
    \$\begingroup\$ Is ssize_t not portable from a POSIX point of view? Most I/O syscalls return ssize_t (read(), write(), recv(), send(), etc.). What do you recommended one use instead? \$\endgroup\$
    – Harith
    Commented Feb 24, 2023 at 7:48
  • \$\begingroup\$ I haven't been able to find any modern Linux flavours that use anything other than ssize_t as of yet. \$\endgroup\$
    – Jonathan
    Commented Feb 25, 2023 at 3:35

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.