Skip to content

Fix #154's memory blowup: worker page-slicing, race condition, and worker pool sizing - #166

Closed
philips-clanker wants to merge 20 commits into
mainfrom
worker-page-slice-issue-154
Closed

Fix #154's memory blowup: worker page-slicing, race condition, and worker pool sizing#166
philips-clanker wants to merge 20 commits into
mainfrom
worker-page-slice-issue-154

Conversation

@philips-clanker

@philips-clanker philips-clanker commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Started as one fix, grew into the actual full resolution of #154's memory blowup - confirmed end-to-end via real device profiling (heap snapshots, an allocation timeline, and a live bisection test). Verified result: peak memory during a full scroll through a 100+ page document down from 1.5GB+/900MB+ to well under 400MB.

The trail, in order

  1. Send only the requested page(s) to a Worker, not the whole note. WorkerPool.processChunk() was posting the entire parsed SupernoteX - every page's raw data - to a Worker on every single lazy one-page load. Since the pool round-robins across every worker, each one eventually held a full copy of the entire document. Fixed via extractPagesRenderData() (wrapping supernote-typescript's extractPageRenderData()), slicing out only the needed page(s) before ever constructing the postMessage payload.
  2. Memory tracing (load/evict logging with running totals) added to make the plugin's own bounded/unbounded behavior directly observable during testing.
  3. Destroy the previous pdf.js document on file switch. pdfDocPromise was silently overwritten on every file load, never releasing the previous note's parsed PDF in pdf.js's own Worker.
  4. Discard a page's load if it scrolled away before it finished - the actual driver of runaway growth on a fast scroll: ensurePageImage()/ensureThumbnail() finalized their result unconditionally, even if the page had already scrolled back out of view during the async rasterization round-trip. A page whose load "lost the race" against the user's scroll speed became permanently stuck loaded, since eviction only re-triggers on the next intersection transition. Confirmed via profiling: "currently loaded" climbing past 30 pages with zero evictions during a fast scroll.
  5. Bisection test (temporary DevTools console toggles, since removed) isolated the remaining growth to the image/Worker pipeline specifically - disabling image rasterization kept memory bounded through a full scroll; disabling the separate text-layer/pdf.js pipeline made no difference at all.
  6. Worker recycling. A worker's own memory grows with how many pages it's ever rasterized over its lifetime, not with anything currently held on the JS side - very likely image-js's own internal decode/encode buffers or a WASM codec's linear memory, never released mid-lifetime regardless of what this plugin does. WorkerPool now terminates and replaces a worker after 8 calls. Confirmed via testing: memory climbed to 1.2GB, then dropped to a few hundred MB once recycling kicked in - the mechanism works, but only for the resting value.
  7. Cap the worker pool to 2 workers instead of navigator.hardwareConcurrency. The actual fix for the peak: lowering the recycle threshold (20 → 8) didn't move the 1.2GB peak at all, because that threshold only controls how long one worker keeps growing, not how many workers can be simultaneously mid-growth at once during a fast scroll. Fewer concurrent workers directly caps the peak, independent of the recycle threshold. Trade-off: bulk exports (attach markdown+images, PDF export) get less parallelism - acceptable, since those are rare/deliberate compared to routine scrolling.

Confirmed working

Per live testing on the actual reported device/document: full scroll through a 100+ page note now stays well under 400MB throughout (previously 1.5GB+/900MB+), with no noticeable slowdown in interactive scrolling.

What I could not verify myself

I don't have Obsidian/Electron/an X server in my environment - every fix in this trail was verified via tsc/eslint/the test suite and (where applicable) standalone simulations or fixture-based checks on my end, but the actual memory measurements, heap snapshots, and bisection results all came from the reporter's real device testing throughout this thread - that's what actually confirmed each step, not anything I could reproduce locally.

Test plan

  • npx tsc --noEmit - clean
  • npx eslint src/main.ts src/myworker.worker.ts - 0 errors
  • npx vitest run - 95 passed
  • npm run build - clean
  • Verified worker page-slicing produces pixel-identical output and is structured-clone-safe (real fixture)
  • Verified the worker-recycling queue logic doesn't reintroduce the original clobbered-onmessage race (standalone simulation, 5/5 runs)
  • Confirmed on real device: full scroll through 100+ page note stays under 400MB (was 1.5GB+)
  • Manual: confirm bulk export (attach markdown+images, PDF export) is still an acceptable speed with only 2 workers

Found via real profiling on issue #154: after ruling out a JS-heap
leak (a heap snapshot showed only 144MB total, with HTMLCanvasElement/
ImageBitmap essentially 0MB shallow size - their pixel data lives in
native/GPU memory heap snapshots can't see at all - and an allocation
timeline recorded live while scrolling showed only ~1.9MB of JS
objects surviving by the end), the reporter took a heap snapshot from
one of the Worker threads directly: ~100MB, with 8 such Worker
instances - matching navigator.hardwareConcurrency on that device, and
roughly matching the ~900MB total that neither DPR capping (#164) nor
zoom-aware rasterization (#163) could explain, since neither touches
what a Worker actually receives.

The cause: WorkerPool.processChunk() posted the *entire* parsed
SupernoteX - every page's raw compressed layer data, not just the
page(s) actually requested - to a Worker on every single call,
including every lazy one-page load triggered by scrolling
(ensurePageImage()/ensureThumbnail()). postMessage structurally clones
its argument into the receiving Worker's own separate V8 heap.
WorkerPool round-robins across every worker it has (see its own
comment on issue #147's concurrency fix), so as a user scrolls through
a long document, every worker eventually receives - and holds, in its
own heap, invisible to the main thread's own heap snapshots/profiling
- a full copy of the *entire* document, just to render one page at a
time.

supernote-typescript already ships extractPageRenderData(note,
pageNumber) specifically to avoid this ("without cloning the whole
note... every other page's buffers" - its own doc comment) - it was
just never wired into this plugin's WorkerPool/ImageConverter.

Adds extractPagesRenderData(), wrapping it to cover a chunk's multiple
page numbers at once, and calls it in processChunk() *before*
constructing the postMessage - so a Worker only ever receives a
structured-clone-safe slice containing exactly the page(s) it was
asked to render, never the whole note. SupernoteWorkerMessage's `note`
field is now IRenderableNote (the minimal slice type
supernote-typescript's own toImage() already accepts) rather than the
full SupernoteX class instance, and no longer needs a separate
pageNumbers field alongside it - the slice already contains exactly
the requested pages, in order.

Verified against a real 10-page fixture: the new path produces
pixel-identical output to the old one, correctly slices to just the
requested page (not all 10), survives structuredClone (confirming
it's genuinely clone-safe, unlike the class instance it replaces), and
preserves page order for a multi-page slice.
Requested for testing on desktop, where DevTools' Memory tab is
available to cross-check against: "scrolling all 100 pages still
causes a crash" even after #155/#156/#158/#159's fixes, and it isn't
obvious from the outside whether eviction is actually keeping up or
something's still accumulating.

Logs every page load/evict (main view and thumbnails alike, since both
go through the same ensurePageImage()/evictPageImage()) with a rough
memory estimate for that page (decoded ImageBitmap + canvas backing
store - the latter often the larger of the two, up to 9x the bitmap's
pixel count at capped 3x devicePixelRatio) and a running total across
every currently-loaded page. Watching whether "currently loaded" keeps
climbing over a long scroll (something isn't actually being freed) or
stays roughly bounded (eviction is working, and the cause is something
else - e.g. pure CPU/GC pressure from rasterization itself, see
supernote-typescript#40's thumbnail-resolution angle on that) should
narrow down what's actually still going wrong.
Found via a real Task Manager reading on issue #154: pdf.worker.min.mjs
(pdf.js's own internal Worker, entirely separate from this plugin's
own WorkerPool/rasterization workers - used only for the search/
text-layer feature) was 82.4MB on its own.

pdfDocPromise was simply overwritten every time a new file loaded,
never destroyed first. pdf.js documents hold real resources in its own
Worker (parsed structure, fonts, etc.) that only get released via an
explicit pdfDoc.destroy() call - garbage collecting the JS-side
reference alone doesn't free them. Across a session with several notes
opened in sequence, each one's parsed PDF stayed fully resident
in pdf.js's Worker for as long as the Supernote view itself remained
open, accumulating with every note switch rather than being replaced.

Added destroy() to the local PdfJsDocument type (a real pdf.js API,
just not previously declared in this file's own minimal subset of it -
see that interface's existing doc comment on why it only covers what's
actually used), and call it - fire-and-forget, doesn't block the next
file's own loading - on the *previous* document both when a new file
loads (onLoadFile()) and when the view closes entirely (onClose()).
Confirmed as the real driver of issue #154's runaway growth via real
profiling on a fast scroll: DevTools showed "Total JS heap size: 1,217
MB", and the plugin's own tracing showed "currently loaded" climbing
past 30 pages with zero evictions in between - not the healthy 5-7
oscillating pattern seen on a slower scroll.

ensurePageImage()/ensureThumbnail() rasterize asynchronously (a worker
round-trip, ~100-300ms). A page can easily scroll back out of view
before that finishes - on a fast scroll through a long document, most
in-flight loads lose this race. Finalizing the load anyway (as both
did unconditionally) leaves that page "stuck" loaded forever: eviction
only re-triggers on the *next* intersection transition (see
pageObserver/thumbObserver), which won't happen again until the user
scrolls back to that exact page. Every page whose load lost this race
during a fast scroll became a permanent, never-evicted addition to
resident memory - compounding with every further page scrolled past,
exactly matching the observed unbounded growth.

Both now re-check the page's current visibility (visibleInMainView/
visibleInThumbnail) right before finalizing - once after the
rasterization call, again after createImageBitmap() (another async
window the same race can happen in, for the main-view path) - and
discard the result without keeping anything if the page already
scrolled away. Resets imageLoadPromise/thumbnailLoadPromise to null in
that case rather than just returning, so a later re-visit triggers a
fresh load instead of reusing this now-settled, empty-result promise.
For issue #154's remaining growth: worker instances stay at 100MB+
across a long scroll session regardless of how many pages are
"currently loaded" per the plugin's own tracking (confirmed via real
profiling - page 1 and page 102 both showed the same 115-131MB workers,
even though tracked page count stayed correctly bounded at both
points after the race-condition fix). That points at something
accumulating *inside* worker execution across repeated calls, not
anything retained on the JS side - but there are two independent
subsystems it could be: the text-layer pipeline (pdf-lib assembling a
whole-document PDF + pdf.js parsing it, both used only for search) and
the image pipeline (the Worker pool rasterizing pages).

Adds `window.supernoteDebug.disableTextLayer`/`.disableImages`,
toggleable from the DevTools console with no rebuild needed - flip one,
reopen the note, compare. Not meant to ship - remove this whole block
(and its three call sites: onLoadFile's pdfDocPromise assembly,
ensurePageImage(), ensureThumbnail()) once the bisection finds the
actual cause.
Confirmed via a bisection test on issue #154: disabling image
rasterization entirely (via the DEBUG_ONLY console toggles just added)
kept total memory around 200MB even after a full scroll through a
100+ page document; disabling the separate text-layer/pdf.js pipeline
instead made no difference at all - workers still grew to 100MB+
regardless, and that growth tracked how many pages a worker had ever
processed over its lifetime, not how many pages were "currently
loaded" per this plugin's own (correctly bounded, after the last fix)
tracking.

This points at something inside the image pipeline's own execution
that never releases memory between calls - very likely image-js's own
internal decode/encode buffers, or a WASM codec's linear memory,
neither reachable/fixable from this plugin's own code, since it lives
entirely inside that dependency's native/WASM state, not anything
this code holds a reference to.

Adds WorkerPool.recycleWorkerIfNeeded(): after a worker has handled
MAX_CALLS_PER_WORKER (20) calls, terminate it and replace it with a
freshly-constructed one at the same pool slot, resetting its call
count. This is the practical way to bound growth that lives inside a
dependency's own internal state rather than anything this code
controls - a fresh Worker/V8 isolate starts with a clean slate
regardless of what the old one accumulated.

Looked up the worker fresh inside send() (not captured earlier in
processChunk()) since a prior queued call on the same pool slot may
have recycled it in the meantime. Recycling itself happens inside the
same per-worker queue chain that already serializes requests (see the
existing concurrency fix's own comment), after a call settles and
before whatever's next in that worker's queue runs, so there's never
an in-flight request on the worker being replaced. Verified via a
standalone simulation (60 concurrent calls across 4 pool slots,
recycling every 5): every result still routes to the correct caller
across 5/5 runs, confirming this doesn't reintroduce the original
clobbered-onmessage race the pool's queuing already exists to prevent.
Confirmed by testing: the mechanism works (memory climbed to 1.2GB,
then dropped to 243MB once recycling kicked in at 20 calls), but a
1.2GB peak before the first recycle landed is still far too high -
every worker ran nearly its whole lifetime before any of them got
replaced. Lowered to recycle more often, trading a bit more
Worker-recreation overhead (spinning up a fresh Worker and reloading
its bundled script isn't free) for a substantially lower ceiling.
Confirmed by testing: lowering MAX_CALLS_PER_WORKER from 20 to 8 made
no difference to the peak (still 1.2GB both times) - only the resting
value after recovery changed (243MB -> 361MB). That's because the
recycle threshold only controls how long one worker keeps growing
before its own reset; it doesn't limit how many workers can be
simultaneously mid-growth at once. The actual peak is reached during a
fast scroll, while every worker in the pool independently accumulates
in parallel - with navigator.hardwareConcurrency (8-9 on the reporting
device) workers, that's 8-9 separate growing accumulators at once,
well before any single one reaches even a low per-worker threshold.

Reducing worker count directly caps the peak, independent of the
recycle threshold - fewer simultaneous accumulators means a lower
ceiling regardless of how often any one of them resets. Deliberately
not tied to core count anymore: more cores would otherwise mean a
*higher* ceiling on a more capable device, backwards from what matters
for this specific problem. Trade-off: bulk operations that rasterize
every page at once (a full PDF/markdown-with-images export) get less
parallelism and take longer - acceptable, since those are rare,
deliberate, one-off actions, unlike lazy per-page loading during
routine scrolling, which is both the common case and the one that
actually needs to be memory-safe on a constrained device.
The bisection they were added for is done: disabling image
rasterization kept memory bounded through a full scroll; disabling the
text-layer/pdf.js pipeline made no difference, isolating the image
Worker pipeline as the actual cause (now fixed via worker recycling +
a smaller, fixed pool size). No longer needed.
@philips-clanker philips-clanker changed the title Send only the requested page(s) to a Worker, not the whole note Fix #154's memory blowup: worker page-slicing, race condition, and worker pool sizing Aug 1, 2026
Thumbnails are now downscaled (THUMBNAIL_SCALE), so even a full
document's worth only amounts to a few MB total - the eviction that
made sense for full-resolution pages isn't needed here. Evicting also
had a visible bug: an evicted <img> (src reset to '') paired with the
aspect-ratio box reserved for layout stability rendered as a "broken
image" icon rather than collapsing invisibly, since the box now had a
legitimate reason to expect content. Removing eviction entirely fixes
that and lets thumbObserver bring back a generous rootMargin (previously
dropped since a percentage margin used to prefetch far more thumbnails
than intended at full resolution) for smoother sidebar scrolling.
Terminates the whole shared worker pool after 15s of no new
rasterization calls, complementing MAX_CALLS_PER_WORKER (which only
bounds growth *during* sustained scrolling, not the resting footprint
once a note is just sitting open) - see issue #154. Recreated lazily
on next use via the existing getSharedWorkerPool() lazy-init.

Also adds a temporary, console-only toggle
(window.supernoteDebug.disableWorkerRecycling) so recycling can be
disabled for A/B testing against real devices, matching the same
pattern used for the earlier text-layer/image-layer bisection.
Disabling MAX_CALLS_PER_WORKER recycling via the debug toggle made no
measurable difference to real-device heap usage, so it wasn't earning
its complexity - removed the recycling logic, its debug toggle, and
callCounts tracking entirely. The idle-teardown mechanism (added
alongside it) is the one confirmed via testing to actually reclaim
memory (~200MB), so that's what's kept and tuned.

Lowered WORKER_POOL_IDLE_TEARDOWN_MS from 15000 to 2000 per testing
feedback. At that shorter window, the previous "reset timer on every
call start" scheme became a real risk: a rasterization slower than 2s
could have its worker torn down mid-flight (WorkerPool.terminate()
abandons any pending onmessage with no error), hanging that call's
promise forever. Fixed by tracking in-flight call count and only
scheduling the teardown once it's actually back at zero, cancelling it
the moment new work starts - the pool can now only be torn down
between calls, never during one, regardless of how short the timeout
is.
…f pages

A search-result jump (stepFind()'s scrollIntoView({behavior: 'smooth'}))
covering the length of a long document animates through every intermediate
page in a fixed, short duration regardless of distance, so each one
transitions into and back out of pageObserver's 100%-margin window within
milliseconds. Previously that still triggered a full decode via
ensurePageImage() for every one of them, each queued behind the last on the
small shared worker pool - jumping from page 4 to page 102 in a 100+ page
note visibly rasterized ~90 pages nobody ever looked at, taking many seconds
before the actual destination page appeared.

Fixes it the same way thumbObserver's own fly-by problem was fixed during
issue #154: debounce the *load* trigger (~150ms), not eviction, so a page
only ever transiently intersecting during a fast/long scroll never
triggers a load at all. highlightCurrentMatch() now also primes its
destination page directly (mirroring goToPage()'s existing pattern), so a
deliberate jump-to-match isn't itself slowed down by that debounce.
chunkPageNumbers() sized each worker call's batch to
pageNumbers.length / workers.length - fine when the pool defaulted to
navigator.hardwareConcurrency (8-9 workers, ~12 pages/chunk on a 100-page
note), but after DEFAULT_MAX_WORKERS was deliberately shrunk to 2 to bound
the scrolling peak (issue #154), a full-note export started handing each of
those 2 workers ~50 pages in a single toImage() call. Confirmed via
real-device testing: >2GB peak, ~1GB held per worker simultaneously
decoding/encoding its own ~50 pages.

Decouples chunk size from pool size entirely - capped at a small fixed
MAX_PAGES_PER_CHUNK (4) regardless of worker count or document length. The
existing per-worker queue still processes every chunk, just more of them,
sequentially per worker - slower for a large bulk export, an acceptable
trade for a rare, deliberate, one-off action.
pdf-lib's embedPng()/font-glyph placement/etc. are `async` in name, but
the actual work is synchronous CPU-bound decode/encode with no real
await inside, so assemblePdfFromImages()'s per-page loop never
naturally yielded to the event loop - on a real 100-page export this
hung the main thread (no repaint, no input handled) for ~30s, even
though the earlier rasterization pass is correctly off-main-thread via
the Worker pool. Confirmed the same structural issue exists in
assembleTextOnlyPdf() (built on every note open, not just export, to
back SupernoteView's search text layer), so both loops now yield via a
shared yieldToMainThread() (setTimeout(0), not requestAnimationFrame -
still yields promptly if the window isn't focused/visible) between
each page.
…ote upfront

Capping how many pages a single Worker call rasterizes at once
(MAX_PAGES_PER_CHUNK) only bounded memory *inside* a Worker - the PDF
export path still called rasterizePages() for the *entire* document
upfront, so a 100-page note ended up holding all 100 pages' encoded PNGs
in one big `images` array on the main thread, on top of whatever
pdf-lib's own ctx.pdfDoc was simultaneously retaining internally for
each already-embedded page. Confirmed via real-device testing: still
~2GB peak and 20+s of main-thread unresponsiveness even after the
per-Worker-call cap and the yield-between-pages fix.

Replaces assemblePdfFromImages(sn, images) with assemblePdfFromNote(sn),
which rasterizes and embeds PDF_ASSEMBLY_BATCH_SIZE (8) pages at a time
instead of accepting a pre-rasterized array for the whole document -
bounding how many pages' encoded bytes exist outside pdf-lib's own
document state at any moment, regardless of total page count. Updated
both callers (VaultWriter.exportToPDF and buildInsertableContent's PDF
branch) accordingly; other export paths (markdown/image-file
attachments) still rasterize everything upfront since they need the
full array anyway to write per-page image files.
…B peak

Batching rasterization (PDF_ASSEMBLY_BATCH_SIZE) didn't fully fix a
reported 2.4GB peak and 20+s freeze - still open which of two causes
(both outside what that batching could ever touch) is responsible:
ctx.pdfDoc's own internal per-page retention (unavoidable - pdf-lib
needs every embedded page's bytes present until save()), or save()
itself, a single synchronous call over the whole document that pdf-lib
gives no way to chunk or yield during. Logs per-batch heap size (via
performance.memory, Chromium-only but that's Obsidian's runtime) plus
explicit timing/heap around save() to tell them apart before deciding
on a bigger structural change.
… cost

Traced the persistent ~2.4GB peak and 20s+ freeze to pdf-lib's own
embedPng() (src/utils/png.ts, PDFImage.embed()): it always fully decodes
every PNG to raw RGBA8 via UPNG.toRGBA8() regardless of source color
type, and retains that raw buffer - plus a *separate* raw alpha-channel
copy for any page with a non-255 alpha value - in ctx.pdfDoc until
save() finally compresses and releases each one. Confirmed via the
heap/timing logs added last commit: heap climbed linearly ~18MB/page
across 102 pages, then save() alone took 16.86s serializing everything
in one synchronous, unyieldable call - despite the final PDF being only
4.9MB, a ~375x gap between what's retained and what's actually needed.

Every one of these pages does have real per-pixel alpha (background/
unwritten pixels are alpha=0 by design, so the on-screen <img> can sit
over Obsidian's own theme background instead of a hardcoded white
rectangle - see invertColorsWhenDark) - but their RGB channel already
bakes in the correct plain-white-page appearance regardless of alpha,
so it's safe to just drop alpha for a page that's being flattened onto
a real PDF page anyway. Threaded a `flatten` option through
ImageConverter.convertToImages -> WorkerPool -> the Worker message,
applied only from assemblePdfFromNote() (PDF export), leaving the
on-screen rendering path (which needs that transparency) untouched.
Removes one whole raw buffer + compression pass per page from pdf-lib's
side; the remaining raw-RGB retention during the loop and the
monolithic save() call are both inherent to embedPng() and can't be
fixed short of not using it (see the updated doc comment on
assemblePdfFromNote()).
pdf-lib's own embedPng() is genuinely expensive - confirmed via
real-device testing to still peak ~1.7GB and take 10+ seconds for a
100-page note even after dropping each page's alpha channel, since it
always fully decodes every embedded PNG to raw pixels and retains that
until pdfDoc.save(). All of that is inherent to pdf-lib itself, not
fixable from this plugin's side short of not using it - so instead of
trying to shrink it further, move the whole rasterize-embed-save
sequence off the main thread entirely.

Bumps the supernote-typescript submodule (branch pdf-page-data-for-worker,
PR philips/supernote-typescript#42) for
IPdfPage/extractPdfPageData - a structured-clone-safe page slice that
carries recognition text alongside rasterization data, letting a whole
note's pages be sent to a Worker in one message.

- myworker.worker.ts: new 'buildPdf' message does createPdfContext()
  through pdfDoc.save() in one call (pdf-lib's objects aren't
  structured-clone-safe, so this can't be split across round trips) -
  same per-batch alpha-flatten and PDF_ASSEMBLY_BATCH_SIZE batching as
  before, just relocated, plus the same heap/timing diagnostics logged
  from the Worker's own console instead.
- main.ts: assemblePdfFromNote() replaced by buildPdfInWorker(), which
  spins up a *dedicated* Worker (not the shared pool used for on-screen
  rendering - tying up one of its 2 workers for 10+ seconds would stall
  page/thumbnail loading for just as long) and terminates it once done.
  Removed the now-dead `flatten` plumbing from the shared pool's
  'convert' path, since PDF building no longer goes through it at all.
…op it

The alpha-drop optimization (convertColor('RGB')) assumed background/
unwritten pixels were already packed as opaque white regardless of
alpha - wrong. They're packed with color at black and alpha 0, so
discarding alpha revealed that stored black as solid ink, producing a
PDF with a black page background and the actual strokes barely
visible - confirmed via a real exported PDF screenshot.

Bumps the supernote-typescript submodule for flattenToWhite(), which
properly alpha-composites each pixel toward white instead of just
truncating the channel, and swaps myworker.worker.ts's buildPdf handler
over to it.
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