Describe the bug
The init hook in src/hooks/init.ts contains a non-atomic check-and-write sequence:
// src/hooks/init.ts (current behavior)
if (!(await autoupdateNeeded())) return // ← READ marker mtime (or ENOENT)
debug('autoupdate running')
await writeFile(autoupdatefile, '') // ← WRITE marker (3 lines later)
// ... spawn `<cli> update --autoupdate` as detached child
There is a multi-await window between the autoupdateNeeded() read on the first line and the writeFile on the third line. When the marker file does not yet exist (fresh install with no ~/Library/Caches/<cli>/autoupdate) or has expired the debounce window, multiple parallel CLI invocations can all enter autoupdateNeeded() and all see ENOENT → return true (or all see the same stale mtime) before any one of them reaches writeFile. Each then spawns its own <cli> update --autoupdate detached child.
The expected behavior is "at most one autoupdate child per debounce window." The actual behavior is "up to N autoupdate children per race-window event, where N is the parallelism."
To Reproduce
- On any machine using a CLI that depends on
@oclif/plugin-update, ensure the autoupdate marker file is missing:
rm -f ~/Library/Caches/<cli>/autoupdate # macOS
# or
rm -f ~/.cache/<cli>/autoupdate # Linux
- Fire several concurrent CLI invocations:
for i in {1..6}; do <cli> --version >/dev/null 2>&1 & done; wait
- Count surviving autoupdate children:
ps auxww | grep '<cli> update --autoupdate' | grep -v grep | wc -l
Expected: at most 1. Observed: typically equal to the number of concurrent invocations.
Expected behavior
At most one <cli> update --autoupdate child should be spawned per debounce-window event, regardless of how many CLI invocations start concurrently.
Screenshots
n/a
Environment (please complete the following information):
- OS & version: macOS 26.x (Sequoia), Apple Silicon — also reproducible on Linux
- Shell/terminal & version: zsh
- Node: 20.x and 24.x both reproduce
@oclif/plugin-update version: 4.7.43 (also present in earlier versions; the affected code paths have existed for many releases)
Additional context
This bug interacts with a separate bug — the unbounded recursive sleep loop in update.ts's debounce() (filed as a separate issue, link to be added once opened). Bug 1 (this one) produces extra spawned children; bug 2 makes those children pin in memory forever. Either bug alone is a real cost; combined they amplify each other.
I have a fix ready and will open a PR shortly that uses open(path, 'wx') (O_EXCL) to make the check and create a single atomic step. Only one process can create the marker; others see EEXIST and bail. Stale-marker reclaim handled by unlink + open('wx') (small remaining race window, bounded to one race per debounce-window per machine).
Describe the bug
The init hook in
src/hooks/init.tscontains a non-atomic check-and-write sequence:There is a multi-await window between the
autoupdateNeeded()read on the first line and thewriteFileon the third line. When the marker file does not yet exist (fresh install with no~/Library/Caches/<cli>/autoupdate) or has expired the debounce window, multiple parallel CLI invocations can all enterautoupdateNeeded()and all seeENOENT → return true(or all see the same stale mtime) before any one of them reacheswriteFile. Each then spawns its own<cli> update --autoupdatedetached child.The expected behavior is "at most one autoupdate child per debounce window." The actual behavior is "up to N autoupdate children per race-window event, where N is the parallelism."
To Reproduce
@oclif/plugin-update, ensure the autoupdate marker file is missing:Expected: at most 1. Observed: typically equal to the number of concurrent invocations.
Expected behavior
At most one
<cli> update --autoupdatechild should be spawned per debounce-window event, regardless of how many CLI invocations start concurrently.Screenshots
n/a
Environment (please complete the following information):
@oclif/plugin-updateversion: 4.7.43 (also present in earlier versions; the affected code paths have existed for many releases)Additional context
This bug interacts with a separate bug — the unbounded recursive sleep loop in
update.ts'sdebounce()(filed as a separate issue, link to be added once opened). Bug 1 (this one) produces extra spawned children; bug 2 makes those children pin in memory forever. Either bug alone is a real cost; combined they amplify each other.I have a fix ready and will open a PR shortly that uses
open(path, 'wx')(O_EXCL) to make the check and create a single atomic step. Only one process can create the marker; others seeEEXISTand bail. Stale-marker reclaim handled byunlink+open('wx')(small remaining race window, bounded to one race per debounce-window per machine).