') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(desktop): boot after ready to break Electron ESM startup deadlock by Astro-Han · Pull Request #1880 · apache/maka · GitHub
Skip to content

fix(desktop): boot after ready to break Electron ESM startup deadlock - #1880

Merged
Astro-Han merged 2 commits into
mainfrom
fix/storage-root-ready-deadlock
Aug 2, 2026
Merged

fix(desktop): boot after ready to break Electron ESM startup deadlock#1880
Astro-Han merged 2 commits into
mainfrom
fix/storage-root-ready-deadlock

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Problem

Electron ESM emits ready only after the main module finishes evaluating. Maka's main entry ran await resolveDesktopStorageRoot(...) in the top-level module-evaluation chain; on a storage-root identity conflict, confirmDesktopStorageRootRepair called await app.whenReady() — a guaranteed deadlock (module evaluation waits on ready, ready waits on module evaluation). Any user hitting the repair dialog got a silently hung process with no window.

Verified experimentally on Electron 43.1.1: ready fires only after module evaluation completes, and dialog.showMessageBox throws before ready, so the whenReady await is not removable — the check must leave the top-level chain.

Fix

Split the entry into a thin pre-ready main.ts and a boot.ts loaded via dynamic import inside the app.whenReady() callback:

  • main.ts (43 lines): app.setName, E2E userData redirect, single-instance lock (now an explicit if/else so the losing process exits without evaluating the boot chain), then whenReady().then(() => import('./boot.js')) with a showErrorBox fatal path (suppressed under isolated E2E so a failure exits fast instead of hanging on a modal).
  • boot.ts: the unchanged startup chain (root-identity check → stores → IPC → lifecycle) as module-level code, now evaluated after ready. The "root-identity check before any store/db write" ordering is preserved.
  • confirmDesktopStorageRootRepair: drops await app.whenReady() for an if (!app.isReady()) throw assertion (ready is guaranteed by the boot contract).
  • Shared E2E switches moved to startup-context.ts.

Reviewed independently by Claude Opus and Codex (consult) before implementation; both rejected the minimal "fire-and-forget gate" variant as a silent integrity hole and recommended this thin-entry + dynamic-import shape.

Validation

  • New E2E regression storage-root-conflict.spec.ts: seeds a valid marker, corrupts its dev, launches without a fixture, asserts the app parks at the modal repair dialog (the only accepted success signal — the dialog can only appear after ready, since the whole boot module runs inside the whenReady callback, so it simultaneously proves ready was reached and that the root-identity gate is holding) and writes nothing (no SQLite) before the user answers. Fails on main (deadlock) and would fail if the gate were ever removed; passes here. The observable form of "dialog is open" is a CDP evaluate that never settles, because the macOS modal loop stops answering evaluation.
  • main-process unit tests: 1314 pass. tsc, biome lint, format all clean.
  • Existing E2E paths verified: normal launch (send-message) and e2e-fixture seeding (scroll-geometry long-transcript).

Notes

  • ready no longer waits on the login-shell PATH probe (resolveShellEnv moved into boot), an incidental latency improvement.
  • Per review: console allow-list extended to boot.ts (CI test:dist gate), E2E success signal tightened to "parked at dialog" only, E2E fatal path no longer shows a modal, and stale main.ts references in comments updated to boot.ts.
  • PR fix(runtime): evict oldest queued PTY data instead of pausing the source #1873-era worktrees unaffected; this branch is built from latest main.

Electron ESM emits `ready` only after the main module finishes
evaluating, so a top-level `await app.whenReady()` in the startup chain
deadlocks: module evaluation waits on ready, ready waits on module
evaluation. The storage-root repair dialog hit exactly this — any
root-identity conflict hung the process silently with no window.
Split the entry: main.ts now does only pre-ready work (setName, E2E
userData redirect, single-instance lock with a proper return) and
dynamic-imports boot.ts inside the whenReady callback. boot.ts keeps the
whole startup chain (root-identity check, stores, IPC, lifecycle) as
module-level code after ready, so the check still precedes every store
and db write and confirmRepair no longer needs whenReady at all.
Also:
- fix losing-second-instance exiting without returning, so it never
touches shared state (was opening SQLite before exit)
- surface fatal boot errors via showErrorBox instead of a silent exit
- E2E regression test: conflicting storage root reaches ready with the
repair dialog open and writes nothing before the user answers
Review (Claude Opus + GPT-5.6-sol, independent) findings:
- CI gate: check-console allow-list only covered main.ts; the 7 console
sites moved into boot.ts with the startup chain, so 'test:dist' failed
the audit. Allow boot.ts and refresh the stale main.ts reason.
- E2E: the regression test claimed a '[startup] app ready' console signal
that nothing consumed; what actually passed was a 1s CDP-timeout
heuristic, which could false-positive on any slow/stuck main process.
Tighten to accept ONLY 'parked at the modal repair dialog' as success:
the dialog can only appear after ready (whole boot module runs inside
the whenReady callback), so it simultaneously proves ready + gate
holding, and a deadlocked process or a removed gate both fail.
- Fatal path: suppress showErrorBox under isolated E2E (same reasoning as
the fixture-fatal path in boot.ts) so a boot failure exits fast instead
of hanging on a modal until test timeout.
- Comments: update stale 'main.ts' references to boot.ts where they name
the startup chain's home.
@Astro-Han
Astro-Han marked this pull request as ready for review August 2, 2026 05:58
@Astro-Han
Astro-Han merged commit e73f661 into mainAug 2, 2026
3 of 5 checks passed
@Astro-Han
Astro-Han deleted the fix/storage-root-ready-deadlock branch August 2, 2026 05:59
Astro-Han added a commit that referenced this pull request Aug 2, 2026
Not this branch's change. #1880 added a `check-console.mjs` allowlist entry on
one line that Biome wraps across four, so `format:check` — and with it the whole
`typecheck` job — has been failing on main since that merge, for every branch.
Fixing it here because this PR cannot go green without it. It is `biome format
--write` on that one file and nothing else.
Astro-Han added a commit that referenced this pull request Aug 2, 2026
…nal) (#1887)
* fix(scripts): format check-console allow-list entry
The PR1880 entry for main.ts exceeded the line width; biome format
required splitting it. format:check was failing CI on main.
* fix(desktop): make storage-root-conflict e2e signal platform-independent
The regression test for the ESM startup deadlock treated 'CDP evaluate
never settles within 1s' as the proof that the repair dialog was open.
That holds only on macOS, where modal loops block CDP evaluation; on
Linux (CI) the modal keeps answering evaluation, so the test failed even
though the app parked correctly — and a deadlocked main process would
have been accepted as a pass.
Replace the heuristic with an explicit contract: boot.ts prints
'[storage-root] root-identity conflict; parking at repair dialog'
synchronously before the modal (printed only after ready, only when the
gate fired), and the test waits for that console event. The workspace
write-free assertion is unchanged. Deadlock and gate-removal both never
print the signal, so both still fail the test on every platform.
Sign up for freeto 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

@Astro-Han