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.
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:
acquireDownloadLockreclaims a lock only onceageMs > DOWNLOAD_LOCK_STALE_MS, but a waiter gives up afterDOWNLOAD_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, throwsTimed 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.
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.tmpand the lock both survive. Thecatchblock does clean uptmpPathon a normal failure — it's specifically the killed-process path that leaks both.3. Orphaned
.tmpfiles are never swept.tmpPathincludes pid + timestamp + random, so every interrupted attempt leaves a distinct file: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
*.tmpolder 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:
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.