DownloadLock: wait on a blocking flock instead of failing (alternative to #23343) - #23356
DownloadLock: wait on a blocking flock instead of failing (alternative to #23343)#23356dduugg wants to merge 2 commits into
Conversation
Two separate brew processes needing the same download (e.g. two brew bundle parallel-install workers that both need an undeclared implicit dependency) previously died immediately with OperationInProgressError, since LockFile#lock takes a non-blocking flock. The error already told users to "wait for it to finish", so do that automatically. No polling or signals needed: flock(LOCK_EX) without LOCK_NB is already a kernel-level blocking wait, so the kernel wakes us the instant the holder releases. LockFile#lock grows a `blocking:` option and the open/flock/inode-recheck sequence moves into a private `acquired?`. The blocking wait deliberately sits outside `ignore_interrupts`: inside it, SIGINT is deferred until the lock frees, so Ctrl-C hangs indefinitely. The non-blocking path keeps its existing `ignore_interrupts` wrapping, leaving FormulaLock, CaskLock and cleanup.rb behaviour unchanged. Timeout.timeout caps the wait, since it does interrupt a blocked flock. See #23328
`acquired?` opened the lock file and then called `flock`, but nothing closed it if an exception unwound out of that call. With a blocking `flock` that is reachable: `Timeout::Error` from `DownloadLock`'s wait cap, `Interrupt` from Ctrl-C, or `DownloadQueue#cancel` raising `Interrupt` into a worker thread. That leaked one file descriptor per interrupted wait. Worse, in the window after `flock` returns but before `@lockfile` is assigned, the process kept the lock with nothing referencing it, so `unlock` became a no-op and a retry then waited on a lock its own process held and could not release. A repro aligning the release with the timeout expiry hit this 20 times in 400 attempts; with this change, 0 in 400 and no descriptor growth. Assign or close in an `ensure` so every exit path is covered, and add tests for the interrupted-after-acquiring case, for the wait actually elapsing, and for the inode recheck (which no existing test covered).
|
I reviewed this in depth before deciding between it and #23343, including reproducing the numbers in the description above. One real defect turned up, which I have fixed on the branch, and I still think #23343 is the one to land. Detail below so the reasoning is on the record rather than just the outcome. A blocking flock admits a defect that a non-blocking one cannot
Two consequences, both confirmed empirically:
The window is milliseconds rather than nanoseconds, because the three filesystem syscalls between the flock and the assignment are each interrupt checkpoints. A repro that aligns the holder's release with the timeout expiry hit it 20 times in 400 attempts. Fixed by assigning or closing in an The defect is gone, but it is still why I would not land this. It exists because the flock is long enough to be interrupted. #23343's flock is always The measured win holds up, and does not pay for the costThe benchmark above is directionally right. I measured blocking wake latency at 0.164ms mean over 20 randomized trials against 52.5ms for 0.1s polling. The CPU claim is real and I understated it: paired and interleaved at n=20, blocking used 0.000326s against polling's 0.005182s, with polling higher in 20 of 20 trials and non-overlapping ranges. My table's 1.9x on n=6 invites dismissal as noise when the effect is robust. Two corrections. The true ratio is about 321x rather than 500x, matching the ~310x that theory predicts. And both my 500x and my polling row are phase-locked to a single hold duration, so neither is a stable figure. "Two to three orders of magnitude" is what I should have written. None of which changes that 50ms of average wake latency is invisible against a wait measured in seconds to minutes, and one wakeup per 100ms is not a real cost. A 321x improvement on an imperceptible quantity does not buy much, and here it is paid for by restructuring a primitive that serializes the Cellar, the Caskroom and the download cache, in a file that has already needed two race fixes (4bd75d4, 969005a). My evidence for the restructure being safe was not evidenceI cited The inode recheck is the most correctness-sensitive thing this diff moved, from Three other corrections to the description above
One gap that applies to whichever landsThe Also worth carrying across: waiting makes resume-after-crash work, because Closing this in favour of #23343. Review was AI-assisted, consistent with the disclosure in the description. Every claim above that is stated as measured was reproduced on this branch, and the two numbers I trusted without re-running my own repro (the orphaned lock rate, and the mutation survival) I verified independently before writing them down. |
What does this change do, and why?
Draft: this is an alternative to #23343, opened for comparison rather than to merge alongside it. Both fix the same half of #23328, so pick one and I'll close the other. Posting this because @MikeMcQuaid asked on #23343 whether we could use "signals or similar here and not just
sleep".It turns out no signals are needed.
flock(LOCK_EX)withoutLOCK_NBis already a kernel-level blocking wait, so the kernel wakes us the instant the holder releases. The polling in #23343 only exists becauseLockFile#lockhardcodesLOCK_NB. So this version adds ablocking:option toLockFile#lockand moves the open/flock/inode-recheck sequence into a privateacquired?helper.Measured comparison against #23343
Wake latency is how long after the holder releases the waiter actually acquires. 6 trials each, with deliberately non-interval-aligned hold durations so polls are never accidentally synchronised with the release.
sleep 0.1(#23343)sleep 1.0Blocking is roughly 500x more responsive than the 0.1s polling in #23343 and uses less CPU than it, so it removes the interval tradeoff I was weighing over there entirely.
The subtle part, and why the diff touches
LockFileA naive version of this introduces a Ctrl-C hang.
LockFile#lockwraps its body inignore_interrupts, and a blocking flock insideignore_interruptsswallowsSIGINTcompletely: the process stays blocked (printing "One sec, cleaning up...") until the lock frees, which could be an hour. I verified that experimentally before writing this. The polling approach in #23343 sidesteps it only by accident, because itssleephappens outsideignore_interrupts.So here the blocking wait deliberately sits outside
ignore_interrupts, while the non-blocking path keeps its existing wrapping.FormulaLock,CaskLock, andcleanup.rb's stale-download handling therefore keep their current behaviour byte for byte, andlock_file_spec.rbpasses unchanged, which is the main evidence for that.Timeout.timeoutcaps the wait (it does interrupt a blocked flock, andFile#flockhas no timeout parameter of its own).The cost of this approach versus #23343 is that it restructures a shared, correctness-sensitive primitive rather than only touching
DownloadLock. That is the main thing worth weighing.On using
concurrent-rubyor another library insteadWorth ruling out explicitly, since we already depend on
concurrent-ruby: it can't address this. Every primitive it offers (Event,Semaphore,CountDownLatch,ReadWriteLock, the atomics) is built on Ruby'sMutex/ConditionVariableand coordinates threads within one process. There is noflockusage anywhere in the gem. This race is between two separatebrewprocesses, so shared-memory primitives can't see each other. Cross-process advisory locking is whatflockis for, and it's already in stdlib.Within a single process there's nothing to fix:
DownloadQueuealready dedupes bycached_location, so two of its threads never race on the same download. The contention is strictly cross-process.Third-party gems in this space (
filelock,lockfile, and friends) are thin wrappers overFile#flock, so they'd add a vendored dependency (plus.licenses/entries and RBIs) for no functional gain.Step-by-step reproduction
Same as #23328 and #23343 (two processes racing on one download). Covered here by unit tests plus a real, non-mocked check using two live
flocks: a held lock genuinely blocks a second instance until released, a realSIGINTinterrupts a blocked wait immediately, and the wait cap raises rather than hanging forever.brewcommands to reproduce the bug?brew lgtm(style, typechecking and tests) locally?Used Claude Code to prototype this alternative, run the comparison benchmarks, and write the tests. Verified by:
brew typecheckandbrew style --changed(clean).lock_file,download_strategies/curlandcleanupsuites (all passing).lock_file_spec.rbpasses without modification, which is the check that the non-blocking path's semantics survived the restructure.ignore_interruptshazard experimentally rather than assuming it: a forked child blocked on flock, sent a realSIGINT, comparing bare / inside-ignore_interrupts/ inside-Timeout.timeout. Only theignore_interruptscase swallowed the signal and hung, which is what drove the design here.SIGINTcheck against the finished implementation (Interruptraised immediately, ~0.002s CPU).