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.
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 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.
UV_THREADPOOL_SIZEEvery 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:
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 + 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).
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: