npm update fails with EBUSY because your server exited hours ago but its child process still holds the file
env paths · case
Symptom
Section titled “Symptom”A routine operation on a directory fails, and the error blames the filesystem for something you did not do:
npm error code EBUSYnpm error syscall renamenpm error EBUSY: resource busy or lockedDeleting a build directory, replacing a binary, updating a global package, cleaning a temp folder — all of them hit it. Rebooting fixes it, which tells you it is a lock and tells you nothing about whose.
The usual culprit is a process you believe is dead. You pressed Ctrl+C on the parent; the parent exited; a child it spawned is still running and still has the file open.
PS> $f = [IO.File]::Open("$PWD\held.txt", 'Create', 'Write', 'None')PS> Remove-Item held.txtRemove-Item : The process cannot access the file 'held.txt' because it is being used by another process.PS> $f.Close() # now it deletesOn Linux or macOS the same sequence succeeds: the directory entry disappears immediately and the bytes stay alive for the holder until it closes.
To find the holder:
PS> Get-Process | Where-Object { $_.Modules.FileName -like "*held*" }# or, for handles rather than modules, Sysinternals handle.exe -a held.txtPOSIX unlink removes a name, not a file. The inode survives until the last descriptor closes, so a running process never blocks a delete or a rename.
Windows file locking is MANDATORY, not advisory. A handle opened without
FILE_SHARE_DELETE — which is the default in every high-level runtime API,
including Node’s fs.open and .NET’s File.Open — makes the OS refuse deletes
and renames for as long as that handle lives. The refusal comes back as EBUSY
for a rename and EPERM for an unlink, neither of which names the holder.
Two things make it worse than a plain “close your files” problem:
- A signal handler that calls
process.exit()synchronously does not give sockets, database handles, or child processes time to close. The parent vanishes; the handles do not. - Windows has no process groups in the POSIX sense, so killing a parent does not
kill what it spawned.
Ctrl+Creaches the console group; a detached child does not get it, and an orphaned grandchild never does.
That second point is why the lock outlives everything you can see in a task list you skim.
Workaround
Section titled “Workaround”Make shutdown release handles before the process leaves, and kill the whole tree:
const GRACE_MS = 3000;for (const sig of ["SIGINT", "SIGTERM", ...(isWin ? ["SIGBREAK"] : ["SIGHUP"])]) { process.on(sig, async () => { const force = setTimeout(() => process.exit(0), GRACE_MS).unref?.(); await server.close(); // drain connections await db.close(); // release the sqlite handle killProcessTree(child.pid); // taskkill /PID <pid> /T /F on Windows process.exit(0); });}SIGBREAK matters: Ctrl+Break is a distinct signal on Windows, and a handler
registered only for SIGINT leaves the server orphaned when a user presses it.
When you must delete a path that something may hold, retry with backoff rather
than failing on the first EBUSY — antivirus and the search indexer take
transient handles on files you just wrote, and those clear on their own within a
second or two.
This is the file-lifetime half of the Windows process model.
startup-artifact-is-not-a-process is the liveness half: no supervisor owns your
process. Here, no unlink semantics free your file.