Skip to content

Stop publishing hanging forever on the page-checks browser (BL-16612) - #8110

Draft
andrew-polk wants to merge 7 commits into
masterfrom
BL-16612-page-checks-hang
Draft

Stop publishing hanging forever on the page-checks browser (BL-16612)#8110
andrew-polk wants to merge 7 commits into
masterfrom
BL-16612-page-checks-hang

Conversation

@andrew-polk

@andrew-polk andrew-polk commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Found while investigating repeated Nightly Build and Test failures where all 12 visual-regression cases timed out (~24 minutes wasted). The hang is in publishing, not in the tests. Bloom's log ended at the same point in every failing run:

Failed to navigate fully to RemoveUnwantedContentInternal DOM

and then went silent for 23 minutes. Three consecutive nightlies ran the same commit e2a5d45e62 with results fail / pass / fail, so it is a flake rather than a regression.

Two defects, which have to be fixed together

1. OffScreenBrowser.RunAndBlock waited with no timeout. When the page-checks navigation failed, PublishHelper carried on (per the BL-7892 comment) and called RunJavascript on a browser whose page had never loaded — and that never returned. The blocked thread is a BloomServer worker, so the request was never answered and publishing stayed wedged for the life of the process. Every blocking call is now bounded and throws OffScreenBrowserTimeoutException instead of waiting forever. The unbounded _ready.Wait() in the constructor is bounded for the same reason.

2. Carrying on after a failed navigation silently produced a bad book. With no element information, IsDisplayed answers "displayed" for everything and FontsUsed comes back empty, so a BloomPUB keeps content that should have been stripped and embeds none of its fonts. Only epub publishing noticed, because IsDisplayed throws for it. So merely adding the timeout from (1) would have converted the hang into quiet corruption — which is why both land together. Page checks now retry once on a fresh renderer and, failing that, fail the publish (the existing handler reports it) rather than emit a book we know is wrong.

Why the navigation stalls is still unproven

The leading hypothesis is BloomServer worker starvation: the thread blocking on the browser is itself a worker, and the browser's navigation needs a worker to serve the page it is loading. _countBlockedThreads is not computed automatically — callers must register — and OffScreenBrowser never did, so a worker blocked there was invisible to the logic in QueueRequest that adds a worker when all of them are blocked. RunAndBlock now registers, which should break that cycle if it is the cause.

Deliberately not raised: the 10s navigation budget. Per-test durations from the passing run show the entire first case (tab switches, book select, branding, theme, preview screenshot, full BloomPUB staging, several player captures) taking 18.4s, so that navigation normally finishes in a small fraction of 10s. The failures blew through 10s and then never completed — bimodal, i.e. a stall rather than a near-miss, so a longer wait is not the fix. Added BloomServer.GetWorkerPoolDiagnostics() and log it, with the elapsed time, whenever page checks fail, so the next occurrence tells us whether the pool was starved.

Test-side

The visual-regression suite now bounds each request to Bloom and names the call that hung, instead of an opaque "test timed out" pointing at the test function — which was all the original failure gave us to go on. Once Bloom stops answering, the remaining cases fail immediately rather than each burning 120s. Ordinary image-diff failures still run every case, so we keep every diff.

Testing

  • New OffScreenBrowserTests.RunJavascript_WhenTheBrowserDoesNotComeBackInTime_ThrowsRatherThanBlockingForever proves the backstop fires (it gives up at 500ms rather than waiting out a 5s script) and GetWorkerPoolDiagnostics_WithAServerRunning_ReportsWorkerCounts covers the new diagnostics. Both pass, with the existing test as a sanity check.
  • Verified separately that node-fetch honors AbortSignal.timeout — and that it reports AbortError, not TimeoutError, which is why the new code checks signal.aborted rather than the error name.
  • BloomTests.Publish (which drives RemoveUnwantedContent) cannot run in a worktree without a built output/browser: it aborts on a Debug.Fail for a missing favicon.ico. Confirmed identical on pristine code (163 vs 164 failures, same abort point), so this PR's CI run is the real check on those suites.

Ref: https://issues.bloomlibrary.org/youtrack/issue/BL-16612

Devin review


This change is Reviewable

Comment thread src/BloomExe/Publish/OffScreenBrowser.cs
Comment thread src/BloomVisualRegressionTests/index.spec.ts
Comment on lines +158 to +161
throw new ApplicationException(
"Bloom could not determine which parts of this book are visible, so it stopped rather than "
+ "publish a book with the wrong content or missing fonts. See the log for details."
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Devin] Investigate: Behavior change: failed page-checks navigation now fails BloomPUB publishing instead of producing a book

Devin's point: this PR deliberately turns a previously-silent degradation into a hard failure for BloomPUB (before, only epub noticed, via IsDisplayed throwing). It asks that we confirm the BloomPUB entry points surface the new ApplicationException as a friendly error rather than an unhandled crash, since this path was not previously reachable for BloomPUB.

That is the right question to ask of this change, so I traced every path that can now throw.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Claude Opus 5) Good question to press on. I traced every path that can now reach this throw. There are exactly two callers of RemoveUnwantedContentBloomPubMaker.cs:651 (BloomPUB/BloomReader) and EpubMaker.cs:1733 (epub, which already threw here, so it is unchanged) — and BloomPubMaker's creation entry points are reached from:

path what happens now
Publish tab preview (PublishApi.MakeBloompubPreview) explicit try/catch → request.Failed(...) + NonFatalProblem.Report — reported to the user
Save to file / Send to device (PublishToBloomPubApi:388,415) no local catch
Bulk create (BulkBloomPubCreator:121) no local catch
RAB project (RabProjectService:1257) no local catch
CLI (CreateArtifactsCommand, SendFontAnalyticsCommand) no local catch — CLI/harvester, fails the command

No crash risk: the paths without a local catch still land in BloomServer.RequestProcessorLoop's broad catch (Exception error), which logs the message, URL and stack and fails that request. The worker thread survives and Bloom stays up.

But the messaging is thin on those paths. The user gets a failed request and a log entry rather than a friendly explanation — they may just see the operation stop. The exception text is already written in plain English for that reason ("Bloom could not determine which parts of this book are visible, so it stopped rather than publish a book with the wrong content or missing fonts"), but only the preview path actually shows it.

Improving that means touching user-facing error messaging at four call sites, which is past what I should decide alone, so I am leaving this thread open and putting it to the developer. Not a blocker for the fix itself.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Claude Opus 5) Andrew's response: wants to discuss whether we want this change at all -- is it belt and suspenders now that the server adds a worker, and it looks high risk.

Both fair. Two honest qualifications, so the discussion has the full picture:

  1. It is not purely redundant. The guard addresses starvation-caused navigation failures; this refusal can also fire when the WebView2 renderer genuinely dies or wedges for reasons the pool cannot help with, and via the script backstop in the other open thread. So the path is not dead code -- just much rarer than before.
  2. Against that: if it ever does fire on Save-to-file or Send-to-device, the user currently sees the operation simply stop, with the explanation going only to the log. That is a poor experience and the kind of thing that generates "it did nothing" support reports.

There is also a middle option I did not offer in the report, which changes the risk calculus: instead of wiring the message into four call sites, report once at the throw site via NonFatalProblem.Report, which the preview path already uses. One edit instead of four, covering every path. The wrinkle is that the preview path would then report twice, since its handler reports any exception generically -- so it would need a look, but it is a much smaller change than four separate progress-channel edits.

Leaving open pending that discussion.

@andrew-polk

andrew-polk commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

(Claude Opus 5) Consulted Devin on 2026-07-27 up to commit 04ccfe0f11.

Devin ran twice — once on 918f86e830 and again on the fix commit 04ccfe0f11. No Bugs either time.

  • Registration only helps if a NEW request arrives — a real gap, and the important one: registering the blocked thread only made starvation visible, and QueueRequest acts on it only when the next request arrives, which may never come. Fixed in 04ccfe0f11 via RegisterThreadBlockingAndEnsureAFreeWorker(). (thread, resolved.) Devin re-raised it on the second pass while still quoting the old RegisterThreadBlocking() call, so that repeat is stale.
  • Late abort on an unconsumed response body — tested against node-fetch 3.3.2 rather than assumed; no unhandled error. (thread, resolved.)
  • Failed page checks now fail BloomPUB publishing — traced every entry point: no crash risk, but the non-preview paths only log rather than explain. Left open for the developer. (thread.)

Four informational items from the first pass were acted on anyway (abandoned-task exception, superseded-publish timeout, body reads brought inside the request timeout, and only-timeouts marking Bloom wedged). The second pass's seven needed no action.

CI is green (pr-automation, track). CodeRabbit is disabled for this repo by .coderabbit.yml (auto_review.enabled: false).

@andrew-polk

andrew-polk commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

(Claude Opus 5) Re-split the branch's history (force-push) so the fix and the instrumentation are separate commits, at Andrew's request — it was hard to tell how much of the diff existed only for diagnosis.

  • 92150a65b7the fix: the bounded waits, retry-then-fail page checks, and the free-worker guarantee.
  • e313457b15the instrumentation: GetWorkerPoolDiagnostics() and its two log/message call sites, its test, and the visual-regression suite's bounded/labelled requests. No product behavior; revertable on its own.

Only ~33 lines of shipping product code are instrumentation (BloomServer +21, PublishHelper 9, OffScreenBrowser 3); the remaining 162 are test-harness and a test.

The resulting tree is byte-identical to the pre-split 04ccfe0f11 — same tree hash 10fe6b4feb…. Nothing in the code changed, only how it is divided into commits, so the three review threads above are all still anchored and current (GitHub did not mark them outdated). Commit 1 was verified to build and pass its test on its own, so the branch bisects cleanly.

andrew-polk and others added 4 commits August 4, 2026 09:34
Every publish (BloomPUB, ePUB, video) first loads the book into an
off-screen browser to ask which elements are visible and which fonts are
used. Two defects in that step, fixed together because fixing either alone
makes things worse:

1. The wait was unbounded. OffScreenBrowser.RunAndBlock ended in
   tcs.Task.GetAwaiter().GetResult() with no timeout, so a WebView2 that
   stopped making progress blocked the caller forever. The caller is a
   BloomServer worker, so the request was never answered and publishing
   stayed wedged for the life of the process. Every blocking call is now
   bounded and throws OffScreenBrowserTimeoutException instead. The
   constructor's _ready.Wait() was unbounded for the same reason and is now
   bounded too.

2. A failed page-checks navigation silently produced a bad book. The old
   code logged the failure and carried on (per BL-7892), but with no element
   information IsDisplayed answers "displayed" for everything and FontsUsed
   comes back empty -- so a BloomPUB kept content that should have been
   stripped and embedded none of its fonts. Only ePUB noticed, because
   IsDisplayed throws for it. Fixing only (1) would therefore have traded a
   hang for quiet corruption. Page checks now retry once on a fresh renderer
   and, failing that, fail the publish rather than emit a book we know is
   wrong.

This is the part of the work that rests on proven facts rather than on any
theory about the cause. Thread stacks captured from the 2026-08-03 nightly
hang show the process hard-deadlocked in RunAndBlock, idle (0.72s of CPU
across 24 minutes) rather than slow, after the log recorded exactly the
navigation failure described in (2). An unbounded GetAwaiter().GetResult()
on a server worker is indefensible on its own terms.

Deliberately NOT raising the 10s navigation budget: timings from a passing
nightly show that step normally finishing in a small fraction of it, while
the failures blew straight through and never completed -- a stall, not a
near-miss, so a longer wait would only delay the hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BloomServer counts blocked worker threads so it can add a worker when they
are all stuck, but that count is not computed automatically: callers must
call RegisterThreadBlocking. OffScreenBrowser never did, so a worker blocked
waiting on the off-screen browser was invisible to it.

The thread stacks from the 2026-08-03 nightly hang show the blind spot
precisely. Six BloomServer.RequestProcessorLoop threads, all six unavailable:
five parked on the API lock (which DO register, in BloomApiHandler) and the
sixth blocked in OffScreenBrowser.RunAndBlock without registering. So the
top-up test evaluated 5 >= 6 and declined to add a worker, while the true
state was 6 of 6 unavailable. The one unregistered thread is the one that
would have flipped it.

Registering is correct regardless of what caused the hang: the count is
supposed to mean "workers that cannot take new work", and this thread cannot.
That makes it a fix to a documented mechanism rather than a bet on any
theory, which is why it is separate from the commit that follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The commits so far make the stall survivable but do not explain it. The
leading theory is BloomServer worker starvation: the thread blocking on the
off-screen browser is itself a worker, and the browser's navigation needs a
worker to serve the page it is loading. That is a hypothesis, not a finding,
and the logs we had could not test it -- Bloom's log simply went silent.

Worth being precise about what the 2026-08-03 stack captures did and did not
settle. They prove a hard deadlock in RunAndBlock. They do NOT support
starvation as the cause: the wedged thread holds the API lock, so every later
request piles up behind it, which means a fully-consumed pool is the
inevitable consequence of any such hang whatever started it. So this adds the
measurements that can actually discriminate:

- BloomServer.GetWorkerPoolDiagnostics(), a one-line snapshot of
  workers/busy/blocked/recursive/queued. It reads the counters without taking
  the queue lock on purpose: it is called precisely when the pool may be
  wedged, and taking that lock could block the very report we need.
- NoteWorkerPoolHeadroom() remembers the tightest moment -- the point where
  the fewest workers could take new work -- and PublishHelper logs it once per
  publish, on success as much as on failure. This is the part that makes a
  HEALTHY run informative: if the pool never drops below a comfortable margin
  during a normal publish, starvation is not a plausible explanation, and we
  learn that without having to catch a failure in the act. Measured as idle
  (workers - busy) rather than merely blocked, because a worker busy serving a
  long request is just as unable to serve the page we are waiting for.
- Page-check failures log that snapshot plus how long the attempt took.

Also makes the visual-regression suite diagnosable, which is how this bug was
found the hard way. Each request to Bloom is bounded and labelled, so a hang
reports which call stopped answering instead of an opaque "test timed out in
120000ms" pointing at the test function. Once Bloom stops answering the
remaining cases fail immediately rather than each burning 120s. Only a genuine
timeout counts as wedged, so a transient blip cannot mask remaining image
diffs, and ordinary diff failures still run every case.

No product behavior changes here; it is all reporting, and can be dropped
independently of the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This is the fix for the cause, now measured rather than theorised.

Stacks captured at the moment of failure (nightly run 30864382766, at the
instant Bloom logged "Failed to navigate fully to
RemoveUnwantedContentInternal", 2.5 minutes in) show all four BloomServer
workers occupied and NOT ONE idle:

  workers in RequestProcessorLoop      4
  idle in WaitHandle.WaitAny           0
  blocked on the API lock              3   (SemaphoreSlim.Wait in
                                            BloomApiHandler.ProcessRequestAsync)
  blocked in OffScreenBrowser          1   (RunAndBlock)

The chain that produces it:

1. The publish endpoint is registered with requiresSync at its default of
   true, so the publish holds the API lock for its whole duration -- minutes.
2. MinWorkerThreads is Math.Max(Environment.ProcessorCount, 2) = 4 on these
   runners. Three park waiting for that lock; the fourth is the publish.
3. So no worker is left to serve the in-memory page the off-screen
   page-checks browser is navigating to, and that navigation cannot complete.
   It times out at its 10s budget.
4. Pre-fix, the code logged that and carried on (BL-7892), then blocked
   forever in the follow-up RunJavascript.

The escape hatch in QueueRequest -- if (_countBlockedThreads >=
_workers.Count) SpinUpAWorker() -- evaluated 3 >= 4 and declined, because the
three lock-waiters register but the thread blocked in RunAndBlock does not.
Registering it (previous commit) makes that 4 >= 4.

Registering alone is not enough, though, which is why this commit exists.
QueueRequest only evaluates that condition when a NEW request arrives, and the
request we need served may already be sitting in the queue -- with the browser
issuing nothing further, because it is waiting for exactly that page. So
RegisterThreadBlockingAndEnsureAFreeWorker evaluates the same condition at the
moment the blocked count goes up, and RunAndBlock uses it.

Why this is a safe change to the server's core: it introduces no new
mechanism. Same condition, same SpinUpAWorker(), same lock (_queue). It only
adds one more moment at which a rule the server already applies gets
evaluated. Worst case it creates a worker slightly earlier than the existing
path would; it cannot create one the existing rule would not. The pre-existing
property that workers are never retired is unchanged, and growth is still
bounded by the number of simultaneously-blocked threads.

Why adding a worker helps at all: the requests the browser needs -- its
in-memory page, CSS, images -- are not API requests, so they never touch the
lock the publish thread holds. A fresh worker can serve them while the others
stay parked. If they were API requests a new worker would block too and this
would be pointless.

It also logs when it actually has to add a worker. That line appearing in a
passing run's log is positive confirmation that the starvation condition was
real and that this is what kept requests moving -- otherwise a guard quietly
doing its job is indistinguishable from a bug that quietly went away.

Deliberately NOT bundled here, though it is arguably the deeper smell: that a
minutes-long publish serialises all API traffic at all. Freeing those three
workers by revisiting requiresSync for publish endpoints would change a core
concurrency invariant that exists for reasons (see BL-15586 in
BloomApiHandler), so it wants its own investigation rather than riding along
with a fix we can be confident in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andrew-polk
andrew-polk force-pushed the BL-16612-page-checks-hang branch from 831febc to 958f5fb Compare August 4, 2026 16:34
andrew-polk and others added 2 commits August 4, 2026 09:37
… count

Found reviewing this before handing it back: the guard registered the block and
only then took the lock to add a worker, and the caller's finally that
unregisters covers the block only AFTER the guard returns. So if SpinUpAWorker
threw -- thread creation failing under memory pressure, say -- the exception
would escape before that try, _countBlockedThreads would stay permanently
incremented, and an inflated count makes the guard fire on essentially every
later block. A slow, permanent corruption of the server's own accounting, from
a failure that has nothing to do with the caller.

Adding a worker is an optimisation: failing to add one must never fail the
caller and must never leak the count. So the lock and the spin-up are now
inside a try that logs and carries on, which makes the method unable to throw
once it has registered, and the register/unregister pairing unconditional.

Unlikely to fire in practice. Included because this sits at the lowest level of
the server's request handling, where "unlikely" is not the standard.

Also updated the comment to say the starvation is established rather than
suspected, and to name the run whose moment-of-failure stacks established it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three bugs, all real, two of them artifacts of my own commit-splitting surgery.

**The guard's documentation had come adrift.** Splitting the branch into
purpose-separated commits left the XML doc block for
RegisterThreadBlockingAndEnsureAFreeWorker stranded above two unrelated private
counters, so the public method was undocumented and the description was
attached to the wrong thing. Reattached.

**The recovery attempt could escape uncaught.** On a failed page check the code
asks the browser for a clean renderer -- but that is the same unresponsive
browser, so StartFreshBrowser can itself hit its backstop and throw. Sitting
outside the try, that skipped the retry entirely and surfaced a technical
timeout instead of the explanation written for users, in exactly the hang this
retry exists for. Now caught and logged so the loop reaches its own verdict.

**The blocked-count pairing rested on an argument rather than structure.** The
previous commit made the guard unable to throw once it had registered, which
closes the leak, but the register call still sat outside the try whose finally
unregisters. Now it registers inside the try and unregisters only if it got
that far, so the pairing holds regardless of what throws. A leaked count would
leave the server permanently believing a worker is blocked, so this is worth
being structural about rather than reasoned about.

Also raised the visual-regression suite's quick-read timeout from 10s to 30s
(Devin's flag). A timeout there does not fail one case, it marks Bloom wedged
and fails every remaining case -- and switching to the collection tab reloads a
webview, which on a loaded machine can genuinely take a while. A spurious abort
of the whole suite is much worse than waiting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/BloomExe/web/BloomServer.cs
Comment thread src/BloomExe/Publish/PublishHelper.cs
Comment thread src/BloomExe/Publish/OffScreenBrowser.cs
Comment thread src/BloomVisualRegressionTests/index.spec.ts
// stopped making progress altogether. We used to block forever in that case, which wedged the
// calling thread permanently; when the caller was a BloomServer worker that wedged publishing with
// it, and the process never recovered (BL-16612).
private const int kDefaultBlockTimeoutMs = 30000;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Devin] Investigate: RunJavascript page-checks backstop is a fixed 30s regardless of book size

The backstop that bounds a script run is a constant 30 seconds, but the page-checks script walks every element carrying an id across the whole DOM. On a large enough book that work is not obviously bounded by 30s, and exceeding it now fails the publish rather than degrading it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Claude Opus 5) Andrew's response: needs discussion, and 30s is certainly too low. So no change yet -- leaving this open rather than picking a number.

Recording the shape of the discussion for whoever picks this up: the value is doing two different jobs. A work budget should scale with the book, since the script walks every element carrying an id; a liveness detector should be generous and fixed. It is currently being used as the latter while sized like the former.

The useful asymmetry is that bounding the wait at all is what fixed the disaster case. Now that it can never wait forever, the exact number only trades a spurious refusal against a slower failure -- and a spurious refusal is far worse. That argues for raising it substantially rather than tuning it finely.

Our only timing evidence is from the visual-regression books, which are tiny, so it says nothing about the large-book case. Logging how long page checks take on SUCCESS (we currently only log it on failure) would give a real distribution from the nightly and from user logs, and let the number be set from data.

Comment thread src/BloomExe/Publish/PublishHelper.cs
@andrew-polk

Copy link
Copy Markdown
Contributor Author

(Claude Opus 5) Consulted Devin on 2026-08-04 up to commit dc98a17e0b.

Three passes this run. Every finding was real — no false positives — and two of the three bugs were artifacts of my own commit-splitting surgery rather than the design:

  • The guard's documentation had come adrift, left above two unrelated private counters when the method moved between commits, so the public method read as undocumented. Reattached.
  • The recovery attempt could escape uncaught. On a failed page check we ask the browser for a clean renderer — but it is the same unresponsive browser, so StartFreshBrowser can hit its own backstop and throw. Sitting outside the try, that skipped the retry entirely and surfaced a technical timeout instead of the explanation written for users, in exactly the hang the retry exists for.
  • The blocked-count pairing rested on an argument rather than structure. I had independently found the same leak and made the guard unable to throw once it registers; Devin's framing was better, so the register now happens inside the try with a conditional unregister. Both belts.
  • Also raised the suite's quick-read timeout 10s → 30s: a timeout there does not fail one case, it marks Bloom wedged and fails every remaining one.

Three flags are deliberately left open for Andrew, all judgement calls rather than defects:

  • the new publish-blocking message is not localized (this repo's rule says such strings follow the xlf-strings skill, which requires asking which priority file);
  • the page-checks script backstop is a fixed 30s regardless of book size, and exceeding it now fails the publish;
  • the pre-existing one about that refusal only reaching the user on the Publish-tab path.

Devin's final pass re-lists the three fixed bugs, but they are stale — I verified each fix is present at dc98a17e0b (doc attached to the method, StartFreshBrowser caught, if (registered) guarding the unregister). Its results are cumulative per PR rather than per-commit.

CI green. CodeRabbit is disabled for this repo by .coderabbit.yml.

Andrew's call on the preflight question: leave it in English and record the
reason, which is what .github/skills/xlf-strings asks for when a string is
deliberately not localized.

The reasoning, now in the code: reaching this throw means the off-screen
browser failed twice in a row, so it is a fault condition rather than a normal
outcome, and the skill's guidance is that such strings stay in English. Keeping
the wording identical to the log entry just above it is also what lets a user's
description of the problem match what we read in their log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants