Parallel downloads rebased - #1958
Conversation
…l downloads Snapshot GetVisible() on the UI thread before handing off to Task.Run, preventing InvalidOperationException when parallel downloads modify the collection concurrently. Also fix installer script to use 64-bit dotnet. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… of the PR Restores the plain 'dotnet publish' invocations in Scripts/Windows/Build-WindowsInstaller.ps1. Hardcoding C:\Program Files\dotnet\dotnet.exe is machine-specific and unrelated to parallel downloads.
ClearQueue() only stops new work from starting. With parallel downloads, books already running kept going after an abort or a disk-full result, so the queue reported itself stopped while downloads continued. Adds ProcessQueueViewModel.CancelAllAsync(), which clears the queue and cancels every active book, and uses it for both the abort and disk-full paths. Both Cancel All buttons now route through it as well - the Avalonia one previously cancelled only Queue.Current, so it missed every book but the first.
…d a numeric control Replaces the MultiThreadEnabled checkbox and the ProcessorCount default with a single MaxConcurrentDownloads value where 1 means serial - the behaviour Libation had before parallel downloads existed. The old pairing could not express 'off' at all: the bool was hardcoded true in the view model constructor and the int setter clamped to a minimum of 2, so there was no way back to one-at-a-time downloads. Collapsing both into one value makes that state unreachable rather than merely fixed, and removes the risk of the two settings disagreeing. Bounds live in Configuration as named constants: minimum 1, default 3, maximum 10. Audible throttles license requests, so the default is deliberately conservative and the cap keeps users from choosing a number that produces license denials instead of speed. WinForms swaps the 'Parallel downloads' checkbox for an 'At once:' spinner that reads its bounds from those constants.
…sor count Chardonnay had parallel downloads with no way to configure them, since the queue logic lives in shared UI code but each UI supplies its own controls. Adds the Auto-scroll toggle and the 'At once' spinner to Chardonnay's queue panel, bound to the same view model properties the WinForms panel uses. Also uses Environment.ProcessorCount as the spinner's ceiling rather than its default: min(ProcessorCount, 10). Downloading is bound by Audible's license throttling rather than local CPU, so core count says nothing about how many concurrent downloads will succeed - it only bounds how many decrypts can usefully run at once. The default stays 3. Spinner bounds are bound rather than hardcoded, so the two UIs cannot drift apart.
The 'At once' spinner sat at x136-181 on the second row, but the 'DL Limit:' label starts at x148. Moves the label and spinner up to the first row beside the Auto-scroll checkbox, where there is clear space between Cancel All and Clear Finished.
The queue loop only woke when a book finished, so books queued a moment after it started sat idle until an in-flight download happened to complete. Observed as one book downloading alone for 15 seconds with a limit of 5, then four starting within 37ms of the first finishing. The loop now waits on whichever comes first: an active task completing, or a new book being queued. The enqueue signal is captured before the queue is inspected, so a book queued between TryDequeueNext and the wait completes the token already held rather than a fresh one - otherwise the wakeup is lost and the loop sleeps until something else finishes. The 'nothing left, exit' path re-checks the same signal for the same reason.
Both of these are reachable only once more than one book is active at a time, and both were found by compiling TrackedQueue<T> on its own. MarkCompleted moved a book from Active to Completed and raised only CompletedCountChanged. The display index of an active book is derived from Completed.Count, so when the second of two active books finishes first the two swap places - with no CollectionChanged, a bound list keeps painting the old order and rows show the wrong book's progress. Out-of-order completion is the normal case with parallel downloads. Now raises a Move for the book whose position actually changed; with one book at a time the indices are equal and nothing is raised, so the sequential path behaves exactly as before. GetAllItems built a lazy Concat inside the lock and returned it, so the enumeration ran unlocked. Any foreach or LINQ over the queue while a book task mutated it threw InvalidOperationException. It now returns a snapshot taken while the lock is held. This is the source of the crash that setLiberatedVisibleMenuItem was patched for at the symptom. RemoveActive and ClearCurrent also read _completed.Count outside the lock when computing the index to report; both now capture it inside.
The opt-in daily download limit landed after this branch was written and lives inside the sequential while (Queue.MoveNext()) loop this change replaces. It is sequential by construction, so rebasing alone leaves it subtly wrong rather than merely conflicted. The gate now runs in the dispatch loop, between taking a book off the queue and starting its task. That keeps the existing semantics - checked as a book is about to start so the queue keeps its contents and the limit can be changed mid-run - while books already in flight carry on. Putting it inside the book task instead would have every blocked book polling the history at once. Three concrete defects that fell out of the collision: RequeueLast deferred a book with Queue.ClearCurrent(), which drops Active[0]. With one book at a time that is the book being deferred; with three in flight it is somebody else's download, so deferring the second active book silently evicted the first. It now removes the book it was given. A book cancelled at the gate was left on the active list. The sequential loop retired it on the next MoveNext(); there is no next MoveNext(), so it is marked completed explicitly. CancelAllAsync existed twice after the rebase - the sequential version that sets cancelAllRequested and cancels Queue.Current, and this branch's version that cancels every active book. Unified into one that does both. The flag matters: a queue paused on the limit is sitting in WaitForDailyLimitAsync and that flag is how it learns to stop. AnyOtherQueuedBookAllowed enumerates the queue with Queue.Any(...) while book tasks mutate it; that is safe now that GetAllItems snapshots under the lock, and it also now takes a copy of the active list to cancel.
Faulted book tasks were dropped unobserved. The reaping pass removes any completed task from the active set before the closing WhenAll can rethrow, so an exception out of ProcessOneAsync - which can happen via GetFailureActionAsync in its finally - went nowhere. In the sequential loop it reached the outer catch and was logged. It is logged again. The bad-book dialog did not survive concurrency. The license and Widevine messages are guarded to appear once per run, but the "skip this book?" dialog is per book and its "apply to all remaining books" answer lands in a shared BadBookSessionContext. Three books failing together put three modals on screen racing to set the same override. The dialog is now serialised on the session, and each book re-checks the override after its turn comes: if the book ahead answered "apply to all", the question is not asked again. Machine capability no longer overwrites the stored concurrency setting. MaxAllowedConcurrentDownloads clamped both the getter and the setter, so a user who chose 8 and then opened the same config on a two-core machine read back 2 - and had 2 written over their 8. The stored value is now bounded only by the hard limit, which is machine-independent; processor count bounds the spinner and is applied at the point of use, where the loop decides how many books to run.
Same unlocked-enumeration problem as the others: the speed limit is changed from the UI thread while book tasks are starting and finishing, and Active is the live list.
TrackedQueue<T> is a pure data structure with no dependencies beyond System, so the behaviour parallel downloads relies on can be asserted directly rather than inferred from the UI. Every case here needs more than one book active at once, which is what made these defects unreachable before. The two that mattered: a book finishing out of order now reports the reorder, so a list bound only to CollectionChanged stays in step with the queue; and the queue can be enumerated while it is being mutated, which used to throw. The sequential path is covered too - one book at a time still raises no Move, because the book that finishes is already first.
Bounding the persisted setting by processor count was only half of it. Both spinners take their maximum from the same machine-derived number and are two-way, so a NumericUpDown handed a value above its maximum coerces the display down and writes the coerced value straight back. A user who chose 8 on their desktop and opened the queue panel on a two-core laptop still ended up with 8 replaced by 2 - the same defect, one layer up, and not fixed by the configuration change alone. The bound is now the machine's capability or the stored value, whichever is higher, so the spinner can never coerce what is already there. Lowering it remains the user's to do, and what actually runs is still held down to what the machine can manage at the point of use.
WinForms positioned every control in the panel absolutely, mixing left-anchored and right-anchored ones. At the design width it looked fine; narrower than that, Clear Finished slid left over the "At once" spinner - and the DL Limit label had always slid onto Cancel All the same way, long before this branch. The two rows also never lined up with each other, because the labels and the spinners each started at different offsets. Cancel All and Clear Finished now dock to the panel edges and the settings sit in a two-row table between them: labels right-aligned in a fixed column, controls left-aligned in the next, so "At once" and "DL Limit" line up and stay lined up. Nothing can overlap, because nothing is placed by hand any more. Avalonia's row gained a fourth column for the same setting, which left less room before the star column collapses and Clear Finished clips. The spinner asked for 90px to show a number between 1 and 10; 72 is enough and gives most of that width back.
The settings row was a single line of four groups: Cancel All, the auto-scroll and concurrency controls, the speed limit, and Clear Finished. Adding the concurrency control pushed the total past what the queue panel is usually given, and Clear Finished - last in the row - was the one that ran off the edge. Narrowing the spinners bought some room back but not enough to be worth relying on. The two settings now stack, the way the WinForms panel does, with their labels right-aligned in a shared column so they line up. That roughly halves the horizontal space they need. The row grows from 40 to 72 to hold both lines, and the two buttons stretch to the new height.
|
Buckle up, it's going to be a big one. As you well know, this is an ambitious one that you took a bite of. I assume most people are using AI help these days so I made this thorough and (hopefully) consumable by humans and AI. Thanks for the honest write-up — it made this much faster to review, and the substance holds up: the dispatch loop, the multi-active Three things I'd want fixed before merge, then some decisions and nits. One of them is in the area you asked to have double-checked. There's an implementation-notes appendix at the end with reproduction steps and a couple of traps, in case you hand any of this to an assistant. 1. Abort and disk-full throw
|
|
Thank you — that's the most useful review I've had on anything. The appendix especially; the I am going to work these blockers locally first and check them out and I will do i full push at the end and I will let you know when to look. I epxect a number of incremental fixes and I don't want to cause you to get pummled with a lot of individutal notificaitons. So, All three reproduce. Taking them in order. More later, but here is the full plan, 1.
|
|
Your plan looks right, and batching it into one push at the end is fine by me - no need to drip-feed. One correction before you start: don't clear On #2, keeping delivery synchronous when there's no invoker is fine, but it means the tests cover inline delivery while the app runs the posted path - the one with the ordering bug. Add a fake invoker that queues actions so at least one test drives concurrent Auto-scroll: your instinct is right. Restore master's behavior and let the checkbox gate that same logic in both UIs. Note that leaves Last small thing: with Abort as a session override, every in-flight book will run the whole abort path. Short-circuit the inherited ones to cancelled rather than having each re-enter Nothing else from me - the rest of your plan looks solid. |
OK, this was a lot harder then I expected. Chardonay is ticky. Please please double check the work. Very rewarding but I am tired of tesitng both standard and chardonay. :-)
Continues #1885 by @SirBiggin, rebased onto current
master(bb0ece9e). His two commits areunsquashed and still authored by him — the concurrent queue loop and the multi-active
TrackedQueueare his work and remain the substance of this change. We're both added to
LibationContributor.cs.Opened as a new PR rather than force-pushed into #1885, per @rmcrackan's request.
The original four review points
cancels every active item rather than just
Current.MaxConcurrentDownloadsdefaults to 3 with a numeric control and a hard capof 10. The concern was real: above 3 reproduced
Content License denied, which surfaces asordinary download failures.
masteris empty for that file.The daily download limit
818510d1landed after this branch was written, inside the exactwhile (Queue.MoveNext())loopthis change replaces, so rebasing alone left it subtly wrong rather than merely conflicted. The gate
now runs in the dispatch loop, between taking a book off the queue and starting its task — same
semantics, while books already in flight carry on. Three defects fell out of that:
RequeueLastdeferred a book withClearCurrent(), which dropsActive[0]. With three in flightthat's somebody else's download, so deferring the second active book evicted the first.
MoveNext(), and there is no nextMoveNext().CancelAllAsyncexisted in two forms after the rebase; unified into one that both setscancelAllRequested(how a paused queue learns to stop) and cancels every active book.The
TrackedQueuedefectsBoth of the ones you reproduced are fixed, and both are pinned down by tests in
Source/_Tests/LibationUiBase.Tests/TrackedQueueTests.cs:MarkCompletednow raises aMovefor the book whose display position actually changed. With onebook at a time the indices are equal and nothing is raised, so the sequential path is unchanged.
GetAllItemsreturns a snapshot taken under the lock instead of a lazyConcatevaluated outsideit. That's the source of the crash
setLiberatedVisibleMenuItemwas patched for at the symptom,and it makes
AnyOtherQueuedBookAllowed'sQueue.Any(...)safe.GetActive()was added for thesame reason; the speed-limit and Cancel All paths use it.
Plus the three from your diff read: faulted book tasks are observed and logged rather than dropped;
the bad-book dialog is serialised on the session so concurrent failures can't stack modals racing
over the same override; and machine capability no longer overwrites the stored concurrency setting.
That last one needed fixing twice. Bounding the persisted value was only half of it — both spinners
took their maximum from the same machine-derived number and are two-way, so a control handed a value
above its maximum coerces the display down and writes it back. The bound is now machine capability
or the stored value, whichever is higher. The trade-off is an asymmetry: on a machine smaller than
the stored value you can lower the setting but not raise it. The alternative — bound by the flat hard
limit, apply machine capability only at the point of use — avoids that but lets someone pick 10 on a
two-core box and silently get 2. I went with the first because it's what you asked for; happy to
switch.
Tested
Against a real library, both UIs: parallel downloads, out-of-order completion, Cancel All mid-run,
the limit pause and cancel-while-paused, the Plus-only deferral with owned titles still downloading,
concurrency persistence across restarts (including with
DOTNET_PROCESSOR_COUNTfaked low), and thebad-book dialog under five simultaneous failures.
Layout, and two things to decide
The queue settings panel needed rework in both UIs — WinForms positioned everything absolutely and
mixed left- and right-anchored controls, and Chardonnay's single row of four groups pushed Clear
Finished off the edge. Both are now two-row layouts with labels right-aligned in a shared column.
Worth noting the WinForms narrow-width overlap predates this PR — the DL Limit label has always
slid onto Cancel All the same way — so this fixes more than it caused. Both layout commits are last
on the branch and drop cleanly if you'd rather they went separately.
Unrelated, but found while testing: Chardonnay's
ProcessBookControlis pinned to 87px and splits*,16between the title stack and the status line. The stack isn't clipped, so a title wrapping tothree lines paints over the status text. Reproducible on master with any long title — the daily-limit
status just makes it obvious, being a long string where you'd normally read "Queued". Left alone
here since this PR doesn't otherwise touch that file, but worth an issue.