Skip to content

Race window in init hook can spawn duplicate <cli> update --autoupdate children #1335

@BadassBison

Description

@BadassBison

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

  1. 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
  2. Fire several concurrent CLI invocations:
    for i in {1..6}; do <cli> --version >/dev/null 2>&1 & done; wait
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions