Skip to content

Concurrent installs of the same component can destroy its node_modules #1973

Description

@heskew

Summary

Nothing serializes dependency installs for a component. When two installs of the same component overlap, npm's reify tears down a tree the other is building, fails with ENOTEMPTY, and aborts mid-teardown — leaving the component with no node_modules at all.

Observed on 5.1.x on a two-node hosted cluster. Three deploy_component attempts for one component landed within 13 minutes; the first two exited 0, the third died:

npm error code ENOTEMPTY
npm error syscall rmdir
npm error path /home/harperdb/harper/components/<component>/node_modules/node-llama-cpp/dist
npm error errno -39
npm error ENOTEMPTY: directory not empty, rmdir '.../node_modules/node-llama-cpp/dist'

End state: package-lock.json written, node_modules gone. The component kept running only because the worker still had the modules loaded in memory — the loss surfaced on the next restart, days later.

Why nothing prevents it

  • installApplications() (components/Application.ts) pushes every app into applicationInstallationPromises and awaits Promise.allSettled, so installs run concurrently by design.
  • deployLifecycle (components/deployLifecycle.ts) ref-counts in-flight deploys per component name and exists to quiet file watchers. It deliberately allows overlap for the same name (0→1 fires start, 1→0 fires end, intermediate transitions are silent), so it is not — and was never intended as — a mutex around installs.
  • Operation-driven deploys each call prepareApplication() independently, so two calls for the same component have nothing in common to contend on.

Retrying a deploy that appears stuck is the natural operator reaction, which makes this easy to trigger.

Secondary: the failed install leaks a child process

After the failure the container held a [npm install] <defunct> zombie, still unreaped ~24 hours later. nonInteractiveSpawn rejects on timeout via childProcess.kill(); something on the abort path isn't reaping the child.

Suggested direction

Serialize installs per component directory — a mutex or queue keyed on application.dirPath, so a second install of the same component waits rather than racing. A failed install should also not be able to leave a component with fewer dependencies than it started with; staging into a temp dir and swapping, or restoring on failure, would make the operation non-destructive.

Related: #1974 (a destroyed install can never recover) and #1975 (it reports healthy while broken). This bug creates the damage; those two explain why it persisted unnoticed.


🤖 Filed by Claude on behalf of @heskew

Metadata

Metadata

Assignees

Labels

area:componentsComponents / applications subsystembugSomething isn't working

Type

No type

Fields

Priority

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions