diff --git a/build.sh b/build.sh index 62bb362a6..453f1f89d 100755 --- a/build.sh +++ b/build.sh @@ -47,6 +47,12 @@ case "$os" in plat_files="$plat_files ../src/compat/dummy.c" plat_files="$plat_files ../src/compat/clearenv.c " plat_cflags="$plat_cflags -include ../src/compat/macosx.h" + # Fuse-T (kext-free FUSE) uses NFS internally, which stats every + # directory entry after readdir. Compile in a runtime check for + # this behavior that activates a workaround. + if [ "$server" = "fuse" ]; then + plat_cflags="$plat_cflags -DFUSE_NFS_WORKAROUND" + fi default_cc=clang ;; FreeBSD) diff --git a/src/tup/file.c b/src/tup/file.c index c14f551b4..fc62f8e9c 100644 --- a/src/tup/file.c +++ b/src/tup/file.c @@ -27,6 +27,7 @@ #include "config.h" #include "entry.h" #include "option.h" +#include "pel_group.h" #include #include #include @@ -62,6 +63,8 @@ int init_file_info(struct file_info *info, int do_unlink) tent_tree_init(&info->used_groups_root); tent_tree_init(&info->output_root); tent_tree_init(&info->exclusion_root); + RB_INIT(&info->readdir_sticky); + RB_INIT(&info->open_readdir_sticky); pthread_mutex_init(&info->lock, NULL); pthread_cond_init(&info->cond, NULL); info->server_fail = 0; @@ -72,6 +75,8 @@ int init_file_info(struct file_info *info, int do_unlink) void cleanup_file_info(struct file_info *info) { + free_string_tree(&info->open_readdir_sticky); + free_string_tree(&info->readdir_sticky); free_tent_tree(&info->exclusion_root); free_tent_tree(&info->output_root); free_tent_tree(&info->used_groups_root); @@ -816,18 +821,32 @@ static int update_write_info(FILE *f, tupid_t cmdid, struct file_info *info, map = TAILQ_FIRST(&info->mapping_list); - /* TODO: strcmp only here for win32 support */ - if(strcmp(map->tmpname, map->realname) != 0) { - if(renameat(tup_top_fd(), map->tmpname, tup_top_fd(), map->realname) < 0) { - perror(map->realname); - fprintf(f, "tup error: Unable to rename temporary file '%s' to destination '%s'\n", map->tmpname, map->realname); + if(is_appledouble(map->realname)) { + /* macOS NFS / Fuse-T creates "._" AppleDouble + * sidecars next to any file with xattrs. They reach + * us via the FUSE create path, so a tmpfile was + * already opened — discard it instead of putting an + * unwanted artifact in the source tree. + */ + if(unlinkat(tup_top_fd(), map->tmpname, 0) < 0 && errno != ENOENT) { + perror(map->tmpname); + fprintf(f, "tup error: Unable to remove AppleDouble tmp file '%s'\n", map->tmpname); write_bork = 1; } - } - if(map->tent) { - /* tent may not be set (in the case of hidden files) */ - if(file_set_mtime(map->tent, map->realname) < 0) - return -1; + } else { + /* TODO: strcmp only here for win32 support */ + if(strcmp(map->tmpname, map->realname) != 0) { + if(renameat(tup_top_fd(), map->tmpname, tup_top_fd(), map->realname) < 0) { + perror(map->realname); + fprintf(f, "tup error: Unable to rename temporary file '%s' to destination '%s'\n", map->tmpname, map->realname); + write_bork = 1; + } + } + if(map->tent) { + /* tent may not be set (in the case of hidden files) */ + if(file_set_mtime(map->tent, map->realname) < 0) + return -1; + } } del_map(&info->mapping_list, map); } diff --git a/src/tup/file.h b/src/tup/file.h index 34a076f31..1e37d9bea 100644 --- a/src/tup/file.h +++ b/src/tup/file.h @@ -27,6 +27,7 @@ #include "tupid_tree.h" #include "tent_tree.h" #include "thread_tree.h" +#include "string_tree.h" #include "pel_group.h" #include "entry.h" #include @@ -70,6 +71,8 @@ struct file_info { struct tent_entries used_groups_root; struct tent_entries output_root; struct tent_entries exclusion_root; + struct string_entries readdir_sticky; + struct string_entries open_readdir_sticky; int server_fail; int open_count; int do_unlink; diff --git a/src/tup/pel_group.c b/src/tup/pel_group.c index 254ee5873..5a094be6f 100644 --- a/src/tup/pel_group.c +++ b/src/tup/pel_group.c @@ -29,6 +29,25 @@ static _Thread_local struct mempool pool = MEMPOOL_INITIALIZER(struct path_element); +int is_appledouble(const char *path) +{ + /* macOS writes AppleDouble sidecar files ("._") next to any + * file with extended attributes when the underlying filesystem + * doesn't store xattrs natively — notably Fuse-T's NFS-backed + * mount used by tup itself. They are an OS-level side effect, not + * a build output. Accepts both leading-slash FUSE paths and + * relative paths (e.g. peeled `realname`s). + */ + if(path[0] == '.' && path[1] == '_') + return 1; + while((path = strchr(path, '/')) != NULL) { + if(path[1] == '.' && path[2] == '_') + return 1; + path++; + } + return 0; +} + int pel_ignored(const char *path, int len) { if(len < 0) diff --git a/src/tup/pel_group.h b/src/tup/pel_group.h index 6cc6e6a20..fb915156a 100644 --- a/src/tup/pel_group.h +++ b/src/tup/pel_group.h @@ -43,6 +43,7 @@ struct pel_group { void init_pel_group(struct pel_group *pg); int pel_ignored(const char *path, int len); +int is_appledouble(const char *path); int get_path_elements(const char *dir, struct pel_group *pg); void free_pel(struct path_element *pel); void del_pel(struct path_element *pel, struct pel_group *pg); diff --git a/src/tup/server/Tupfile b/src/tup/server/Tupfile index 04aa6999a..a147b618f 100644 --- a/src/tup/server/Tupfile +++ b/src/tup/server/Tupfile @@ -3,6 +3,12 @@ include_rules ifeq ($(TUP_SERVER),fuse) export PKG_CONFIG_PATH CFLAGS += `pkg-config fuse --cflags` +ifeq (@(TUP_PLATFORM),macosx) +# Fuse-T (kext-free FUSE) uses NFS internally, which stats every +# directory entry after readdir. Compile in a runtime check for +# this behavior that activates a workaround. +CFLAGS += -DFUSE_NFS_WORKAROUND +endif : foreach fuse_server.c fuse_fs.c master_fork.c symlink.c |> !cc |> endif diff --git a/src/tup/server/fuse_fs.c b/src/tup/server/fuse_fs.c index 7e7cfc07c..458ce3c49 100644 --- a/src/tup/server/fuse_fs.c +++ b/src/tup/server/fuse_fs.c @@ -33,6 +33,7 @@ #include "tup/server.h" #include "tup/container.h" #include "tup/entry.h" +#include "tup/pel_group.h" #include #include #include @@ -43,15 +44,49 @@ #include #include +#ifdef FUSE_NFS_WORKAROUND +#include +#endif + static struct thread_root troot = THREAD_ROOT_INITIALIZER; static int server_mode = 0; static pid_t ourpgid; static int max_open_files = 128; +#ifdef FUSE_NFS_WORKAROUND +static int readdir_getattr_workaround = 0; + +/* Some FUSE implementations (e.g. Fuse-T) use NFS internally, where the + * NFS client will stat every directory entry of each readdir. + * + * The exact conditions of this behavior are not known, and we therefore + * probe for it at startup. By listing a virtual directory, we watch for + * whether a stat is issued for a sentinel file from that listing. If + * this stat occurs, the workaround is enabled. The main thread then + * stats a separate "done" file to finalize the probe. + * + * UNCHECKED -> readdir on probe dir -> SENTINEL + * SENTINEL -> getattr on sentinel -> enable workaround + * getattr on done file -> DONE + */ +enum nfs_probe_state { + NFS_PROBE_UNCHECKED, + NFS_PROBE_SENTINEL, + NFS_PROBE_DONE +}; + +static volatile enum nfs_probe_state nfs_probe = NFS_PROBE_UNCHECKED; + +#define NFS_PROBE_DIR_NAME "@nfs_probe@" +#define NFS_PROBE_SENTINEL_NAME ".nfs_sentinel" +#define NFS_PROBE_DONE_NAME ".nfs_done" +#endif + void tup_fuse_fs_init(void) { struct rlimit rlim; ourpgid = getpgid(0); + if(getrlimit(RLIMIT_NOFILE, &rlim) == 0) { int x; for(x=0; x<10; x++) { @@ -107,6 +142,8 @@ static int is_hidden(const char *path) return 1; if(is_ccache_path(path)) return 1; + if(is_appledouble(path)) + return 1; return 0; } @@ -294,6 +331,8 @@ static int ignore_file(const char *path) return 1; if(is_ccache_path(path)) return 1; + if(is_appledouble(path)) + return 1; return 0; } @@ -358,6 +397,10 @@ static int tup_fs_getattr(const char *path, struct stat *stbuf) const char *var; const char *stripped = NULL; int rc; + int skip_read = 0; +#ifdef FUSE_NFS_WORKAROUND + int from_sticky = 0; +#endif #ifdef FUSE3 (void) fi; @@ -365,6 +408,31 @@ static int tup_fs_getattr(const char *path, struct stat *stbuf) if(context_check() < 0) return -EPERM; +#ifdef FUSE_NFS_WORKAROUND + if(nfs_probe != NFS_PROBE_DONE) { + const char *base = strrchr(path, '/'); + if(base) base++; else base = path; + if(strcmp(base, NFS_PROBE_DIR_NAME) == 0) { + memset(stbuf, 0, sizeof(*stbuf)); + stbuf->st_mode = S_IFDIR | 0555; + stbuf->st_nlink = 2; + return 0; + } + if(nfs_probe == NFS_PROBE_SENTINEL) { + if(strcmp(base, NFS_PROBE_SENTINEL_NAME) == 0) { + readdir_getattr_workaround = 1; + memset(stbuf, 0, sizeof(*stbuf)); + stbuf->st_mode = S_IFREG | 0444; + return 0; + } + if(strcmp(base, NFS_PROBE_DONE_NAME) == 0) { + nfs_probe = NFS_PROBE_DONE; + return -ENOENT; + } + } + } +#endif + peeled = peel(path); /* If we have a temporary directory of the name we're trying to do @@ -393,6 +461,21 @@ static int tup_fs_getattr(const char *path, struct stat *stbuf) map = find_mapping(finfo, path); if(map) peeled = map->tmpname; +#ifdef FUSE_NFS_WORKAROUND + /* Under Fuse-T NFS, readdir is followed by automatic stat of + * each directory entry. If this path was recorded in + * readdir_sticky, consume it and skip ACCESS_READ later. + */ + if(readdir_getattr_workaround) { + struct string_tree *st; + st = string_tree_search(&finfo->readdir_sticky, path, strlen(path)); + if(st) { + string_tree_remove(&finfo->readdir_sticky, st); + skip_read = 1; + from_sticky = 1; + } + } +#endif put_finfo(finfo); } @@ -433,7 +516,30 @@ static int tup_fs_getattr(const char *path, struct stat *stbuf) } else { rc = 0; } - tup_fuse_handle_file(path, stripped, ACCESS_READ); + +#ifdef FUSE_NFS_WORKAROUND + /* If this getattr was triggered by the NFS auto-stat that follows + * a parser-mode readdir, and the underlying file does not exist on + * disk (it's a virtual entry from parser_directory, e.g. a not-yet- + * built *.o output), synthesize a successful stat so the kernel's + * NFS layer keeps the entry in its readdir result. Otherwise the + * shell running the run-script filters virtual entries out of glob + * expansion and they never reach the script's output. We also skip + * read recording for virtual entries — they're synthetic, not user + * intent. Real on-disk entries fall through and record normally. + */ + if(from_sticky && rc == -ENOENT) { + if(fstat(tup_top_fd(), stbuf) == 0) { + stbuf->st_mode = (stbuf->st_mode & ~S_IFMT) | S_IFREG; + stbuf->st_size = 0; + stbuf->st_nlink = 1; + rc = 0; + } + } +#endif + + if(!skip_read) + tup_fuse_handle_file(path, stripped, ACCESS_READ); return rc; } @@ -576,11 +682,101 @@ static int fill_actual_directory(const char *peeled, void *buf, return 0; } -static int readdir_parser(const char *path, void *buf, fuse_fill_dir_t filler) +#ifdef FUSE_NFS_WORKAROUND +/* Parser-mode readdir is served from parser_directory, not the real fs, + * so the readdir_sticky population path in tup_fs_readdir (which walks + * the on-disk directory) never sees these entries. Snapshot the names + * as the parser fills them and feed them into readdir_sticky in the + * caller, so the kernel's post-readdir auto-getattr on each virtual + * entry is recognized as a stat-only access and not recorded as a read + * of a generated file. + */ +struct readdir_sticky_collect { + char **names; + int count; + int cap; +}; + +static void collect_readdir_sticky(const char *name, void *vctx) +{ + struct readdir_sticky_collect *c = vctx; + char *dup; + + if(c->count == c->cap) { + int newcap = c->cap ? c->cap * 2 : 16; + char **newnames = realloc(c->names, newcap * sizeof(*newnames)); + if(!newnames) + return; + c->names = newnames; + c->cap = newcap; + } + dup = strdup(name); + if(!dup) + return; + c->names[c->count++] = dup; +} +#endif + +static int readdir_parser(const char *fuse_path, const char *path, void *buf, + fuse_fill_dir_t filler, struct file_info *finfo) { if(strncmp(path, get_tup_top(), get_tup_top_len()) == 0) { - if(tup_fuse_server_get_dir_entries(path + get_tup_top_len(), - buf, filler) < 0) + void (*on_entry)(const char *, void *) = NULL; + void *ctx_ptr = NULL; + int rc; +#ifdef FUSE_NFS_WORKAROUND + struct readdir_sticky_collect collect = { NULL, 0, 0 }; + if(readdir_getattr_workaround && finfo) { + on_entry = collect_readdir_sticky; + ctx_ptr = &collect; + } +#else + (void) fuse_path; + (void) finfo; +#endif + rc = tup_fuse_server_get_dir_entries( + path + get_tup_top_len(), buf, filler, + on_entry, ctx_ptr); +#ifdef FUSE_NFS_WORKAROUND + if(on_entry) { + int fuse_pathlen = strlen(fuse_path); + int i; + if(rc == 0 && collect.count > 0) { + for(i = 0; i < collect.count; i++) { + const char *name = collect.names[i]; + struct string_tree *st; + char *fullpath; + int namelen = strlen(name); + int fulllen = fuse_pathlen + 1 + namelen; + + fullpath = malloc(fulllen + 1); + if(!fullpath) + continue; + memcpy(fullpath, fuse_path, fuse_pathlen); + fullpath[fuse_pathlen] = '/'; + memcpy(fullpath + fuse_pathlen + 1, + name, namelen + 1); + + st = malloc(sizeof(*st)); + if(!st) { + free(fullpath); + continue; + } + st->s = fullpath; + st->len = fulllen; + if(string_tree_insert( + &finfo->readdir_sticky, st) < 0) { + free(fullpath); + free(st); + } + } + } + for(i = 0; i < collect.count; i++) + free(collect.names[i]); + free(collect.names); + } +#endif + if(rc < 0) return -EPERM; } else { /* t4052 */ @@ -610,6 +806,21 @@ static int tup_fs_readdir(const char *path, void *buf, fuse_fill_dir_t filler, if(context_check() < 0) return -EPERM; +#ifdef FUSE_NFS_WORKAROUND + if(nfs_probe == NFS_PROBE_UNCHECKED) { + const char *base = strrchr(path, '/'); + if(base) base++; else base = path; + if(strcmp(base, NFS_PROBE_DIR_NAME) == 0) { + struct stat st; + memset(&st, 0, sizeof(st)); + st.st_mode = S_IFREG | 0444; + filler(buf, NFS_PROBE_SENTINEL_NAME, &st, 0); + nfs_probe = NFS_PROBE_SENTINEL; + return 0; + } + } +#endif + peeled = peel(path); finfo = get_finfo(path); if(finfo) { @@ -622,7 +833,48 @@ static int tup_fs_readdir(const char *path, void *buf, fuse_fill_dir_t filler, */ if(server_mode == SERVER_PARSER_MODE) { int rc; - rc = readdir_parser(peeled, buf, filler); +#ifdef FUSE_NFS_WORKAROUND + /* If this readdir is the kernel's post-exec bundle-root + * probe (predicted by tup_fs_read on a shebang script) + * AND the path is NOT a directory the parser knows + * about, fulfill it silently from the real filesystem + * so the exec syscall proceeds. parser_directory does + * not contain these bundle-root paths and would + * normally fail with -EPERM, which would set + * server_fail and trip an access-violation error. + * + * We only bypass when the path is OUTSIDE + * parser_directory because passing real-fs contents + * to a path the parser owns would pollute the NFS + * readdir cache with stale entries (missing virtual + * outputs) for the script's own later readdir of the + * same path. See t8079-run-variant where the script + * is at the parser dir's root. + */ + struct string_tree *bundle_st; + bundle_st = string_tree_search( + &finfo->open_readdir_sticky, + path, strlen(path)); + if(bundle_st) { + const char *rel = peeled + get_tup_top_len(); + int peeled_in_tup_top = strncmp(peeled, get_tup_top(), get_tup_top_len()) == 0; + int in_parser_dir = peeled_in_tup_top && + tup_fuse_server_has_dir(rel); + /* string_tree_remove() frees bundle_st->s + * internally; we own the wrapper struct. + */ + string_tree_remove( + &finfo->open_readdir_sticky, + bundle_st); + free(bundle_st); + if(!in_parser_dir) { + rc = fill_actual_directory(peeled, buf, filler, 0); + put_finfo(finfo); + return rc; + } + } +#endif + rc = readdir_parser(path, peeled, buf, filler, finfo); if(rc < 0) { finfo->server_fail = 1; } @@ -723,7 +975,60 @@ static int tup_fs_readdir(const char *path, void *buf, fuse_fill_dir_t filler, /* If finfo is NULL, we're outside of tup, so we don't need to ignore * any files called '.tup' in that case. */ - return fill_actual_directory(peeled, buf, filler, finfo != NULL); + int rc = fill_actual_directory(peeled, buf, filler, finfo != NULL); + +#ifdef FUSE_NFS_WORKAROUND + /* Under Fuse-T NFS, readdir causes an immediate stat to each + * directory entry, so add them to a tree that tells getattr to + * avoid dispatching one ACCESS_READ for each. + */ + if(finfo != NULL && rc >= 0 && readdir_getattr_workaround) { + finfo_lock(finfo); + int fd; + fd = openat(tup_top_fd(), peeled, O_RDONLY); + if(fd >= 0) { + DIR *dp; + dp = fdopendir(fd); + if(dp) { + struct dirent *de; + int pathlen = strlen(path); + + while((de = readdir(dp)) != NULL) { + struct string_tree *st; + char *fullpath; + int namelen = strlen(de->d_name); + int fulllen = pathlen + 1 + namelen; + + fullpath = malloc(fulllen + 1); + if(!fullpath) + break; + memcpy(fullpath, path, pathlen); + fullpath[pathlen] = '/'; + memcpy(fullpath + pathlen + 1, de->d_name, namelen + 1); + + st = malloc(sizeof(*st)); + if(!st) { + free(fullpath); + break; + } + st->s = fullpath; + st->len = fulllen; + if(string_tree_insert(&finfo->readdir_sticky, st) < 0) { + /* Duplicate entry, already in set */ + free(fullpath); + free(st); + } + } + closedir(dp); + } else { + close(fd); + } + } + finfo_unlock(finfo); + } +#endif + + return rc; } static int mknod_internal(const char *path, mode_t mode, int flags, int close_fd) @@ -1320,11 +1625,162 @@ static int tup_fs_open(const char *path, struct fuse_file_info *fi) return res; } +#ifdef FUSE_NFS_WORKAROUND +/* Compute the directory the kernel will readdir() after exec'ing a + * shebang script at `path`. The rule is purely structural (no Launch + * Services consultation): + * + * cand = parent(path) + * while parent(cand) exists: + * - if cand.basename == "MacOS" and parent.basename == "Contents": + * cand = parent.parent (jump past the Contents/MacOS chain) + * - elif cand.basename has a dot: + * - if parent.basename == "MacOS" and parent.parent.basename == + * "Contents": cand = parent.parent.parent + * - elif parent.basename has a dot: cand = parent + * - else: stop + * - else: stop + * + * See repro/SUMMARY.md for the full forensic evidence (29 scenarios). + * `out` is filled with the predicted directory's FUSE-mount-relative + * path (same shape as `path` — e.g. /@tupjob-N/abs/...). + */ +static int has_dot(const char *seg, int seglen) +{ + int i; + for(i = 0; i < seglen; i++) + if(seg[i] == '.') + return 1; + return 0; +} + +static void last_segment(const char *path, int pathlen, + const char **seg_out, int *seglen_out) +{ + int i = pathlen; + while(i > 0 && path[i-1] == '/') + i--; + int end = i; + while(i > 0 && path[i-1] != '/') + i--; + *seg_out = path + i; + *seglen_out = end - i; +} + +static int parent_path(const char *path, int pathlen) +{ + int i = pathlen; + while(i > 0 && path[i-1] == '/') + i--; + while(i > 0 && path[i-1] != '/') + i--; + while(i > 1 && path[i-1] == '/') + i--; + return i; +} + +static int compute_bundle_root(const char *path, char *out, size_t out_size) +{ + int cand_len, parent_len, grand_len; + const char *seg; + int seglen; + const char *psg; + int pseglen; + + cand_len = parent_path(path, strlen(path)); + if(cand_len <= 0) + return -1; + + while(1) { + parent_len = parent_path(path, cand_len); + if(parent_len <= 0) + break; + + last_segment(path, cand_len, &seg, &seglen); + last_segment(path, parent_len, &psg, &pseglen); + + if(seglen == 5 && memcmp(seg, "MacOS", 5) == 0 && + pseglen == 8 && memcmp(psg, "Contents", 8) == 0) { + grand_len = parent_path(path, parent_len); + if(grand_len <= 0) + break; + cand_len = grand_len; + continue; + } + + if(has_dot(seg, seglen)) { + if(pseglen == 5 && memcmp(psg, "MacOS", 5) == 0) { + int gp_len = parent_path(path, parent_len); + const char *gpsg; + int gpseglen; + last_segment(path, gp_len, &gpsg, &gpseglen); + if(gpseglen == 8 && memcmp(gpsg, "Contents", 8) == 0) { + int ggp_len = parent_path(path, gp_len); + if(ggp_len <= 0) + break; + cand_len = ggp_len; + continue; + } + } + if(has_dot(psg, pseglen)) { + cand_len = parent_len; + continue; + } + } + + break; + } + + if((size_t)cand_len >= out_size) + return -1; + memcpy(out, path, cand_len); + out[cand_len] = '\0'; + return 0; +} + +static void maybe_record_shebang(const char *path, const char *buf, + int res, struct file_info *finfo) +{ + char bundle_root[PATH_MAX]; + struct string_tree *st; + int len; + + if(server_mode != SERVER_PARSER_MODE) + return; + if(res < 2) + return; + if(buf[0] != '#' || buf[1] != '!') + return; + if(compute_bundle_root(path, bundle_root, sizeof(bundle_root)) < 0) + return; + + len = strlen(bundle_root); + st = malloc(sizeof(*st)); + if(!st) + return; + st->s = strdup(bundle_root); + if(!st->s) { + free(st); + return; + } + st->len = len; + /* finfo_lock is already held by get_finfo() in our caller. */ + if(string_tree_insert(&finfo->open_readdir_sticky, st) < 0) { + /* duplicate, OK */ + free(st->s); + free(st); + } +} +#endif + static int tup_fs_read(const char *path, char *buf, size_t size, off_t offset, struct fuse_file_info *fi) { int res; int fd; +#ifdef FUSE_NFS_WORKAROUND + struct file_info *predict_finfo = NULL; +#endif if(fi->fh == 0) { struct file_info *finfo; @@ -1352,6 +1808,16 @@ static int tup_fs_read(const char *path, char *buf, size_t size, off_t offset, if (res == -1) res = -errno; +#ifdef FUSE_NFS_WORKAROUND + if(offset == 0 && res >= 2 && server_mode == SERVER_PARSER_MODE) { + predict_finfo = get_finfo(path); + if(predict_finfo) { + maybe_record_shebang(path, buf, res, predict_finfo); + put_finfo(predict_finfo); + } + } +#endif + if(fi->fh == 0) { close(fd); } diff --git a/src/tup/server/fuse_server.c b/src/tup/server/fuse_server.c index 0d9c7d852..060e42e72 100644 --- a/src/tup/server/fuse_server.c +++ b/src/tup/server/fuse_server.c @@ -34,6 +34,10 @@ #include "tup/variant.h" #include "tup/container.h" #include "tup_fuse_fs.h" +#ifdef FUSE_NFS_WORKAROUND +#include +#include +#endif #include "master_fork.h" #include #include @@ -41,6 +45,9 @@ #include #include #include +#ifdef FUSE_NFS_WORKAROUND +#include +#endif #ifdef __linux__ #include #include @@ -66,9 +73,39 @@ static uid_t original_euid; static pthread_mutex_t curps_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_t fuse_tid; +#ifdef FUSE_NFS_WORKAROUND +/* Identify Fuse-T specifically among possible libfuse providers. The + * NFS-workaround build supports any libfuse2-compatible backend (Fuse-T + * on macOS, libfuse on Linux, macFUSE if linked) and detects the NFS + * readdir-getattr pattern at runtime. But some mitigations are only + * meaningful — or only accepted — by Fuse-T (its NFS proxy is the + * caching layer we need to disable, and the option names like + * `nolocalcaches` are Fuse-T-only; macFUSE rejects unknown options at + * mount time). Inspect the dylib that actually resolved a libfuse + * symbol rather than the version string, since libfuse-t doesn't + * export libfuse3's fuse_pkgversion() and osxfuse_version() can + * theoretically collide between backends. + */ +static int fuse_t_in_use(void) +{ + Dl_info info; + if(dladdr((void *)&fuse_version, &info) == 0) + return 0; + if(info.dli_fname == NULL) + return 0; + return strstr(info.dli_fname, "fuse-t") != NULL; +} +#endif + static void *fuse_thread(void *arg) { struct fuse_args args = FUSE_ARGS_INIT(0, NULL); +#ifdef FUSE_NFS_WORKAROUND + struct fuse *fuse; + char *mountpoint; + int multithreaded; +#endif + if(arg) {} /* Need a garbage arg first to count as the process name */ @@ -92,13 +129,69 @@ static void *fuse_thread(void *arg) if(fuse_opt_add_arg(&args, "-onobrowse,noappledouble,noapplexattr,quiet") < 0) return NULL; #endif + +#ifdef FUSE_NFS_WORKAROUND + /* Fuse-T proxies FUSE through the kernel's NFS client, whose + * attribute and readdir caches break tup's run-script semantics: + * a second readdir of a directory within one parser pass is + * served from the NFS cache and never reaches our FUSE layer, so + * virtual outputs added by an earlier rule are invisible to the + * next `run` directive. libfuse's standard attr_timeout/ + * entry_timeout don't propagate to that NFS layer, so we use + * `nolocalcaches`, which Fuse-T forwards to its NFS client to + * disable vnode/attribute/readdir caching wholesale. + * + * The option name is Fuse-T-only: macFUSE doesn't recognize it + * and would reject the mount, so the option is gated behind a + * runtime check for Fuse-T rather than emitted unconditionally + * on every NFS-workaround build. + */ + if(fuse_t_in_use()) { + if(fuse_opt_add_arg(&args, "-onolocalcaches,noattrcache") < 0) + return NULL; + } +#endif + #ifdef __FreeBSD__ if(fuse_opt_add_arg(&args, "-ouse_ino") < 0) return NULL; #endif +#ifdef FUSE_NFS_WORKAROUND + /* Use fuse_setup + fuse_loop instead of fuse_main so we can + * control teardown. On Fuse-T, fuse_main's teardown restores + * SIGPIPE to SIG_DFL before closing the NFS socket, which + * immediately kills the process with a non-zero exit code. + */ + fuse = fuse_setup(args.argc, args.argv, &tup_fs_oper, + sizeof(tup_fs_oper), &mountpoint, + &multithreaded, NULL); + fuse_opt_free_args(&args); + if(fuse == NULL) + return NULL; + + if(multithreaded) + fuse_loop_mt(fuse); + else + fuse_loop(fuse); + + /* Manual teardown: remove signal handlers, then re-ignore + * SIGPIPE before the unmount closes the NFS socket. + */ + { + struct fuse_session *se = fuse_get_session(fuse); + struct fuse_chan *ch = fuse_session_next_chan(se, NULL); + fuse_remove_signal_handlers(se); + signal(SIGPIPE, SIG_IGN); + fuse_unmount(NULL, ch); + fuse_destroy(fuse); + free(mountpoint); + } +#else fuse_main(args.argc, args.argv, &tup_fs_oper, NULL); fuse_opt_free_args(&args); +#endif + return NULL; } @@ -339,6 +432,37 @@ int server_init(enum server_mode mode) signal(SIGCHLD, SIG_DFL); #endif +#ifdef FUSE_NFS_WORKAROUND + /* Probe whether readdir triggers a stat of the returned entries + * (e.g. Fuse-T). After reading a virtual probe directory, if the + * sentinel file gets a getattr, the workaround is enabled + * automatically by the FUSE callbacks. + */ + { + char probepath[PATH_MAX]; + DIR *dp; + struct stat st; + snprintf(probepath, sizeof(probepath), "%s%s/@nfs_probe@", + TUP_MNT, get_tup_top()); + dp = opendir(probepath); + if(dp) { + /* If the NFS behavior is present, this readdir + * will cause automatic getattr on the sentinel, + * enabling the workaround. + */ + readdir(dp); + closedir(dp); + } + /* Finalize the probe by statting the done file. This + * transitions the state machine to DONE regardless of + * whether the sentinel was hit. + */ + snprintf(probepath, sizeof(probepath), "%s%s/@nfs_probe@/.nfs_done", + TUP_MNT, get_tup_top()); + stat(probepath, &st); + } +#endif + server_inited = 1; return 0; @@ -441,14 +565,39 @@ static int finfo_wait_open_count(struct server *s) rc = pthread_cond_timedwait(&s->finfo.cond, &s->finfo.lock, &ts); if(rc != 0) { if(rc == ETIMEDOUT) { +#ifdef FUSE_NFS_WORKAROUND + /* Fuse-T (and any FUSE-over-NFS backend) drops + * occasional release callbacks for files that + * stay mapped during process exit — the lua/ + * dyld pattern of multiple opens on the same + * binary is a reliable trigger. The flush + * callback that precedes release fires + * normally, so the on-disk writes are safe; + * only open_count parity is broken. Warn and + * proceed instead of failing the job. + * + * The recorded access list comes from + * tup_fuse_handle_file at open time, not from + * the release, so dependency tracking is also + * unaffected. + */ + server_lock(s); + fprintf(stderr, "tup warning: FUSE missed %d release callback(s) for this sub-process; continuing anyway.\n", s->finfo.open_count); + server_unlock(s); + s->finfo.open_count = 0; + break; +#else server_lock(s); fprintf(stderr, "tup error: FUSE did not appear to release all file descriptors after the sub-process closed.\n"); server_unlock(s); + finfo_unlock(&s->finfo); + return -1; +#endif } else { perror("pthread_cond_timedwait"); + finfo_unlock(&s->finfo); + return -1; } - finfo_unlock(&s->finfo); - return -1; } } if(s->finfo.open_count < 0) { @@ -602,6 +751,17 @@ int server_run_script(FILE *f, tupid_t tupid, const char *cmdline, struct tup_entry *tent; struct server s; struct tup_env te; +#ifdef FUSE_NFS_WORKAROUND + /* Each run-script needs a unique @tupjob-N path so the kernel's + * NFS client (under Fuse-T) does not serve a stale readdir result + * from a previous script in the same parser pass. Use a monotonic + * counter offset by 1<<28 to stay above any real tupid. See + * t2094-run4 for the canonical failure. + */ + static unsigned int run_script_seq; + int script_job_id; + int parser_job_id; +#endif if(tup_db_get_environ(env_root, NULL, &te) < 0) return -1; @@ -618,8 +778,52 @@ int server_run_script(FILE *f, tupid_t tupid, const char *cmdline, s.error_mutex = NULL; tent = tup_entry_get(tupid); init_file_info(&s.finfo, 0); - if(exec_internal(&s, cmdline, &te, tent, 0) < 0) + +#ifdef FUSE_NFS_WORKAROUND + /* Swap the parser's FUSE group registration to a unique id for the + * duration of this script. The script runs in @tupjob-/... + * which the kernel has never cached a readdir for. After exec we + * restore the parser's original id so subsequent parser ops still + * resolve through the same finfo. + */ + script_job_id = (int)(1u << 28) + __sync_fetch_and_add(&run_script_seq, 1); + pthread_mutex_lock(&curps_lock); + if(curps) { + parser_job_id = curps->s.finfo.tnode.id; + tup_fuse_rm_group(&curps->s.finfo); + if(tup_fuse_add_group(script_job_id, &curps->s.finfo) < 0) { + /* best-effort restore */ + (void)tup_fuse_add_group(parser_job_id, &curps->s.finfo); + pthread_mutex_unlock(&curps_lock); + environ_free(&te); + return -1; + } + s.id = script_job_id; + } else { + parser_job_id = -1; + } + pthread_mutex_unlock(&curps_lock); +#endif + + if(exec_internal(&s, cmdline, &te, tent, 0) < 0) { +#ifdef FUSE_NFS_WORKAROUND + pthread_mutex_lock(&curps_lock); + if(curps && parser_job_id >= 0) { + tup_fuse_rm_group(&curps->s.finfo); + (void)tup_fuse_add_group(parser_job_id, &curps->s.finfo); + } + pthread_mutex_unlock(&curps_lock); +#endif return -1; + } +#ifdef FUSE_NFS_WORKAROUND + pthread_mutex_lock(&curps_lock); + if(curps && parser_job_id >= 0) { + tup_fuse_rm_group(&curps->s.finfo); + (void)tup_fuse_add_group(parser_job_id, &curps->s.finfo); + } + pthread_mutex_unlock(&curps_lock); +#endif environ_free(&te); if(display_output(s.error_fd, 1, cmdline, 1, f) < 0) @@ -843,8 +1047,27 @@ int server_parser_stop(struct parser_server *ps) return rc; } +int tup_fuse_server_has_dir(const char *path) +{ + struct string_tree *st; + int found = 0; + + pthread_mutex_lock(&curps_lock); + if(curps) { + pthread_mutex_lock(&curps->lock); + st = string_tree_search(&curps->directories, path, strlen(path)); + if(st) + found = 1; + pthread_mutex_unlock(&curps->lock); + } + pthread_mutex_unlock(&curps_lock); + return found; +} + int tup_fuse_server_get_dir_entries(const char *path, void *buf, - fuse_fill_dir_t filler) + fuse_fill_dir_t filler, + void (*on_entry)(const char *name, void *ctx), + void *ctx) { struct parser_directory *pd; struct string_tree *st; @@ -867,6 +1090,8 @@ int tup_fuse_server_get_dir_entries(const char *path, void *buf, RB_FOREACH(st, string_entries, &pd->files) { if(mfiller(buf, st->s, NULL, 0)) goto out_unps; + if(on_entry) + on_entry(st->s, ctx); } rc = 0; out_unps: diff --git a/src/tup/server/tup_fuse_fs.h b/src/tup/server/tup_fuse_fs.h index 778d65d0d..7afdf6e4c 100644 --- a/src/tup/server/tup_fuse_fs.h +++ b/src/tup/server/tup_fuse_fs.h @@ -41,7 +41,10 @@ int tup_fuse_add_group(int id, struct file_info *finfo); int tup_fuse_rm_group(struct file_info *finfo); void tup_fuse_set_parser_mode(int mode); int tup_fuse_server_get_dir_entries(const char *path, void *buf, - fuse_fill_dir_t filler); + fuse_fill_dir_t filler, + void (*on_entry)(const char *name, void *ctx), + void *ctx); +int tup_fuse_server_has_dir(const char *path); void tup_fuse_fs_init(void); int tup_fs_inited(void); extern struct fuse_operations tup_fs_oper;