Skip to content

Harden the task queue against synchronously-throwing tasks - #749

Open
titouanmathis wants to merge 6 commits into
3.xfrom
fix/queue-task-throw-isolation
Open

Harden the task queue against synchronously-throwing tasks#749
titouanmathis wants to merge 6 commits into
3.xfrom
fix/queue-task-throw-isolation

Conversation

@titouanmathis

@titouanmathis titouanmathis commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Problem

A queued task that throws synchronously used to take the whole task queue down with it.

Queue.add() pushed () => resolve(task()) onto this.tasks. When task() threw, the throw escaped resolve(...), escaped run()'s while loop, and escaped flush() — which meant:

  • the rest of the batch never ran, and its tasks stayed in this.tasks forever;
  • isScheduled stayed true, so every later scheduleFlush() early-returned and no flush was ever re-armed;
  • the add() promise never settled.

Base/utils.ts holds a single module-global SmartQueue behind addToQueue, and every lifecycle method routes its work through it. So one component with a throwing mounted() hook permanently killed mounting, updating, destroying and terminating for every component on the page.

Reproduced against main: await registerComponent(Boom) with a wiring failure never resolves.

Fix

Contain the throw where the task runsQueue.add() wraps resolve(task()) in a try/catch and rejects the returned promise. run(), flush() and scheduleFlush() never see the throw, the batch keeps draining, isScheduled is reset normally, and a synchronous throw now settles the promise exactly like an async task whose promise rejects. SmartQueue inherits this unchanged and is not modified at all.

scheduleFlush() also resets isScheduled if the waiter itself throws. Unlike the task path, this one is genuinely reachable: waiter is a public constructor parameter (new Queue(concurrency, waiter)). The task already pushed stays queued deliberately — it runs on the next flush that does get scheduled, and its promise never reached the caller (the throw pre-empts add()'s return), so rejecting it could only produce an unobservable rejection.

Lifecycle methods no longer reject in queued mode. $mount(), $update(), $destroy() and $terminate() catch a failed queued task and resolve. They run detached on the shared queue and are commonly called fire-and-forget — from the auto-mounting mutation observer, from $terminate() when an element leaves the DOM — so a rejection would have had no awaiter and would only have become an unhandled rejection.

The error is not swallowed. It is re-thrown from a microtask, so it reaches window.onerror and any error monitor, wrapped in an error naming the instance $id and the failed lifecycle, carrying the original error as its cause.

A failed lifecycle stops at the point of failure: after-mounted and after-destroyed are not emitted. What $isMounted ends up as depends on where the failure happened — false if the wiring failed, and true if the mounted() hook itself threw, because $isMounted is set immediately before the hook runs. That is deliberate: the component is wired in that case, and $destroy()'s guard needs the flag to tear it down.

Blocking mode keeps rejecting. With features.get('blocking') set, addToQueue runs the task synchronously in the caller's own stack — no queue, nothing to wedge — so the error is re-thrown and the lifecycle promise rejects as it always has. try { await createApp(App, { blocking: true }) } catch {} still catches startup failures.

registerComponent() still skips instances that failed to mount. Because $mount() no longer rejects, Promise.allSettled in registerComponent would have reported a never-mounted instance as a successful registration, silently undoing the v3.8.0 skip-and-log behaviour. It now checks $isMounted on the fulfilled branch. The rejected branch stays and is still reachable — it fires when the instance could not even be constructed, which no other channel reports. registerComponents is unaffected: registerComponent still rejects on a failed dynamic import.

Scope

The fix is deliberately minimal. An earlier revision of this branch also added a try/finally in Queue.flush(), a try/catch in Queue.run() and mirrored both in SmartQueue. Those were removed: Queue.add() is the only writer to this.tasks package-wide, and once its closure cannot throw, none of those guards is reachable through the public API. SmartQueue.ts is now byte-identical to main, and Queue.ts carries about 14 lines of new code.

Tests

  • One component's mount fails and an unrelated component queued afterwards still mounts, and the queue keeps accepting work — the headline regression.
  • A queued mount task failing leaves the component unmounted, does not emit after-mounted, and reports the error with the component identity and the original cause.
  • Throwing destroyed() / updated() / terminated() hooks: the lifecycle resolves, after-destroyed is not emitted, the error is reported.
  • Fire-and-forget $mount() on a throwing component produces zero unhandled rejections and exactly one global report.
  • Blocking mode: $mount() rejects with the original error and nothing is deferred to a microtask.
  • registerComponent skips a never-mounted instance.
  • Queue level: same-batch isolation, no wedging across flushes, sync/async rejection parity, the scheduler-throw flag reset, and the orphaned task running on the next successful flush.

All lifecycle tests were checked to fail when the corresponding production change is reverted.

Also fixed SmartQueue.spec.ts leaving a rejection unobserved, which made a root-level vitest run packages/tests/utils/SmartQueue.spec.ts exit 1 (CI passed only because it runs from packages/tests).

Docs & changelog

  • packages/docs/utils/Queue.md documents that add() rejects on task failure and warns that a failure-capable task must have its rejection observed.
  • packages/docs/api/instance-methods.md gains an "Error handling" section for the lifecycle contract, the global error channel, and the blocking-mode exception.
  • CHANGELOG.md covers the queue wedge, add() rejecting, the non-rejecting lifecycle methods, blocking mode, and registerComponent.

Related, not addressed here

  1. A failed $destroy() leaves the instance in the global storage (after-destroyed is the only path to deleteInstance), so the terminate scan can re-fire $terminate() on each mutation. This is pre-existing on main for async-rejecting teardown; this PR widens the reachable paths while fixing a strictly worse failure. Follow-up.
  2. A failed $mount() leaves a stale entry in the element storage. Recoverable — the catch resets __isMounting — and strictly better than main, which hung. Follow-up.

The 'terminated' marker in the mount scan, which an earlier revision of this description called a latent defect, is intended behaviour — see #750, which documents and pins it.

Verification

Rebased onto main (a67c3cfc). From the repo root, after npm ci && npm run build:

  • npm run test --workspace=@studiometa/js-toolkit-tests — 142 files, 1024 passed, 2 skipped
  • npx vitest run packages/tests/utils/SmartQueue.spec.ts — 4 passed, exit 0
  • npm run lint — 0 errors (1 pre-existing unicorn/no-useless-spread warning in autoload/loader.ts, from main)
  • npm run build — exit 0

🤖 Generated with Claude Code

https://claude.ai/code/session_0114BLVFkrWWJAhmamS6NHB3

@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.08197% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.20%. Comparing base (a67c3cf) to head (5d6ec2f).

Files with missing lines Patch % Lines
packages/js-toolkit/Base/utils.ts 77.77% 2 Missing ⚠️
...ckages/js-toolkit/Base/managers/ChildrenManager.ts 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #749      +/-   ##
==========================================
- Coverage   97.26%   97.20%   -0.06%     
==========================================
  Files         169      169              
  Lines        4128     4156      +28     
  Branches     1145     1148       +3     
==========================================
+ Hits         4015     4040      +25     
- Misses        102      105       +3     
  Partials       11       11              
Flag Coverage Δ
eslint-plugin-js-toolkit 94.18% <ø> (ø)
js-toolkit 97.20% <95.08%> (-0.06%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Export Size

@studiometa/js-toolkit

Name Size Diff
Queue 255 B +29 B (+12.83%) 🔺
SmartQueue 441 B +30 B (+7.30%) 🔺
registerComponent 277 B +15 B (+5.73%) 🔺
registerComponents 312 B +12 B (+4.00%) 🔺
BASE 9.18 kB +213 B (+2.38%) 🔺
Base 9.13 kB +209 B (+2.34%) 🔺
FRAMEWORK 16.97 kB +217 B (+1.30%) 🔺
ALL 24.09 kB +174 B (+0.73%) 🔺
AUTOLOAD 3.26 kB +16 B (+0.49%) 🔺
autoload 2.26 kB +11 B (+0.49%) 🔺
registerManifest 2.76 kB +13 B (+0.47%) 🔺
ComponentLoader 2.17 kB +10 B (+0.46%) 🔺
registerManifests 2.77 kB +5 B (+0.18%) 🔺
UTILS 9.65 kB +17 B (+0.18%) 🔺
HELPERS 2.69 kB +4 B (+0.15%) 🔺
Unchanged

@studiometa/js-toolkit

Name Size Diff
AbstractService 528 B -
addClass 226 B -
addStyle 238 B -
animate 3.27 kB -
boundingRectToCircle 154 B -
cache 194 B -
camelCase 397 B -
clamp 67 B -
clamp01 87 B -
closestComponent 418 B -
collideCircleCircle 99 B -
collideCircleRect 159 B -
collidePointCircle 112 B -
collidePointRect 103 B -
collideRectRect 99 B -
composeManifests 100 B -
createApp 893 B -
createEaseInOut 116 B -
createEaseOut 71 B -
createElement 592 B -
createLocalStorage 1.19 kB -
createLocalStorageProvider 264 B -
createMemoryStorageProvider 146 B -
createNoopProvider 96 B -
createRange 90 B -
createSessionStorage 1.23 kB -
createSessionStorageProvider 254 B -
createStorage 1.17 kB -
createUrlSearchParamsInHashProvider 413 B -
createUrlSearchParamsInHashStorage 1.22 kB -
createUrlSearchParamsProvider 386 B -
createUrlSearchParamsStorage 1.21 kB -
damp 78 B -
dashCase 372 B -
debounce 92 B -
DECORATORS 7.74 kB -
DEFAULT_DIAGNOSTIC_PREFIX 104 B -
defineFeatures 322 B -
defineManifest 455 B -
domScheduler 296 B -
DragService 1.94 kB -
ease 435 B -
easeInCirc 68 B -
easeInCubic 59 B -
easeInExpo 80 B -
easeInOutCirc 141 B -
easeInOutCubic 130 B -
easeInOutExpo 134 B -
easeInOutQuad 128 B -
easeInOutQuart 133 B -
easeInOutQuint 152 B -
easeInOutSine 151 B -
easeInQuad 63 B -
easeInQuart 61 B -
easeInQuint 62 B -
easeInSine 77 B -
easeLinear 49 B -
easeOutCirc 115 B -
easeOutCubic 103 B -
easeOutExpo 112 B -
easeOutQuad 103 B -
easeOutQuart 100 B -
easeOutQuint 103 B -
easeOutSine 121 B -
endsWith 88 B -
fold 156 B -
fromMetaGlob 213 B -
fromWebpackContext 94 B -
getAncestorWhere 91 B -
getAncestorWhereUntil 119 B -
getClosestParent 184 B -
getComponentResolver 138 B -
getDirectChildren 201 B -
getInstanceFromElement 92 B -
getInstances 185 B -
getOffsetSizes 159 B -
getScopedGroups 95 B -
hasWindow 62 B -
historyPush 499 B -
historyReplace 503 B -
IDLE_TIMEOUT 53 B -
importOnInteraction 899 B -
importOnMediaQuery 236 B -
importWhenIdle 223 B -
importWhenPrefersMotion 275 B -
importWhenVisible 914 B -
inertiaFinalValue 142 B -
isArray 70 B -
isBoolean 78 B -
isDefined 86 B -
isDev 72 B -
isDirectChild 219 B -
isEmpty 207 B -
isEmptyString 93 B -
isFunction 72 B -
isNull 72 B -
isNumber 84 B -
isObject 105 B -
isString 86 B -
keyCodes 97 B -
KeyService 854 B -
lerp 57 B -
loadElement 169 B -
loadIframe 189 B -
loadImage 188 B -
loadLink 186 B -
loadScript 197 B -
LoadService 593 B -
localStorageProvider 715 B -
logTree 510 B -
lowerCase 60 B -
map 71 B -
matrix 106 B -
mean 91 B -
memo 100 B -
memoize 189 B -
memoryStorageProvider 607 B -
MutationService 799 B -
nextFrame 162 B -
nextMicrotask 111 B -
nextTick 134 B -
noop 39 B -
noopValue 49 B -
objectToURLSearchParams 302 B -
pascalCase 374 B -
PointerService 1.07 kB -
queryComponent 613 B -
queryComponentAll 616 B -
RafService 956 B -
random 64 B -
randomInt 77 B -
randomItem 211 B -
readEagerTokens 186 B -
removeClass 222 B -
removeStyle 238 B -
ResizeService 1.01 kB -
round 56 B -
saveActiveElement 56 B -
ScrollService 1.28 kB -
scrollTo 2.31 kB -
SERVICES 4.01 kB -
sessionStorageProvider 712 B -
smoothTo 470 B -
snakeCase 374 B -
spring 115 B -
startsWith 87 B -
throttle 101 B -
toggleClass 225 B -
transform 288 B -
transition 914 B -
trapFocus 363 B -
tween 1.72 kB -
untrapFocus 45 B -
upperCase 54 B -
urlSearchParamsInHashProvider 537 B -
urlSearchParamsProvider 539 B -
useDrag 1.98 kB -
useKey 871 B -
useLoad 610 B -
useMutation 829 B -
usePointer 1.09 kB -
useRaf 963 B -
useResize 1.02 kB -
useScheduler 290 B -
useScroll 1.3 kB -
version 47 B -
VISIBLE_ROOT_MARGIN 64 B -
wait 79 B -
withBreakpointManager 1.42 kB -
withBreakpointObserver 1.61 kB -
withDrag 2.09 kB -
withExtraConfig 134 B -
withFreezedOptions 140 B -
withGroup 399 B -
withIntersectionObserver 260 B -
withLeadingCharacters 88 B -
withLeadingSlash 107 B -
withMountOnMediaQuery 322 B -
withMountWhenInView 286 B -
withMountWhenPrefersMotion 355 B -
withMutation 959 B -
withName 81 B -
withoutLeadingCharacters 86 B -
withoutLeadingCharactersRecursive 124 B -
withoutLeadingSlash 93 B -
withoutTrailingCharacters 98 B -
withoutTrailingCharactersRecursive 129 B -
withoutTrailingSlash 103 B -
withRelativePointer 1.23 kB -
withResponsiveOptions 2.26 kB -
withScrolledInView 2.93 kB -
withTrailingCharacters 96 B -
withTrailingSlash 120 B -
wrap 93 B -

@codspeed-hq

codspeed-hq Bot commented Aug 1, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 31.52%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 126 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
all transforms 299.5 µs 437.4 µs -31.52%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/queue-task-throw-isolation (5d6ec2f) with main (a67c3cf)

Open in CodSpeed

titouanmathis added a commit that referenced this pull request Aug 1, 2026
Address the review of #749: resolving the `add()` promise with `undefined`
on a synchronous throw was wrong on two counts. It was inconsistent —
`await queue.add(() => { throw e })` fulfilled while
`await queue.add(async () => { throw e })` rejected, so a caller doing
rollback in `catch` behaved differently solely because the task was declared
`async`. And it lied at the lifecycle boundary — a synchronous throw in a
queued `$mount` task made the internal `Promise.all` look successful, so the
component set `$isMounted = true`, emitted `after-mounted` and resolved as if
init had completed, silently swallowing an initialization failure.

Make failures consistent and honest instead of papering over them:

- `Queue.add()` now rejects with the task's error on a synchronous throw,
  exactly like an async task returning a rejected promise. The rejection is
  the single surfacing channel; the wrapper no longer re-throws, so `run()`'s
  `queueMicrotask` does not double-report. `run()` keeps its try/catch purely
  as defence in depth.
- Fire-and-forget call sites attach an explicit `.catch()` routing the error
  to a log (`reportQueuedTaskError`) so a rejection never becomes an unhandled
  rejection: the two `addToQueue` calls in `mutationCallback` and the
  async-child `.then()` branch in `ChildrenManager`.
- The awaited lifecycle boundaries in `Base.ts` (`$mount`, `$update`,
  `$destroy`, `$terminate`) wrap their queued work in try/catch: a failed
  task no longer falsely reports success (`$mount` leaves the component
  honestly unmounted and does not emit `after-mounted`; `$destroy` does not
  emit `after-destroyed`), surfaces the error through the log, and resolves
  instead of rejecting so fire-and-forget callers do not produce unhandled
  rejections.

Also fixes two minor findings from the same review:

- `scheduleFlush()` reset the `isScheduled` flag in a `finally`-like path so a
  throwing custom scheduler no longer wedges the queue permanently.
- The "same batch" regression test now uses a manual waiter, enqueues every
  task and triggers a single flush, so it actually exercises same-batch drain
  isolation instead of flushing each `add()` on its own.

Update the tests accordingly: a synchronously-throwing task's `add()` promise
rejects (consistent with the async case) and is surfaced exactly once; a
throwing queued lifecycle task does not mark the component mounted and does
not produce an unhandled rejection; the scheduler-wedge case is covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
@titouanmathis
titouanmathis force-pushed the fix/queue-task-throw-isolation branch from 329e962 to 9dcd6a6 Compare August 2, 2026 07:01
@titouanmathis titouanmathis changed the title Fix task queue deadlock on synchronously-throwing tasks Harden the task queue against synchronously-throwing tasks Aug 2, 2026
titouanmathis and others added 6 commits August 7, 2026 10:43
A task that threw synchronously used to (a) abort the rest of its batch
and (b) leave the queue's scheduling flag stuck `true` forever, which
permanently froze every queued lifecycle operation on the page. Its
`add()` promise also stayed pending forever, so awaiters hung.

- `Queue`/`SmartQueue`: run each task in `try/catch` so one throw can no
  longer abort the batch; reset the scheduling flag in `finally` and
  re-arm the flush. A throwing custom scheduler no longer wedges either.
- `add()` now rejects on a synchronous throw — consistent with an async
  task that returns a rejected promise — instead of hanging or silently
  resolving, so a failure is never mistaken for success.
- Handle the rejection at the call sites rather than changing behaviour
  by hiding it: the fire-and-forget queue calls (`mutationCallback`, the
  async-child hooks) route errors to a warn helper, and the lifecycle
  boundaries (`$mount`/`$update`/`$destroy`/`$terminate`) surface the
  error and no longer falsely mark a component as mounted.

Adds regression tests for batch isolation, scheduler-throw recovery,
reject consistency, exactly-once error reporting, and the
not-falsely-mounted lifecycle behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
`Queue.ts` is the only writer to `this.tasks` package-wide, and after the
`add()` fix the closure it pushes provably cannot throw. The extra guards
added around it were therefore unreachable through the public API:

- `Queue.flush()`'s try/finally
- `Queue.run()`'s try/catch and `queueMicrotask` re-throw
- both `SmartQueue` overrides, which now simply inherit the fix

`scheduleFlush()`'s `isScheduled` reset stays: `waiter` is a public
constructor parameter, so a throwing scheduler is reachable. The task it
already pushed stays queued on purpose — it runs on the next flush that
does get scheduled, and its promise never reached the caller, so
rejecting it could only produce an unobservable rejection.

Also fix `SmartQueue.spec.ts` leaving a rejection unobserved, which made
a root-level `vitest run packages/tests/utils/SmartQueue.spec.ts` exit 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114BLVFkrWWJAhmamS6NHB3
A failed queued lifecycle task was reported with a bare, ungated
`console.warn`, which no error monitor observes: not `window.onerror`,
not `unhandledrejection`, not the Sentry/Bugsnag defaults. Combined with
the lifecycle methods no longer rejecting, a userland exception had no
channel at all.

Re-throw from a microtask instead — the primitive the queue fix already
used — so the failure reaches the global error handler without wedging
anything. The thrown error names the instance `$id` and the failed
lifecycle, matching the diagnostics style used elsewhere, and carries the
original error as its `cause`.

In blocking mode `addToQueue` runs the task synchronously in the caller's
own stack, so no queue is involved and nothing can wedge: the error is
re-thrown there and the lifecycle promise keeps rejecting, which is what
`try { await createApp(App, { blocking: true }) } catch` relies on.

Adds coverage for the headline scenario (a failing component no longer
stops an unrelated one from mounting), for the `$update`, `$destroy` and
`$terminate` catches, and for blocking mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114BLVFkrWWJAhmamS6NHB3
`$register()` feeds `$mount()` promises into `Promise.allSettled`, and
those no longer reject when a queued mount task fails. `registerComponent`
was therefore handing back a never-mounted instance as a successful
registration, silently undoing the v3.8.0 skip-and-log behaviour.

Check `$isMounted` on the fulfilled branch instead. The rejected branch
stays: it still fires when the instance could not even be constructed,
which no other channel reports.

`registerComponents` is unaffected — `registerComponent` still rejects on
a failed dynamic import, which is what its own rejected branch covers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114BLVFkrWWJAhmamS6NHB3
`Queue.add()` now rejects when its task fails, so document it and warn
that a task which can fail must have its rejection observed — the page
still showed fire-and-forget `queue.add(...)` only.

Add an error-handling section to the instance methods reference covering
the non-rejecting lifecycle methods, the global error channel, the events
that are not emitted on failure, and the blocking-mode exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114BLVFkrWWJAhmamS6NHB3
Match the pattern used in parent-resolution-residual.spec.ts so a failed
assertion cannot leave `blocking` enabled for later tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114BLVFkrWWJAhmamS6NHB3
@titouanmathis
titouanmathis force-pushed the fix/queue-task-throw-isolation branch from 9dcd6a6 to 5d6ec2f Compare August 7, 2026 08:47
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Code Review

Risk: Low — The queue and lifecycle error-handling changes are internally consistent and safe to merge.

The MR contains the synchronous task-throw isolation, scheduler recovery, lifecycle error reporting, and registration filtering described in the intent. It also adds documentation and regression coverage for queue behavior, lifecycle failures, blocking mode, and failed registrations.


Review usage: 21,377 in (3,056 cached) / 875 out tokens — $0.0142 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 5d6ec2f.

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