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 forevertimeoutMs 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.
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.
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.
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.
User-mounted volumes (like Google Drive, SMB shares via Nautilus) appear under /run/user/*/gvfs:
const volumes = await getVolumeMountPoints();
// May include entries like:
// /run/user/1000/gvfs/smb-share:server=nas,share=documents
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: