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.