Skip to content

Fix Cancel Upload sometimes doing nothing (BL-16340) - #8184

Draft
andrew-polk wants to merge 7 commits into
masterfrom
BL-16340
Draft

Fix Cancel Upload sometimes doing nothing (BL-16340)#8184
andrew-polk wants to merge 7 commits into
masterfrom
BL-16340

Conversation

@andrew-polk

@andrew-polk andrew-polk commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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, which is 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_IsServedWhileAnotherRequestHoldsTheSyncLock, which proves the property fix 1 depends on. Verified it is not falsely passing: flipping the interrupting endpoint to requiresSync: true makes 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 Reviewable

andrew-polk and others added 3 commits August 10, 2026 12:10
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>
Comment thread src/BloomBrowserUI/publish/LibraryPublish/LibraryPublishSteps.tsx
Comment thread src/BloomExe/web/controllers/LibraryPublishApi.cs
andrew-polk and others added 2 commits August 10, 2026 14:33
…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>
Comment thread src/BloomExe/web/controllers/LibraryPublishApi.cs Outdated
Comment thread src/BloomExe/web/controllers/LibraryPublishApi.cs
Comment thread src/BloomExe/web/controllers/LibraryPublishApi.cs
Comment thread src/BloomExe/web/controllers/LibraryPublishApi.cs
Comment thread src/BloomExe/web/controllers/LibraryPublishApi.cs Outdated
Comment thread src/BloomExe/web/controllers/LibraryPublishApi.cs
Comment thread src/BloomExe/web/controllers/LibraryPublishApi.cs Outdated
@andrew-polk

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 (1M context) from Andrew's machine during preflight] Consulted Devin on 2026-08-10, up to commit 2fe96846528ee13012d93b0168c850e03d710df5.

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

  • An error line could re-enable UPLOAD BOOK while a cancel-pending upload was still running — fixed in 8fdbeb6443.
  • A cancel clicked just as an upload finished announced the opposite of what happened — fixed in e17c6e5b54.
  • The collision dialog's "change the book id" button changed the book's identity on disk and then uploaded nothing — fixed in e17c6e5b54.
  • A cancelled upload that ended in failure released nothing, leaving the button dead — fixed in 2fe9684652.
  • A cancel-flag reset depending on the UI never allowing a second attempt — assessed as pre-existing and not made worse; reasoning on its thread.

Open, waiting on the developer (three threads)

  • Whether the "already on BloomLibrary" dialog should appear at all after a cancel.
  • Two identical "Upload was cancelled" lines in the pre-upload window.
  • A cancel that overtakes the start-of-attempt reset, and the related "silent when Idle" trade-off.

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 (pr-automation) passed on every pushed commit. CodeRabbit is configured on this repo but has auto_review disabled, so it did not run.

andrew-polk and others added 2 commits August 10, 2026 15:16
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>
@andrew-polk

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 (1M context) from Andrew's machine during preflight] Consulted Devin on 2026-08-10, up to commit 4ac067f509.

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:

  • The collision dialog after a cancel — suppressed. The client drops the "is there an existing copy?" reply if the user cancelled while it was in flight, so no dialog appears over an abandoned attempt and no upload is posted to be declined.
  • The first moments of an upload — fixed as one change rather than three patches. The client now waits for C# to acknowledge the attempt before Cancel becomes pressable (visible but disabled until then), which removes the start-of-attempt race by construction rather than narrowing it.

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 libraryPublish/cancel is lock-free while libraryPublish/upload waits on the UI thread. Devin re-reported it, was right, and 4ac067f509 fixes it properly: both declining paths send the release event without a second message.

CI (pr-automation) passed on every pushed commit. CodeRabbit is configured on this repo but has auto_review disabled, so it did not run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant