0012. Comic files decoded in a utility process, not in the main process
- Status: Accepted (amended by 0018: timeouts on the calls that open a file, archives tied to their window)
- Date: 2026-10-03
Context
Opening a comic means parsing a file the user did not write: a ZIP, a RAR (unrar compiled to WebAssembly), a PDF (pdf.js), then decoding images with @napi-rs/canvas, a native addon. All of that ran in the main process, which also owns the window and the database. A booby-trapped or merely broken file could therefore take the whole app down: a PDF declaring a gigantic page was measured at +495 MB, and a bug in a native decoder is a crash with no JavaScript stack to catch. The rasterization also ran on the same event loop as every other IPC call, delaying them.
Decision
ComicService(the archives kept open by the reader) andThumbnailCacherun in a decoder process: an ElectronutilityProcessforked fromsrc/main/decoder/decoder-worker.ts, bundled apart asdecoder-worker.cjs(vite.decoder.config.mts, a secondtarget: 'main'entry of the Forge Vite plugin).- The main process talks to it through a
MessageChannelMainport, with a small request/response protocol (src/main/decoder/protocol.ts): every call carries an id, the answer carries the same id and either a result or an already translated error message.DecoderClient(decoder-client.ts) is the main-side API; the IPC handlers only ever call it. The main process keeps the database, the dialogs and the filesystem walk; it no longer imports any archive code. - The folder scan stays driven by the main process (it owns
hasPath()/register()): for each unknown file it callsinspect, which opens the archive in the decoder process, caches its cover, and returns the page and file counts. - The process is started on the first call. When it dies, every call waiting for it rejects with a translated "unreadable file" error (
errors:decoder.crashed), and the next call starts a new one, initialised again with the thumbnails directory and the current language (applyMainLanguage()forwards each language change).
Consequences
- A crash or an out-of-memory kill in a decoder ends that one request (a book that won't open, a cover left out, a file counted as
failedin a scan), never the app or the database. - Rendering no longer blocks the main process's other IPC handlers.
- Archives opened in a dead process are gone: the reader's next page request fails with an "unknown archive" error until the book is reopened. Accepted for now: a crash is rare, and reopening restores the position from the library.
- A page crosses the process boundary by structured clone, so its bytes are copied once more (a few MB per page, small next to decoding it;
postMessagecan only transfer ports, not buffers, in a utility process). The renderer still gets them throughipcMain.handle, as before. - The build constraints of the decoders (
unrar.wasmnext to the bundle,pdfjs-distand@napi-rs/canvasexternal and copied into the package, native binaries unpacked from the asar) now apply todecoder-worker.cjs;vite.main.config.mtsonly keepsnode:sqlite. The wasm file and the bundle are in the same folder, so__dirname-based lookups are unchanged. In development,forge.config.tsrestarts the app whendecoder-worker.cjsis rebuilt too. - The decoder process has its own i18next instance, like the renderer, switched by the main process.
Alternatives considered
child_process.forkor a Nodeworker_threadsworker: a worker thread shares the process, so a native crash or a memory blow-up still takes the app down.utilityProcessis Electron's supported way to run Node code in a separate process, withMessagePortsupport.- Only wrapping decoders in
try/catch: can't catch a native crash or the OS killing a process for memory. - The whole scan in the decoder process: would save a round trip per file but needs the database accessed from both sides, or a callback protocol for each
register(); the main-driven loop keeps the database in one place. - Moving the metadata lookups too: they parse JSON and HTML from known APIs, not arbitrary user files; out of scope.