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:
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:
soft option will retry foreverThe 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.
On macOS, metadata, mount enumeration, and path resolution run on detached
native threads. Their path and directory checks also avoid libuv. Node's
process.exit() joins libuv's worker pool, so a JavaScript timeout alone would
not prevent a hung native call from blocking exit.
The addon permits at most four executing jobs and 256 queued jobs across its
Worker environments. Further queued requests reject with code: "EBUSY".
DiskArbitration lock acquisition observes each job's deadline and cancellation;
an OS call that already holds the lock cannot be interrupted. Access probes
reuse in-flight work per mount path, with at most 64 distinct probes. A stalled
call can consume this bounded capacity until the OS returns.
timeoutMs: 0 still disables the deadline. A pending query keeps the event loop
alive for normal operation, but does not prevent explicit process.exit() or
Worker termination. Finite deadlines release the request's event-loop timer
even if native work remains blocked.
This protection covers the macOS volume-query APIs. watchAvailableSpace(),
hidden-attribute operations, and other application calls to Node's filesystem
APIs can still occupy libuv workers. Applications that must exit reliably with
dead mounts should run such work in a child process whose parent enforces an
exit deadline and can send SIGKILL.
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:
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.getMountPointForPath() and
getVolumeMetadataForPath() resolve through targeted native calls
(fstatfs). They also ignore mountPoints, so caching it changes nothing
there.For public enumeration, macOS gives its native access and directory probes a
quarter of timeoutMs 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. No JavaScript directory probe
runs afterward.
UV_THREADPOOL_SIZENode's filesystem calls — including stat() and readdir() — and the
Linux/Windows native metadata workers use libuv's thread pool. That pool holds
UV_THREADPOOL_SIZE threads (4 by default, regardless of core count) and is
shared with the rest of your process. macOS volume queries use the separate
bounded executor described above; raising UV_THREADPOOL_SIZE does not expand it.
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:
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.
A subvolume you did not explicitly mount — a snapshot, or anything made with
btrfs subvolume create inside an existing filesystem — is reached as a
directory, and the kernel gives it its own anonymous st_dev with no entry
in /proc/self/mounts. The standard tools disagree about what to call that:
findmnt reads the mount table and reports the containing mount, while df
walks up while st_dev is unchanged and reports the subvolume. This library
returns both — mountPoint is findmnt's answer,
subvolumeRoot ?? mountPoint is df's:
const m = await getVolumeMetadata("/mnt/12tb/backup-2026-05-02");
// mountPoint: "/mnt/12tb" ← the mount that contains it
// subvolumeRoot: "/mnt/12tb/backup-2026-05-02" ← the subvolume itself
// subvolumeUuid: "319fe68e-…" ← distinct per subvolume
// uuid: "03c98b0e-…" ← the filesystem, shared
Derive volume-relative paths from subvolumeRoot, not mountPoint: it is
mountPoint for an ordinary btrfs mount, and it keeps naming the same subvolume
if that subvolume is later mounted directly.
subvol is undefined for such a path (there is no mount option to read) and
subvolid comes from the ioctl. isReadOnly reflects the subvolume, so a
read-only snapshot under a read-write mount correctly reports true.
This is the one case where getMountPointForPath() returns a mount point whose
device does not match the target's; it requires the containing mount to be btrfs
and statfs() to agree that the path is on btrfs. Any other unresolvable path
still throws No mount point found for path.
used + available Can Exceed sizeused 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.
uuid — fsid Is Best-Effortlibblkid 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).
btrfs is the exception: its anonymous device names the subvolume, not the
mount, so it cannot discriminate mounts. A path on btrfs resolves to the deepest
btrfs mount that is a path ancestor, confirmed by statfs(), regardless of
whether that mount's device matches. This is what makes a nested subvolume
resolve at all, and it is also why /mnt/all/@/photos resolves to /mnt/all
rather than to / when @ is mounted at both.
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.
On btrfs this gotcha is sharper, because resolution there uses path ancestry
rather than the device: a path resolves to the deepest btrfs mount present in
the list you supplied. Omit a deeper btrfs mount and the path silently looks
like a nested subvolume of a shallower one — you get a wrong mountPoint and a
subvolumeRoot computed against it, with no error. Measured: dropping
/mnt/a/inner-mount from the array made a path beneath it resolve to /mnt/a.
The array must contain every mount that is a path ancestor of anything you
resolve. Public enumeration also omits file mount targets, so add any exact file
bind-mount path you need to resolve.
(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: