Fix Cancel Upload sometimes doing nothing (BL-16340) - #8184
Conversation
https://issues.bloomlibrary.org/youtrack/issue/BL-16340 Cancel's whole job is to set one boolean, _progress.CancelRequested, which the upload polls between stages. The publish screen greys out UPLOAD BOOK the instant the user clicks Cancel, and the only thing that ever brings it back is the uploadCanceled websocket event, which C# sends only if that flag is still true when the upload finishes. So anything that stops the flag being set in time leaves the upload running to completion AND the button dead for good -- exactly what was reported. Three ways that could happen, all fixed here: 1. The cancel request had to queue behind the very work it was trying to interrupt. libraryPublish/cancel was registered to run on the UI thread and (by default) to take the global api sync lock, both of which a long upload can occupy for minutes -- offscreen-browser thumbnailing on the UI thread, then BloomPdfMaker.exe pulling the book and its resources back through Bloom's own server. If the POST was stuck until after the upload's last cancellation check, the flag arrived too late to stop anything. It is now registered (handleOnUiThread: false, requiresSync: false), like the other cancel endpoints (progress/cancel, signLanguage/cancelImportVideo), which exist to set a flag and nothing more. 2. A cancel during the pre-upload handshake was thrown away. The client shows Cancel from the moment the user commits, but two api round trips happen before libraryPublish/upload is posted, and HandleUpload opened by clearing CancelRequested. The flag is now cleared in HandleCheckSubscriptionMatch -- the first call of every upload attempt -- and HandleUpload declines to start if it is already set. HandleCancel reports the cancellation itself when no upload is running, so the user gets the same feedback in that window. 3. The UI had no way back regardless of what C# decided. isCanceling is now also cleared on uploadSuccessful and on an error in the progress box, so the user can never be left with no working button. Also: - A cancel that arrives after the server has committed the upload no longer reports "Upload was cancelled". If a book id came back, the book really is on BloomLibrary, and saying otherwise is a falsehood the user might act on. - WebProgressAdapter.CancelRequested is backed by a volatile field; it is written and read from different threads with no lock between them. - Bulk upload shares _progress but has no Cancel of its own, so it now clears the flag too; otherwise a leftover cancel could silently stop it before it started. Tests: added EndpointHandlerTests.RequestNotRequiringSync_IsServedWhileAnother RequestHoldsTheSyncLock, which proves the property fix 1 depends on. Verified it is not falsely passing: flipping the interrupting endpoint to requiresSync: true makes it fail. Ran the full C# suite through build/agent-dotnet.sh: 3064 passed, 0 failed, 12 skipped. Front end: pnpm test 674 passed / 5 skipped, pnpm typecheck clean, pnpm lint 0 errors, and build/agent-vite.sh builds the real bundle cleanly. The bug itself is a race, so it was not reproduced deterministically; the fixes are reasoned from the code paths rather than from a repro. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
https://issues.bloomlibrary.org/youtrack/issue/BL-16340 Three follow-ups from the Devin review of PR #8184, all in the same cancel-state lifecycle the parent commit reworked. 1. An error line no longer re-enables UPLOAD BOOK mid-upload. handleUploadError fires for ANY Error-kind progress message, not just a terminal one, so clearing isCanceling there could re-enable the button while a cancel-pending upload was still running -- and pressing it would clear the pending cancel server-side (via checkSubscriptionMatch) and start a second upload alongside the first. That clearing is now gone. It was only ever needed for one case: a cancel ending in the "quiet" result, where C# sent no event at all and the screen escaped its Cancel state solely because the pre-upload checker happened to emit an error line. So instead: 2. C# now sends uploadCanceled on EVERY cancelled ending, including "quiet" (without a second message, since one has already been given). The screen's Cancel state is cleared only by that event or by uploadSuccessful, and making it depend on some other path happening to emit an error line is exactly how it gets left permanently greyed out -- the whole of BL-16340. 3. Reading "is a cancel pending?" and setting "an upload is now running" are now one indivisible step under a small private lock. Previously they could interleave so that HandleCancel reported the cancel AND the upload started anyway, which then reported it a second time. Harmless but ambiguous; the lock removes it. It is held only across those field reads/writes -- never across the upload, the HTTP response, or a websocket send. Ran the fast gate: typecheck clean, lint 0 errors, EndpointHandlerTests 6/6. Full suites run separately against this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…or one (BL-16340) https://issues.bloomlibrary.org/youtrack/issue/BL-16340 Two bugs from Devin's re-review of PR #8184. 1. Clicking Cancel a moment too late told the user the exact opposite of what happened. HandleCancel decided whether to announce the cancellation purely from "is an upload running", and that flag is cleared only AFTER UploadBookAsync has already sent uploadSuccessful. A cancel landing in that window therefore printed a red "Upload was cancelled" and sent the event, seconds after the user had been told the book was on BloomLibrary -- someone could reasonably conclude the book was missing and upload it again. It is the same falsehood the previous commit deliberately guarded against inside UploadBookAsync; this path just wasn't covered. The two booleans are replaced by an explicit three-state UploadAttemptState (Idle / StartingUp / Uploading). Only StartingUp means "nobody else will report this"; Idle now means the attempt is over and a late cancel says nothing at all. 2. Cancelling during the pre-upload round trips, then pressing Upload in the "already on BloomLibrary" dialog, gave the book a brand-new instance id on disk and then uploaded nothing. HandleUploadAfterChangingBookId called Model.ChangeBookInstanceId before delegating to HandleUpload, which then declined because a cancel was pending -- so the book's identity was changed permanently for an upload that never happened. It now checks for a pending cancel before changing anything. The same dialog's other buttons were declined silently, so the button simply appeared dead; that path now says "Upload was cancelled" instead. Whether the dialog should appear at all after a cancel is a separate UI question and is left for the developer. Also: the bulk-upload flag clears now take the same lock as every other write to it, and the new sync-lock test records why a spare server worker is guaranteed. Ran the fast gate: typecheck clean, lint 0 errors, EndpointHandlerTests 6/6. Full suites run separately against this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
https://issues.bloomlibrary.org/youtrack/issue/BL-16340 Closes the last window Devin found, and the one it named exactly: a cancel that lands after UploadBookAsync has already decided how the upload ended, where the upload then finishes in a way that emits only an error line -- a failure or an exception. Nothing sent an outcome event in that case, and the client deliberately ignores error lines when deciding whether the user may press UPLOAD BOOK again, so the button stayed greyed out for good. That is the original BL-16340 symptom, reached through a narrower window than the one already fixed. UploadBookAsync now returns whether it sent the client an outcome EVENT (uploadSuccessful or uploadCanceled) rather than only a progress message, and HandleUpload's finally reconciles: if a cancel is pending and no event went out, it sends uploadCanceled. No message with it -- the failure has already been reported, and the point here is releasing the screen, not saying it twice. The invariant this establishes is worth stating plainly: every attempt that the user cancelled now ends with an outcome event, whatever route it took to get there. Sending one twice is harmless, since the client just clears the same state again; sending none is the bug. Ran the fast gate: typecheck clean, lint 0 errors, EndpointHandlerTests 6/6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
[Claude Opus 5 (1M context) from Andrew's machine during preflight] Consulted Devin on 2026-08-10, up to commit Devin ran four times across this branch's commits and its findings drove three of the five commits here. Each finding has its own thread; this is just the tally. Acted on and resolved
Open, waiting on the developer (three threads)
These three are interrelated — they are all about what the user should see in the moments before an upload really begins — so they are going back as one decision rather than being patched piecemeal. None is a regression against master. The bot gauntlet stopped here at its four-cycle cap: each Devin pass was finding issues in code written in the previous pass, which is the point to get a human's eyes on it. Also seen and not mirrored: nine informational items (low signal). Two of them were acted on anyway — the "quiet" ending that sent no event, and the duplicate-report race now closed by a lock — and the rest were assessed, including confirming that moving the cancel handler off the UI thread is safe because the upload already does the same work from a background thread. CI ( |
https://issues.bloomlibrary.org/youtrack/issue/BL-16340 Closes the three remaining cancel-window problems Devin found, together rather than one at a time -- patching them individually is what produced the last two rounds of churn, because they pull against each other. The root of all three: the screen offered Cancel the instant the user clicked UPLOAD BOOK, but C# did not consider an attempt to exist until its first request arrived. Everything in that gap was ambiguous. Now the client waits for C# to acknowledge the attempt -- the subscription check coming back -- before it lets Cancel be pressed. Cancel is visible immediately but disabled until then, so the button does not flicker between labels. A cancel therefore cannot be sent before C# knows an attempt exists, which removes the race by construction instead of narrowing it: - A cancel can no longer be wiped out by the start-of-attempt reset arriving late, so it can no longer be silently ignored with the book uploaded anyway. - The client now drops the "is there an existing copy?" reply if the user cancelled while it was in flight. The collision dialog no longer appears over an abandoned attempt, and no upload is posted for C# to decline -- which also removes the duplicate "Upload was cancelled" line, since only one report is ever made. - A cancel arriving when C# has nothing running now sends the release event WITHOUT the message. It frees the screen if it was still showing Cancel, while not claiming an upload was cancelled when it may just have succeeded. Chose to keep Cancel visible-but-disabled rather than briefly showing UPLOAD BOOK again: a button that changes label twice in a second reads as a glitch, and a disabled Cancel says "not yet" honestly. Ran the fast gate: typecheck clean, lint 0 errors, EndpointHandlerTests 6/6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
https://issues.bloomlibrary.org/youtrack/issue/BL-16340 The last outstanding finding from Devin's review, and the one part of the "first moments of an upload" group the previous commit did not actually close. Suppressing the collision dialog after a cancel stopped the client posting an upload in the common path, but not in this one: if the collision check comes back clean the client posts the upload immediately, and the user can then click Cancel. libraryPublish/upload waits on the UI thread while libraryPublish/cancel is deliberately lock-free, so the cancel overtakes it -- HandleCancel announces the cancellation, and the upload request then arrives and announces it again. Two identical red lines for one click. Both declining paths (HandleUpload and HandleUploadAfterChangingBookId) now send the release event without a second message. The event is still sent, so the screen is released even if one of these is the last thing to happen; only the duplicate wording is gone. Ran the fast gate: EndpointHandlerTests 6/6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
[Claude Opus 5 (1M context) from Andrew's machine during preflight] Consulted Devin on 2026-08-10, up to commit Re-review clean — every finding is now resolved, and Devin confirms it. All 4 bugs and all 5 Investigate flags raised across six passes are marked resolved by Devin at this commit; 12 informational items were seen and not mirrored (two of them acted on anyway). The developer answered the earlier decision report, and both open questions were implemented:
One correction worth recording: I claimed on the duplicate-message thread that suppressing the dialog had removed that problem at the source. It had not — if the collision check comes back clean the client posts the upload immediately, and a later cancel overtakes it because CI ( |
Cancel's whole job is to set one boolean,
_progress.CancelRequested, which the upload polls between stages. The publish screen greys out UPLOAD BOOK the instant the user clicks Cancel, and the only thing that ever brings it back is theuploadCanceledwebsocket event — which C# sends only if that flag is still true when the upload finishes. So anything that stops the flag being set in time leaves the upload running to completion and the button dead for good, which is exactly what was reported.Three ways that could happen, all fixed here:
The cancel request had to queue behind the very work it was trying to interrupt.
libraryPublish/cancelwas registered to run on the UI thread and (by default) to take the global api sync lock — both of which a long upload can occupy for minutes (offscreen-browser thumbnailing on the UI thread, thenBloomPdfMaker.exepulling the book and its resources back through Bloom's own server). If the POST was stuck until after the upload's last cancellation check, the flag arrived too late to stop anything. It is now registered(handleOnUiThread: false, requiresSync: false), like the other cancel endpoints (progress/cancel,signLanguage/cancelImportVideo), which exist to set a flag and nothing more.A cancel during the pre-upload handshake was thrown away. The client shows Cancel from the moment the user commits, but two api round trips happen before
libraryPublish/uploadis posted, andHandleUploadopened by clearingCancelRequested. The flag is now cleared inHandleCheckSubscriptionMatch— the first call of every upload attempt — andHandleUploaddeclines to start if it is already set.HandleCancelreports the cancellation itself when no upload is running, so the user gets the same feedback in that window.The UI had no way back regardless of what C# decided.
isCancelingis now also cleared onuploadSuccessfuland on an error in the progress box, so the user can never be left with no working button.Also:
WebProgressAdapter.CancelRequestedis backed by avolatilefield; it is written and read from different threads with no lock between them._progressbut has no Cancel of its own, so it now clears the flag too; otherwise a leftover cancel could silently stop it before it started.Tests
Added
EndpointHandlerTests.RequestNotRequiringSync_IsServedWhileAnotherRequestHoldsTheSyncLock, which proves the property fix 1 depends on. Verified it is not falsely passing: flipping the interrupting endpoint torequiresSync: truemakes it fail.Note on verification
The bug itself is a race — the reporter could not reproduce it either — so it was not reproduced deterministically here. The fixes are reasoned from the code paths rather than confirmed against a failing repro. Fix 3 is the one that categorically rules out the reported symptom; fixes 1 and 2 address the causes that the code paths support.
Ref: https://issues.bloomlibrary.org/youtrack/issue/BL-16340
Devin review
This change is