diff --git a/src/main/ipc/deferred-emoji-shortcode-dataset.ts b/src/main/ipc/deferred-emoji-shortcode-dataset.ts new file mode 100644 index 00000000000..2a1672e2ee4 --- /dev/null +++ b/src/main/ipc/deferred-emoji-shortcode-dataset.ts @@ -0,0 +1,12 @@ +import { createRequire } from 'node:module' +import type { EmojiShortcodeDataset } from '../../shared/emoji-shortcode-catalog' + +// Why createRequire (same reason as linear-sdk.ts): a static import inlines the 166 KB +// shortcode dataset into out/main/index.js and JSON.parses it on every launch, while only +// worktree-name sanitization ever reads it. `emojibase-data` is a production dependency, so +// the packaged app.asar resolves this the same way it resolves any other bare require. +const requireFromMain = createRequire(__filename) + +export function requireEmojiShortcodeDataset(): EmojiShortcodeDataset { + return requireFromMain('emojibase-data/en/shortcodes/emojibase.json') as EmojiShortcodeDataset +} diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 17ad0c49e46..7a8fe175c89 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -4,9 +4,15 @@ import type { Repo } from '../../shared/repo-types' import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath, resolveWslRepoWorktreeBasePath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree/id' -import { replaceKnownEmojiWithShortcodes } from '../../shared/emoji-shortcode-catalog' +import { + replaceKnownEmojiWithShortcodes, + setEmojiShortcodeDatasetLoader +} from '../../shared/emoji-shortcode-catalog' +import { requireEmojiShortcodeDataset } from './deferred-emoji-shortcode-dataset' import { getWslHome, getWslHomeAsync, parseWslPath } from '../wsl' +setEmojiShortcodeDatasetLoader(requireEmojiShortcodeDataset) + type WorktreePathSettings = Pick & { /** Distro to mirror the workspace root into when the repo itself sits on a * Windows drive but this project's git runs in WSL. Omitted = today's diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index e122961b0c9..171aaf50421 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -139,7 +139,22 @@ export async function initializeReadyFoundation(): Promise { }) state.store = store // Why: create pending readiness before the guard can observe the default session. - const initialProxyApplication = applyElectronProxySettings(store.getSettings()) + // Why parked on state instead of awaited here: Dock/Launchpad launches don't inherit shell + // proxy env vars, so the persisted proxy must land before any app-owned network fetcher runs — + // but the guard below already holds every default-session request until this settles, so + // awaiting it inline only delayed window creation. Runtime launch awaits it before the first + // fetcher (the desktop relay / headless serve). + state.initialProxyApplicationReady = applyElectronProxySettings(store.getSettings()).then( + (result) => { + if (result.source === 'invalid-settings') { + // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. + console.warn('[proxy] persisted proxy settings are invalid; using direct networking') + } + }, + () => { + console.warn('[proxy] Failed to apply network proxy settings') + } + ) installElectronProxyRequestGuard(session.defaultSession) // Why armed here and not at install time: the report remembers what it last said, and // that state lives beside the profile data file, which does not exist until now. @@ -235,16 +250,6 @@ export async function initializeReadyFoundation(): Promise { if (shouldSuppressDevEducation({ isDev: is.dev })) { suppressDevEducationForStore(store) } - try { - // Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run. - const proxyApplyResult = await initialProxyApplication - if (proxyApplyResult.source === 'invalid-settings') { - // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. - console.warn('[proxy] persisted proxy settings are invalid; using direct networking') - } - } catch { - console.warn('[proxy] Failed to apply network proxy settings') - } // Why: the partition installer reads the proxy through this resolver, so register it before sessions materialize. setBrowserNetworkProxySettingsResolver(() => state.store!.getSettings()) // Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches. diff --git a/src/main/startup/main-process-ready-phase-ordering.test.ts b/src/main/startup/main-process-ready-phase-ordering.test.ts new file mode 100644 index 00000000000..3fc30ba359c --- /dev/null +++ b/src/main/startup/main-process-ready-phase-ordering.test.ts @@ -0,0 +1,137 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const phaseEvents: string[] = [] +let releaseI18n: (() => void) | null = null + +vi.mock('./main-process-ready-foundation', () => ({ + initializeReadyFoundation: vi.fn(async () => { + phaseEvents.push('foundation') + }) +})) +vi.mock('./main-process-ready-runtime', () => ({ + initializeReadyRuntimeServices: vi.fn(async () => { + phaseEvents.push('runtime-services') + }) +})) +vi.mock('./main-process-i18n-menu', () => ({ + initializeMainProcessI18nAndMenu: vi.fn( + () => + new Promise((resolve) => { + phaseEvents.push('i18n-start') + releaseI18n = () => { + phaseEvents.push('i18n-done') + resolve() + } + }) + ) +})) +vi.mock('./main-process-runtime-launch', () => ({ + initializeMainProcessRuntimeLaunch: vi.fn(async () => { + phaseEvents.push('launch-start') + await Promise.resolve() + phaseEvents.push('window-created') + }) +})) + +const { initializeMainProcessReady } = await import('./main-process-ready') + +describe('ready-phase concurrency', () => { + beforeEach(() => { + phaseEvents.length = 0 + releaseI18n = null + }) + + it('creates the window without waiting for i18n and the native menu', async () => { + const options = { + openMainWindow: vi.fn(), + handleMacAppActivation: vi.fn() + } as unknown as Parameters[0] + + const ready = initializeMainProcessReady(options) + // Drain the launch phase's microtasks while i18n is still pending. + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } + + expect(phaseEvents).toEqual([ + 'foundation', + 'runtime-services', + 'i18n-start', + 'launch-start', + 'window-created' + ]) + + releaseI18n?.() + await ready + expect(phaseEvents.at(-1)).toBe('i18n-done') + }) + + it('still resolves only once i18n and the menu have settled', async () => { + const options = { + openMainWindow: vi.fn(), + handleMacAppActivation: vi.fn() + } as unknown as Parameters[0] + + const ready = initializeMainProcessReady(options) + let settled = false + void ready.then(() => { + settled = true + }) + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } + + expect(settled).toBe(false) + releaseI18n?.() + await ready + expect(settled).toBe(true) + }) +}) + +describe('initial proxy application ordering', () => { + const readStartupSource = (file: string): string => + readFileSync(join(process.cwd(), 'src/main/startup', file), 'utf8') + + it('parks the default-session proxy apply instead of blocking window creation on it', () => { + const foundation = readStartupSource('main-process-ready-foundation.ts') + + expect(foundation).toContain('state.initialProxyApplicationReady = applyElectronProxySettings(') + // The request guard, not this phase, is what fences fetchers on the proxy; awaiting it here + // only queued openMainWindow behind a ~24 ms setProxy round trip. + expect(foundation).not.toMatch(/await\s+(?:state\.)?initialProxyApplication/) + }) + + it('awaits the proxy after the window opens and before the desktop relay starts', () => { + const launch = readStartupSource('main-process-runtime-launch.ts') + const desktopStart = launch.indexOf('async function launchDesktopMode(') + const desktopEnd = launch.indexOf('\nexport async function initializeMainProcessRuntimeLaunch') + expect(desktopStart).toBeGreaterThanOrEqual(0) + expect(desktopEnd).toBeGreaterThan(desktopStart) + const desktop = launch.slice(desktopStart, desktopEnd) + + const windowIndex = desktop.indexOf('openMainWindow()') + const proxyIndex = desktop.indexOf('await state.initialProxyApplicationReady') + const relayIndex = desktop.indexOf('new DesktopRelayService(') + + expect(windowIndex).toBeGreaterThanOrEqual(0) + expect(proxyIndex).toBeGreaterThan(windowIndex) + expect(relayIndex).toBeGreaterThan(proxyIndex) + }) + + it('keeps headless serve strictly ordered behind the proxy apply', () => { + const launch = readStartupSource('main-process-runtime-launch.ts') + const serveStart = launch.indexOf('async function launchServeMode(') + const serveEnd = launch.indexOf('\nasync function launchDesktopMode(', serveStart) + expect(serveStart).toBeGreaterThanOrEqual(0) + expect(serveEnd).toBeGreaterThan(serveStart) + const serve = launch.slice(serveStart, serveEnd) + + const proxyIndex = serve.indexOf('await state.initialProxyApplicationReady') + const rpcIndex = serve.indexOf('runtimeRpc.start()') + + expect(proxyIndex).toBeGreaterThanOrEqual(0) + expect(rpcIndex).toBeGreaterThan(proxyIndex) + }) +}) diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts index e6d8d782e6e..c89e2aee23d 100644 --- a/src/main/startup/main-process-ready.ts +++ b/src/main/startup/main-process-ready.ts @@ -12,6 +12,11 @@ export async function initializeMainProcessReady( ): Promise { await initializeReadyFoundation() await initializeReadyRuntimeServices() - await initializeMainProcessI18nAndMenu() - await initializeMainProcessRuntimeLaunch(options) + // Why concurrent: window creation reads no translated string and no menu item, and both the + // native menu and the tray only become reachable once the window shows — so serializing them + // ahead of openMainWindow only delayed the renderer (8 ms in English, more for a lazy locale). + await Promise.all([ + initializeMainProcessI18nAndMenu(), + initializeMainProcessRuntimeLaunch(options) + ]) } diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 9df28ecea8c..0e73547c133 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -120,6 +120,9 @@ async function launchServeMode( runtimeRpc: OrcaRuntimeRpcServer, serveOptions: NonNullable> ): Promise { + // Why here: headless serve has no window to unblock, so keep the persisted proxy strictly + // ahead of every fetcher this phase can reach (relay, CLI install, RPC clients). + await state.initialProxyApplicationReady // Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness. logStartupMilestone('wsl-cli-barrier-start') await state.managedWslCliStartupBarrierReady @@ -228,6 +231,10 @@ async function launchDesktopMode( if (!runtimeRpcStartResult.ok) { void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) } + // Why after the window and not before it: the default-session request guard already holds every + // fetcher until the persisted proxy lands, so this only has to keep the launch phase itself + // ordered ahead of the relay — it must not gate the renderer. + await state.initialProxyApplicationReady const cloudAuth = getOrcaCloudAuthConfig() if (cloudAuth.configured) { try { diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index d48219b461e..f467adc3f4d 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -100,6 +100,9 @@ export const mainProcessState = { // Electron with no error. Only the renderer's own pull proves the listener is live. markdownFileOpenListenerReady: false, firstWindowStartupServicesReady: Promise.resolve(), + // Why published: the default-session proxy must be applied before the first app-owned fetcher, + // but window creation has no reason to queue behind it (the request guard already fences it). + initialProxyApplicationReady: Promise.resolve(), managedWslCliReconciliationReady: Promise.resolve(), managedWslCliStartupBarrierReady: Promise.resolve(), // Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op). diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.ts index a3d65c18907..184207ffbd8 100644 --- a/src/renderer/src/lib/workspace-emoji-shortcodes.ts +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.ts @@ -1,8 +1,14 @@ +import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' import { getStandardEmojiShortcodeEntries, + setEmojiShortcodeDatasetLoader, type StandardEmojiShortcodeEntry } from '../../../shared/emoji-shortcode-catalog' +// Why eager here and lazy in main: a dynamic import made the shortcode transform return an +// empty catalog until it settled, so a `:wink:` submitted in that window persisted literally. +setEmojiShortcodeDatasetLoader(() => emojiShortcodes) + export type WorkspaceEmojiSuggestion = StandardEmojiShortcodeEntry export type ActiveWorkspaceEmojiShortcode = { diff --git a/src/shared/emoji-shortcode-catalog.lazy.test.ts b/src/shared/emoji-shortcode-catalog.lazy.test.ts index e838eef765e..4c2ce505e24 100644 --- a/src/shared/emoji-shortcode-catalog.lazy.test.ts +++ b/src/shared/emoji-shortcode-catalog.lazy.test.ts @@ -1,6 +1,13 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' + +async function importConfiguredCatalog() { + const catalog = await import('./emoji-shortcode-catalog.js') + catalog.setEmojiShortcodeDatasetLoader(() => emojiShortcodes) + return catalog +} describe('emoji shortcode catalog laziness', () => { beforeEach(() => { @@ -8,7 +15,7 @@ describe('emoji shortcode catalog laziness', () => { }) it('does not build the catalog when the shared module is imported', async () => { - const catalog = await import('./emoji-shortcode-catalog.js') + const catalog = await importConfiguredCatalog() expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(false) @@ -17,20 +24,23 @@ describe('emoji shortcode catalog laziness', () => { }) it('builds on first use and keeps the main process off the eager path', async () => { - const catalog = await import('./emoji-shortcode-catalog.js') + const catalog = await importConfiguredCatalog() expect(catalog.replaceKnownEmojiWithShortcodes('ship \u{1F389}')).toBe('ship party ') expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(true) }) - it('leaves the main-process worktree namer importing only the deferred entry point', () => { + it('leaves the main-process worktree namer importing only the deferred entry points', () => { // A cross-project import would drag src/main into the shared tsconfig, so assert on source. const worktreeLogic = readFileSync(join(__dirname, '../main/ipc/worktree-logic.ts'), 'utf8') const catalogImport = worktreeLogic.match( /import \{([^}]*)\} from '[^']*emoji-shortcode-catalog'/ ) - expect(catalogImport?.[1].trim()).toBe('replaceKnownEmojiWithShortcodes') + expect(catalogImport?.[1].split(',').map((name) => name.trim())).toEqual([ + 'replaceKnownEmojiWithShortcodes', + 'setEmojiShortcodeDatasetLoader' + ]) }) it('keeps the catalog build out of module scope', () => { @@ -41,4 +51,33 @@ describe('emoji shortcode catalog laziness', () => { expect(sharedSource).not.toMatch(/^const \w+ = new (?:Map|Intl\.Segmenter)\(/m) expect(sharedSource).toContain('function loadCatalog()') }) + + it('keeps the 166 KB dataset off every module main statically imports', () => { + const sharedSource = readFileSync(join(__dirname, 'emoji-shortcode-catalog.ts'), 'utf8') + const worktreeLogic = readFileSync(join(__dirname, '../main/ipc/worktree-logic.ts'), 'utf8') + + // A static `emojibase-data` import anywhere main reaches inlines the dataset into + // out/main/index.js and JSON.parses it on every launch. + const staticDatasetImport = /\bfrom '[^']*emojibase-data[^']*'/ + expect(sharedSource).not.toMatch(staticDatasetImport) + expect(worktreeLogic).not.toMatch(staticDatasetImport) + }) + + it('loads the main-side dataset synchronously into an identical catalog', async () => { + const { requireEmojiShortcodeDataset } = + await import('../main/ipc/deferred-emoji-shortcode-dataset.js') + const eager = await importConfiguredCatalog() + const eagerEntries = eager.getStandardEmojiShortcodeEntries() + const eagerTransform = eager.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}') + + vi.resetModules() + const deferred = await import('./emoji-shortcode-catalog.js') + deferred.setEmojiShortcodeDatasetLoader(requireEmojiShortcodeDataset) + + // No await between registration and first use: the require path keeps the sync contract. + expect(deferred.getStandardEmojiShortcodeEntries()).toEqual(eagerEntries) + expect(deferred.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}')).toBe( + eagerTransform + ) + }) }) diff --git a/src/shared/emoji-shortcode-catalog.ts b/src/shared/emoji-shortcode-catalog.ts index b583a0c617a..fca9922d3fe 100644 --- a/src/shared/emoji-shortcode-catalog.ts +++ b/src/shared/emoji-shortcode-catalog.ts @@ -1,10 +1,23 @@ -import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' - export type StandardEmojiShortcodeEntry = { emoji: string shortcode: string } +/** Shape of `emojibase-data/en/shortcodes/emojibase.json`: hexcode -> shortcode or aliases. */ +export type EmojiShortcodeDataset = Readonly> + +let loadDataset: (() => EmojiShortcodeDataset) | null = null + +/** + * Why injected instead of statically imported: the renderer must keep its eager copy (a + * dynamic import there returned an empty catalog mid-load and persisted `:wink:` literally), + * but a static import here also inlines the same 166 KB into out/main/index.js and JSON.parses + * it on every launch. Main supplies a lazy require instead; both stay synchronous. + */ +export function setEmojiShortcodeDatasetLoader(load: () => EmojiShortcodeDataset): void { + loadDataset = load +} + // Skin-tone aliases (`wave_tone3`) are ~40% of the dataset and would drown the suggestion list. const SKIN_TONE_SHORTCODE = /_tone\d(?:-\d)?$/ @@ -23,7 +36,10 @@ function loadCatalog(): EmojiShortcodeCatalog { if (catalog) { return catalog } - const grouped = Object.entries(emojiShortcodes).flatMap(([hexcode, value]) => { + if (!loadDataset) { + throw new Error('Emoji shortcode dataset loader was never registered') + } + const grouped = Object.entries(loadDataset()).flatMap(([hexcode, value]) => { const shortcodes = (typeof value === 'string' ? [value] : value).filter( (shortcode) => !SKIN_TONE_SHORTCODE.test(shortcode) )