@photostructure/fs-metadata
    Preparing search index...

    Gotchas and Platform-Specific Issues

    This guide covers common issues, platform quirks, and important considerations when using @photostructure/fs-metadata.

    The default timeout is 5000ms (5 seconds). You can override it in two ways:

    1. Environment variable (applies globally):

    # Linux/macOS
    export FS_METADATA_TIMEOUT_MS=30000

    # Windows
    set FS_METADATA_TIMEOUT_MS=30000

    2. Per-call options (takes precedence):

    const metadata = await getVolumeMetadata("/mnt/nas", {
    timeoutMs: 30000, // 30 seconds
    });

    The environment variable is useful for:

    • CI/CD pipelines with slow or emulated environments
    • Docker containers accessing remote volumes
    • Systems with many network mounts

    Problem: Linux, macOS, and Windows can block system calls indefinitely when network filesystems are unhealthy.

    Solution: Always use timeouts for network volumes:

    // Default timeout may be too short for network drives
    const metadata = await getVolumeMetadata("\\\\nas\\share", {
    timeoutMs: 30000, // 30 seconds
    });

    Why it happens:

    • Windows: SMB shares can block when the remote host is down
    • Linux: NFS mounts without soft option will retry forever
    • macOS: AFP/SMB shares may hang during network interruptions

    The same limitation applies to watchAvailableSpace(). Its timeoutMs bounds when the watcher reports a polling error, but Node's statfs() request cannot be cancelled. The watcher does not issue another probe until that underlying request settles, preventing one dead share from consuming another libuv worker on every interval. close() and unref() stop future timers but cannot cancel an already-running filesystem request; that request may keep the process alive until the operating system returns.

    watchVolumeMountPoints() keeps recurring topology snapshots shallow on macOS and Windows. On Linux it issues one directory probe for each newly observed local mount path (including the initial set) so its results continue to omit file bind-mount targets like getVolumeMountPoints(). It never probes paths whose filesystem type is configured as remote. Its timeoutMs bounds each caller-visible snapshot; each Linux directory probe gets one quarter of that budget so it can report before the outer snapshot deadline. A timeout is reported through lastError and an attached error listener, but cannot cancel the underlying native or filesystem work. The watcher does not start another poll until that raw work settles; close() cannot cancel it.

    timeoutMs bounds the caller-visible promise of each single-volume operation — getVolumeMetadata(), getVolumeMetadataForPath(), and getMountPointForPath() — including initial realpath()/stat() calls and later native metadata queries. It is not a single global deadline for getAllVolumeMetadata(), which applies timeoutMs to enumeration and to each per-volume call separately, so its total runtime can approach volumeCount × timeoutMs at low concurrency. The operating-system request may still remain blocked in a background worker because Node's filesystem promises and several platform APIs do not provide portable cancellation.

    A mount point that cannot answer — a dead autofs trigger, an unplugged x-systemd.automount, a wedged FUSE mount — blocks stat() and readdir() until the kernel gives up, which can be many seconds. Two properties keep that cost local:

    Path resolution only stats ancestors. getMountPointForPath() and getVolumeMetadataForPath() partition the candidate mount points by path ancestry before doing any IO, and stat only the ancestors of the target. The non-ancestors are touched solely when no ancestor is on the target's device (the bind-mount fallback). On a typical Linux desktop that is 2 stats rather than 57, and an unrelated dead mount is never touched.

    Enumeration gives each health probe a fraction of the budget. On Linux and macOS, getVolumeMountPoints() is bounded by timeoutMs as a whole, so its health-probe phase gets a quarter of that. A wedged mount reports status: "timeout" on both platforms. Enumeration returns the other volumes instead of failing the whole call on one bad entry. Windows is exempt — timeoutMs applies per system call there, with no outer deadline to lose a race to, so its probe keeps the full budget.

    Note that a timeout abandons the operation, it does not cancel it: fs.promises.stat() has no cancellation, so the libuv worker stays occupied until the kernel returns. This is why resolution avoids issuing the stat rather than merely bounding it, and why embedders should size UV_THREADPOOL_SIZE (see below) for the number of volumes they enumerate.

    How each platform gets there. The mechanism differs, but no platform lets an unreachable volume tax an unrelated lookup:

    • Linux reads the mount table without per-volume I/O.
    • Windows enumerates drive roots for path resolution without GetDriveTypeW, the status check, or GetVolumeInformationW, so a disconnected network drive costs nothing. Those entries carry only mountPoint; anything needing status or fstype enumerates normally and pays for the probe.
    • macOS never enumerates for path resolution: getMountPointForPath() and getVolumeMetadataForPath() resolve through targeted native calls (fstatfs). They also ignore mountPoints, so caching it changes nothing there.

    For public enumeration, macOS bounds its native accessibility probes with the same fraction of timeoutMs the JavaScript probe gets and schedules them in a rolling four-probe window. One wedged mount is reported as timeout without preventing later healthy mounts from being checked or rejecting the whole call.

    Every filesystem call here — stat(), readdir(), and the native metadata workers — runs on libuv's thread pool, not on one thread per core. That pool holds UV_THREADPOOL_SIZE threads (4 by default, regardless of core count) and is shared with the rest of your process.

    maxConcurrency therefore defaults to the pool size plus a small fixed headroom (7), not to availableParallelism(). Core count is the wrong unit: on a 128-core machine it would queue 128 requests against those same 4 threads, and any unrelated read your application issues would wait behind the whole backlog. The headroom is additive because what it covers — threads idling during this library's event-loop turnaround between completions — is a fixed cost that does not grow with the pool.

    Measured on a 32-core box enumerating 57 volumes:

    maxConcurrency elapsed
    1 57 ms
    2 37 ms
    4 29 ms
    7 (default) ~28 ms
    8 25 ms
    16 22 ms
    32 21 ms

    Past the pool size the curve is nearly flat, so the default gives up a few milliseconds of enumeration time for a much shallower queue. To go faster, raise the pool itself — it must be set before any IO, so before requiring this module:

    UV_THREADPOOL_SIZE=16 node app.js
    

    That lifts both the pool and this default. Raising maxConcurrency alone buys less, and costs your application more latency.

    Optical drives (CD/DVD) can take 30+ seconds to spin up:

    const metadata = await getVolumeMetadata("D:\\", {
    timeoutMs: 45000, // 45 seconds for optical drives
    });

    Mapped network drives may not appear in volume listings:

    // This might not show mapped drives
    const volumes = await getVolumeMountPoints();

    // Use UNC path directly instead
    const metadata = await getVolumeMetadata("\\\\server\\share");

    C: is both a system volume and user storage. The library returns it in all queries:

    const volumes = await getAllVolumeMetadata({ includeSystemVolumes: false });
    // C:\ will still be included on Windows

    Windows 10+ supports paths longer than 260 characters (MAX_PATH) when long path support is enabled:

    // Paths up to 32,768 characters are now supported
    const longPath = "C:\\" + "verylongdirectoryname\\".repeat(20) + "file.txt";
    const metadata = await getVolumeMetadata(longPath);

    Requirements:

    • Windows 10 version 1607 or later
    • Long path support enabled in registry or manifest
    • Path must not exceed PATHCCH_MAX_CCH (32,768 wide characters)

    Enabling long paths (administrator required):

    # Set registry key
    New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" `
    -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force

    Note: Even without long path support enabled system-wide, the library handles paths up to 32,768 characters internally and fails gracefully on older systems.

    If you see "No Target Architecture" errors when building from source, ensure Visual Studio build tools are properly installed. See Windows Build Guide.

    Linux permits a regular file to be a mount target. Docker commonly uses this for /etc/hostname, /etc/hosts, and /etc/resolv.conf.

    getVolumeMountPoints() and getAllVolumeMetadata() enumerate directory mount targets and omit these file mounts. Explicit path operations honor the actual mount boundary: getMountPointForPath("/etc/hosts") returns /etc/hosts, and getVolumeMetadataForPath("/etc/hosts") returns metadata for the filesystem mounted there. getVolumeMetadata("/etc/hosts") also works when the file is an actual mount target.

    When skipNetworkVolumes: true, remote mount targets are deliberately not probed because even a metadata operation can block indefinitely on a dead filesystem. Their target type therefore cannot be determined safely, so unprobed remote entries are retained even if a target happens to be a file.

    If resolving paths that may be file mounts, do not pass a mountPoints array created by getVolumeMountPoints(), because that public enumeration does not contain file targets. Let the resolver read the Linux mount table internally, or add the exact file target to a custom array.

    A systemd direct automount (x-systemd.automount in /etc/fstab, or a .automount unit) keeps its autofs trigger entry in /proc/self/mounts and mounts the real filesystem over it once the path is touched:

    systemd-1 /mnt/12tb autofs rw,relatime,fd=71,pgrp=1,timeout=0,direct 0 0
    /dev/sda1 /mnt/12tb btrfs rw,relatime,space_cache=v2,subvolid=5,subvol=/ 0 0

    Mounting over an existing mount appends to the table, so this library reports the last entry for a path: /mnt/12tb above comes back as btrfs on /dev/sda1 — with a uuid, a label, and its subvolume fields — not as autofs on systemd-1. The same holds for mount --bind onto an existing mount point and for overlay stacking.

    This is a last-wins rule, not a mount-tree evaluation. /proc/self/mounts states no parent/child relationship between entries, so file order is a proxy for stacking order. mount --move breaks that proxy: it re-attaches an existing mount without reallocating the internal ID that orders the listing, so the moved mount keeps its earlier position even though it is now on top. Resolving that would require /proc/self/mountinfo. This is a known, documented limitation — if you rely on mount --move, read mountinfo yourself rather than trusting fstype here.

    The same stacking occurs with mount --bind onto an existing mount point and with overlay mounts. If you parse /proc/self/mounts yourself, take the last match rather than the first: the autofs trigger names no block device, so blkid and /dev/disk/by-uuid have nothing to resolve, and autofs is a system fstype, so the volume also disappears from default enumeration.

    An automount whose device is absent (an empty card reader, an unplugged drive) has no overmount, so it correctly remains autofs. Touching such a path blocks until the kernel gives up — several seconds on some hardware — which can exceed timeoutMs. See Configuring the Default Timeout.

    The node:20 Docker image is not supported due to GLIBC version requirements:

    # ❌ Won't work
    FROM node:20

    # ✅ Use this instead
    FROM node:20-bullseye
    # or
    FROM debian:bullseye
    RUN apt-get update && apt-get install -y nodejs npm

    Problem: Electron apps that bundle @photostructure/fs-metadata fail to build on Linux with:

    fatal error: blkid/blkid.h: No such file or directory
    

    …even though npm install reports that prebuilds were found.

    Why it happens: @electron/rebuild (invoked by electron-forge, electron-builder, and friends) always recompiles native modules from source against Electron's bundled Node ABI. The Node-ABI prebuilds shipped in prebuilds/ are not Electron-compatible and are ignored. The fresh compile then needs the same system headers a from-source build needs.

    Solution: Install libblkid-dev (and friends) on the build machine before running electron-rebuild / electron-forge package:

    # Debian/Ubuntu
    sudo apt-get install -y libblkid-dev

    # Fedora/RHEL
    sudo dnf install -y libblkid-devel

    # Alpine
    apk add blkid-dev

    In CI, add this as a step before npm install / the Electron package step. The same applies to any consumer that compiles from source for an unsupported architecture or glibc version. See CONTRIBUTING.md for the full development dependency list.

    Many mount points on Linux are system-only:

    // This filters out /proc, /sys, /dev, snap mounts, etc.
    const userVolumes = await getAllVolumeMetadata({ includeSystemVolumes: false });

    // To see everything:
    const allVolumes = await getAllVolumeMetadata({ includeSystemVolumes: true });

    On btrfs, several mount points can be distinct subvolumes of one filesystem (e.g. / = @ and /home = @home). libblkid keys uuid on the block device, so all siblings report the same uuid and mountFrom:

    const root = await getVolumeMetadata("/"); // uuid: 9486d442-…
    const home = await getVolumeMetadata("/home"); // uuid: 9486d442-… (same!)

    To distinguish siblings, use the additive btrfs-only fields (all undefined elsewhere): subvolid / subvol (from mount options), or the strong subvolumeUuid (per-subvolume UUID via ioctl, kernel ≥ 4.18). See Subvolume Identity for the full rationale, stability semantics, and how zfs/bcachefs differ.

    used is derived from statvfs f_bfree and available from f_bavail. Nothing requires f_bavail <= f_bfree. On most filesystems f_bavail is the smaller of the two (it excludes root-reserved blocks), so the sum lands under size. btrfs subtracts its metadata and global-reserve overhead from f_bfree but not from f_bavail, so the sum can exceed size by that reserve — about 109 MiB on a 12 TB filesystem.

    Do not assume the two partition size, and do not bound them against it either: these are dynamic counters read from separate accounting paths, and filesystem semantics vary enough that any such range check is a latent test failure. Check that they exist and are numbers.

    libblkid can't resolve a ZFS dataset name (tank/home) to a block device, so ZFS datasets report uuid: undefined:

    const m = await getVolumeMetadata("/tank/home");
    // { uuid: undefined, mountFrom: "tank/home", fstype: "zfs", fsid: "005856b5…" }

    fsid is a 16-hex-character identifier from statfs f_fsid. It is normally stable across remount, reboot, and rename, but it is not immutable: OpenZFS may remap it to resolve a collision when duplicate datasets become active, such as with copied or split pools. Treat it as a current identity or fallback, not as the sole durable identifier; persist an application-owned identity where possible.

    This is separate from the pool GUID. zpool reguid is a rare, explicit administrative operation, not something performed by normal reboots, imports, scrubs, resilvers, or disk replacements. fsid is not the zfs get guid value, and stat -f %i prints its two halves in the opposite order. Populated on ZFS only. See Subvolume Identity.

    For stronger, copy-specific identity, opt into the external OpenZFS queries:

    const m = await getVolumeMetadata("/tank/home", {
    includeZfsGuids: true,
    });
    // { zfsDatasetGuid: "9801903932522705432",
    // zfsPoolGuid: "14889780885664284089", ... }

    These unsigned 64-bit values are strings to preserve precision. The option is off by default, requires the zfs / zpool commands, and leaves either field undefined if its bounded query fails. A timed-out command receives SIGTERM and is detached from the metadata request; the library deliberately does not SIGKILL OpenZFS commands, so one blocked in kernel IO may outlive the call.

    GNOME mounts (Google Drive, MTP phones, SMB shares via Nautilus) are exposed by a single fuse.gvfsd-fuse mount; the individual backends are subdirectories of it, not separate mount table entries:

    /run/user/1000/gvfs                                    <- the only mount entry
    /run/user/1000/gvfs/smb-share:server=nas,share=docs <- just a subdirectory

    That mount is excluded by default: fuse.gvfsd-fuse is in SystemFsTypesDefault, so getVolumeMountPoints() omits it unless you pass includeSystemVolumes: true.

    Opting in returns the aggregate bridge, not one mount point per backend:

    const gvfsBridges = (
    await getVolumeMountPoints({ includeSystemVolumes: true })
    ).filter(({ fstype }) => fstype === "fuse.gvfsd-fuse");

    The bridge does not provide a separate filesystem identity, label, or capacity for each backend. Applications that need to discover individual GIO mounts must use a GIO-aware API rather than infer them from mount-table entries.

    The exclusion is by fstype, not path, because gvfsd-fuse mounts at $XDG_RUNTIME_DIR/gvfs and falls back to $HOME/.gvfs when $XDG_RUNTIME_DIR is unavailable (commonly for root). GVfs does not request FUSE's allow_other option, so only the owner can access the bridge; parent directory permissions may reject another user even before FUSE does. The owner can walk the backend directories, while another user gets EACCES and previously saw the bridge reported as inaccessible.

    APFS volumes in the same container share space:

    const volumes = await getAllVolumeMetadata();
    // Multiple volumes might report identical 'available' space
    // because they share the same APFS container

    Memory debugging tools like AddressSanitizer may fail due to SIP:

    # This might not work on macOS with SIP enabled
    ASAN_OPTIONS=detect_leaks=1 npm test

    getMountPointForPath() and getVolumeMetadataForPath() resolve a path to its mount point by device ID matching: mount points on the same device as the target path are candidates, and candidates that are path ancestors of the target are strongly preferred (the deepest one wins).

    When no candidate is a path ancestor — for example, the path is inside a bind mount but only the canonical mount point appears in the mount table — the longest same-device mount point is returned instead. This fallback is intentional: it lets bind-mounted paths resolve to the correct volume.

    Gotcha: if you pass a custom mountPoints array that contains no ancestor of the target path, any same-device entry can be returned, even one with no path relationship to the target. Build custom arrays with getVolumeMountPoints({ includeSystemVolumes: true }) rather than hand-picking entries.

    (macOS is unaffected: it resolves mount points natively via fstatfs() and never scans a mount point list.)

    Hidden file operations behave differently per platform:

    // On Windows: Sets hidden attribute
    await setHidden("C:\\file.txt", true);
    // File remains at: C:\file.txt (hidden)

    // On Linux/macOS: Renames file
    await setHidden("/home/user/file.txt", true);
    // File moved to: /home/user/.file.txt

    Setting an already-hidden file to hidden is a no-op:

    // No error, no change
    await setHidden("/path/to/.hidden", true);

    Dot-prefixing can create invalid paths:

    // This will fail - can't hide root directory
    await setHidden("/", true); // Error!

    // This will fail - parent directory in path
    await setHidden("/path/../file", true); // Error!

    Drive accessibility checks run on the Windows callback pool and are marked as long-running so the pool can provide replacement capacity when a network provider blocks. A timed-out OS request may still remain in that pool because Windows cancellation is driver-dependent; avoid repeatedly probing the same known-dead share.

    Tests can fail due to file system state changes:

    // ❌ Bad: Assumes exact values
    expect(metadata.available).toBe(1000000);

    // ✅ Good: Checks types and ranges
    expect(typeof metadata.available).toBe("number");
    expect(metadata.available).toBeGreaterThanOrEqual(0);

    File operations may not be immediately visible:

    // After creating a file
    await fs.writeFile(path, data);
    // May need a small delay on some systems
    await new Promise((resolve) => setTimeout(resolve, 10));
    const hidden = await isHidden(path);

    What works on one platform may fail on another:

    // Windows: This is valid
    const metadata = await getVolumeMetadata("C:"); // No trailing slash

    // Linux/macOS: Must have trailing slash
    const metadata2 = await getVolumeMetadata("/"); // Trailing slash required
    Error Cause Solution
    ENOENT Path doesn't exist Check path exists before calling
    EACCES Permission denied Run with appropriate permissions
    ETIMEDOUT Operation timed out Increase timeoutMs option
    Invalid mountPoint Empty or invalid path Validate input before calling
    statvfs failed Linux filesystem issue Check mount is accessible
    GetVolumeInformation failed Windows API error Verify drive letter is correct
    try {
    await getVolumeMetadata(mountPoint);
    } catch (error) {
    if (
    process.platform === "win32" &&
    error.message.includes("cannot find the path")
    ) {
    // Windows-specific path error
    } else if (
    process.platform === "linux" &&
    error.message.includes("statvfs")
    ) {
    // Linux-specific filesystem error
    } else if (error.code === "ETIMEDOUT") {
    // Cross-platform timeout
    }
    }

    Enable debug logging to troubleshoot issues:

    # Linux/macOS
    NODE_DEBUG=fs-meta npm test

    # Windows
    set NODE_DEBUG=fs-meta && npm test

    Debug output includes:

    • Native API calls and their results
    • Timeout occurrences
    • Thread creation/destruction (Windows)
    • Memory allocation tracking (debug builds)