Skip to content

Interrupted first download leaves a lock that blocks every subsequent launch for 8 minutes (timeout < stale window) #213

Description

@iceteaSA

v0.50.0 shipped, our cache had ≤0.49.4, so the plugin auto-downloaded on launch. The 80 MB download stalled around half-way, the operator ^C'd the hung TUI, and every subsequent launch then failed until the cache directory was cleaned by hand.

The interesting part isn't that an interrupt leaves a lock — it's that the two constants make the leftover unrecoverable for a window, and I don't think that ordering is intentional:

const DOWNLOAD_LOCK_TIMEOUT_MS = 120_000;      // how long a waiter waits
const DOWNLOAD_LOCK_STALE_MS   = 10 * 60_000;  // when a lock is declared stale

acquireDownloadLock reclaims a lock only once ageMs > DOWNLOAD_LOCK_STALE_MS, but a waiter gives up after DOWNLOAD_LOCK_TIMEOUT_MS. Timeout is 8 minutes shorter than stale, so a lock aged 0–10 min is neither reclaimable nor waitable-out — each launch polls for 2 minutes, times out, throws Timed out waiting for download lock, and fails. Swapping the ordering (or reclaiming when the writer PID is dead) closes it without changing either behaviour's intent.

Three smaller things in the same path, in rough priority order:

1. The lock owner records a PID that's never used for liveness.

const owner = `${process.pid}:${Date.now()}:${randomUUID()}`;

The PID is written and only ever compared for release ownership. A process.kill(pid, 0) liveness probe would reclaim an orphaned lock immediately instead of waiting out a fixed age — the same fix pattern as the artifact-owner lease in the Rust side, which does check liveness.

2. No interrupt handler, so ^C during a download is the worst case rather than a handled one. There is no SIGINT/exit hook, so the partial .tmp and the lock both survive. The catch block does clean up tmpPath on a normal failure — it's specifically the killed-process path that leaks both.

3. Orphaned .tmp files are never swept. tmpPath includes pid + timestamp + random, so every interrupted attempt leaves a distinct file:

aft.20860.1786650000000.a3f9.tmp    41MB

Nothing ever removes these. On our box it was one 41 MB file, but a user hitting ^C a few times on a slow connection accumulates them silently, and the resolver doesn't look at them. A sweep of *.tmp older than the stale window in the versioned dir, when the lock is taken, would be the natural place.

Separately, and maybe the more important one: the first-run failure surface is a launch-blocking 80 MB download with no visible progress. The plugin's ERROR path degrades gracefully on later launches, but the first attempt just hangs the TUI, which is what prompted the ^C that caused all of the above. Downloading in the background and running degraded until the binary is ready would remove the whole class — I don't know how that interacts with your plugin-init contract, so raising it rather than proposing it.

Recovery for anyone hitting this, since the error text doesn't point at the cache:

rm -f ~/.cache/aft/bin/v<version>/.download.lock ~/.cache/aft/bin/v<version>/*.tmp

Happy to send a PR for the constant ordering + PID liveness + tmp sweep if you want them — they're small and independent of the launch-blocking question.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions