mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
perf(startup): stop queueing window creation behind the proxy apply and i18n (#18436)
* perf(startup): stop queueing window creation behind the proxy apply and i18n
Three independent, measured startup wins, all free:
1. Park the initial Chromium proxy apply on `mainProcessState` instead of
awaiting it mid-`initializeReadyFoundation`. `setProxy` still starts at the
identical moment; the default-session request guard (which holds, not
cancels) is what actually fences fetchers on it, so only window creation
stops waiting. Runtime launch still awaits it before the desktop relay and
before every headless-serve fetcher.
2. Run `initializeMainProcessI18nAndMenu` concurrently with
`initializeMainProcessRuntimeLaunch`. Nothing in window creation reads a
translated string or the native menu.
3. Load `emojibase-data` in main through `createRequire` on first use instead
of a static import, keeping 166 KB of JSON off `out/main/index.js` and its
~2 ms parse off every launch. The renderer keeps its eager copy unchanged.
out/main/index.js 7,210,071 -> 7,040,147 bytes. No renderer behaviour changes.
* fix(packaging): ship the emoji shortcode dataset main lazily requires
app.asar carries no node_modules, so main's bare requires resolve only out of
Resources/node_modules. emojibase-data is a devDependency and is not in the
packaged runtime allowlist, so the new createRequire in
deferred-emoji-shortcode-dataset.ts threw MODULE_NOT_FOUND in every packaged
build — breaking sanitizeWorktreeName, and with it workspace creation.
Copy the single 166 KB dataset (not the 49 MB package root) into
Resources/node_modules, and gate every createRequire'd bare specifier in
src/main against the packaged resource plan. verifyPackagedMainRuntimeDeps
cannot catch these: the bundler renames the require binding.
* test(proxy): fail CI when a main-process fetcher escapes the default-session guard
The hoist relies on installElectronProxyRequestGuard(session.defaultSession) holding every app-owned request until the persisted proxy lands. Nothing enforced that every fetcher actually lands on defaultSession. Two source-anchored rules do now: no net.fetch/net.request may name a session/partition, and every non-net .fetch( call site is counted against an allowlist.
* test(proxy): close the shorthand and chained-receiver holes in the fetch call-site audit
The audit caught `net.request({ session: x })` and `ident.fetch(`, but not the two
shapes a real regression is just as likely to take: the `{ url, session }` shorthand
that both `net.request` overloads accept, and a receiver with no bare identifier
(`session.fromPartition(...).fetch(`, `ctx.session.fetch(`). Rule 1 now also matches
the shorthand key; rule 2 scans every `.fetch(` and excludes only a literal
`net`/`globalThis`/`global` receiver. Audited counts are unchanged (2/2/1).
* fix(startup): scope the deferred emoji loader to the projects that own it
TS6307: the composite web project lists src/main/ipc/worktree-logic.ts, which
now imports the deferred dataset loader, and the shared lazy test reached into
src/main from a project that has no src/main files. Add the loader to
tsconfig.tc.web.json and move the cross-project case into a src/main test.
Also close the last two review gaps: gate the runtime-RPC startup failure
dialog (the only launch-phase translateMain reader) on a published i18n
barrier so a concurrent i18n phase cannot leave a non-English user with the
English fallback, and let the fetch call-site audit match `net.fetch (url)`.
This commit is contained in:
@@ -90,7 +90,19 @@ const bundledPluginResources = {
|
||||
// from package directories where pnpm's symlink farm is absent. Copy the exact
|
||||
// runtime dependency closure to Resources/node_modules so bare require() calls
|
||||
// do not fall through to a developer checkout's node_modules.
|
||||
const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources]
|
||||
// Why the single file rather than the package root: app.asar carries no node_modules, so main's
|
||||
// lazy require in deferred-emoji-shortcode-dataset.ts resolves only out of Resources/node_modules,
|
||||
// but emojibase-data is 49 MB of locale datasets and worktree naming reads exactly this 166 KB file.
|
||||
const emojiShortcodeDatasetResource = {
|
||||
from: 'node_modules/emojibase-data/en/shortcodes/emojibase.json',
|
||||
to: 'node_modules/emojibase-data/en/shortcodes/emojibase.json'
|
||||
}
|
||||
const commonExtraResources = [
|
||||
relayExtraResource,
|
||||
bundledPluginResources,
|
||||
skillFreshnessResources,
|
||||
emojiShortcodeDatasetResource
|
||||
]
|
||||
// Why: native speech addons must be real files outside app.asar; copy only the
|
||||
// package matching the artifact target instead of every optional variant.
|
||||
const macSpeechNativeResource = {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const projectRoot = resolve(import.meta.dirname, '..', '..')
|
||||
const electronBuilderConfig = require('../electron-builder.config.cjs')
|
||||
const {
|
||||
createPackagedRuntimeNodeModuleResources,
|
||||
findAsarEntry,
|
||||
isPackagedExternalSpecifier,
|
||||
packageNameFromSpecifier,
|
||||
prunePackagedNodePty,
|
||||
prunePackagedParcelWatcher,
|
||||
prunePackagedSherpaOnnx,
|
||||
@@ -306,3 +310,91 @@ describe('packaged runtime resources', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Why source-anchored: the bundler renames a createRequire()'d require, so
|
||||
// verifyPackagedMainRuntimeDeps' `require("x")` scan cannot see these specifiers — packaging
|
||||
// stays green while the packaged app throws MODULE_NOT_FOUND the first time the path runs.
|
||||
function collectLazyRequireSpecifiers(directory, found = new Map()) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const entryPath = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
collectLazyRequireSpecifiers(entryPath, found)
|
||||
continue
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith('.ts') || entry.name.includes('.test.')) {
|
||||
continue
|
||||
}
|
||||
const source = readFileSync(entryPath, 'utf8')
|
||||
if (!source.includes('createRequire(')) {
|
||||
continue
|
||||
}
|
||||
for (const match of source.matchAll(/\brequire[A-Za-z0-9_]*\(\s*'([^']+)'\s*\)/g)) {
|
||||
if (isPackagedExternalSpecifier(match[1])) {
|
||||
found.set(match[1], relative(projectRoot, entryPath).replaceAll('\\', '/'))
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function packagedResourceDestinations(platform) {
|
||||
return new Set(
|
||||
(electronBuilderConfig[platform].extraResources ?? []).map((resource) =>
|
||||
String(resource.to).replaceAll('\\', '/')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
describe('lazily required packages reach Resources/node_modules', () => {
|
||||
it('copies every createRequire specifier main uses into the packaged resource plan', () => {
|
||||
const specifiers = collectLazyRequireSpecifiers(join(projectRoot, 'src', 'main'))
|
||||
expect(specifiers.size).toBeGreaterThan(0)
|
||||
|
||||
const destinations = {
|
||||
win: packagedResourceDestinations('win'),
|
||||
mac: packagedResourceDestinations('mac'),
|
||||
linux: packagedResourceDestinations('linux')
|
||||
}
|
||||
for (const [specifier, source] of specifiers) {
|
||||
const packageName = packageNameFromSpecifier(specifier)
|
||||
const covered = (platform) =>
|
||||
destinations[platform].has(`node_modules/${packageName}`) ||
|
||||
destinations[platform].has(`node_modules/${specifier}`)
|
||||
// Windows carries the full closure, so an uncovered specifier is uncovered everywhere.
|
||||
expect(
|
||||
covered('win'),
|
||||
`${source} lazily requires '${specifier}', but nothing copies it to Resources/node_modules`
|
||||
).toBe(true)
|
||||
if (covered('mac') && covered('linux')) {
|
||||
continue
|
||||
}
|
||||
// Only the Windows-native loaders may be absent from the mac/linux plans.
|
||||
expect(source, `'${specifier}' is packaged for Windows only`).toContain('windows')
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the copied emoji dataset the way the packaged main bundle does', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-lazy-require-'))
|
||||
try {
|
||||
const datasetPath = 'node_modules/emojibase-data/en/shortcodes/emojibase.json'
|
||||
const entry = electronBuilderConfig.mac.extraResources.find(
|
||||
(resource) => String(resource.to) === datasetPath
|
||||
)
|
||||
expect(entry).toBeDefined()
|
||||
const destination = join(resourcesDir, ...datasetPath.split('/'))
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await cp(join(projectRoot, ...String(entry.from).split('/')), destination)
|
||||
|
||||
// app.asar's parent is Resources, so main's bare require walks into Resources/node_modules.
|
||||
const packagedMainDir = join(resourcesDir, 'app.asar', 'out', 'main')
|
||||
await mkdir(packagedMainDir, { recursive: true })
|
||||
const probe = join(packagedMainDir, 'probe.cjs')
|
||||
await writeFile(probe, 'module.exports = require', 'utf8')
|
||||
|
||||
const dataset = require(probe)('emojibase-data/en/shortcodes/emojibase.json')
|
||||
expect(Object.keys(dataset).length).toBeGreaterThan(1000)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"../src/preload/usage-provider-api.ts",
|
||||
"../src/shared/**/*",
|
||||
"../src/main/gitlab/mappers.ts",
|
||||
"../src/main/ipc/deferred-emoji-shortcode-dataset.ts",
|
||||
"../src/main/ipc/worktree-branch-name.ts",
|
||||
"../src/main/ipc/worktree-logic.ts",
|
||||
"../src/main/ipc/worktree-display-name.ts",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json'
|
||||
import { requireEmojiShortcodeDataset } from './deferred-emoji-shortcode-dataset'
|
||||
|
||||
// Lives under src/main (not next to the shared catalog) so the shared tsconfig projects stay
|
||||
// free of a src/main import — the boundary emoji-shortcode-catalog.lazy.test.ts asserts on.
|
||||
describe('deferred emoji shortcode dataset', () => {
|
||||
it('loads the main-side dataset synchronously into an identical catalog', async () => {
|
||||
vi.resetModules()
|
||||
const eager = await import('../../shared/emoji-shortcode-catalog.js')
|
||||
eager.setEmojiShortcodeDatasetLoader(() => emojiShortcodes)
|
||||
const eagerEntries = eager.getStandardEmojiShortcodeEntries()
|
||||
const eagerTransform = eager.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}')
|
||||
|
||||
vi.resetModules()
|
||||
const deferred = await import('../../shared/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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
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. app.asar ships no node_modules, so this bare require
|
||||
// resolves out of Resources/node_modules — electron-builder.config.cjs copies exactly this file
|
||||
// there (the package root is 49 MB of locale data).
|
||||
const requireFromMain = createRequire(__filename)
|
||||
|
||||
export function requireEmojiShortcodeDataset(): EmojiShortcodeDataset {
|
||||
return requireFromMain('emojibase-data/en/shortcodes/emojibase.json') as EmojiShortcodeDataset
|
||||
}
|
||||
@@ -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<GlobalSettings, 'nestWorkspaces' | 'workspaceDir'> & {
|
||||
/** 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
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Startup applies the persisted proxy to `session.defaultSession` only, and
|
||||
// `installElectronProxyRequestGuard(session.defaultSession)` is what actually holds requests
|
||||
// until that apply (and every later proxy transition) settles. Two ways a main-process fetcher
|
||||
// can escape that fence, both audited here:
|
||||
// 1. a `net.fetch` / `net.request` that names another `session` or `partition`
|
||||
// 2. a `<session>.fetch(` on a `session.fromPartition(...)` session
|
||||
// Known pre-existing gap outside this repo's reach: electron-updater runs on its own partition.
|
||||
//
|
||||
// Rule 2 entries map a file to its expected number of non-`net` `.fetch(` calls. A count change
|
||||
// means a call site was added, removed, or moved: re-audit the file and update the count.
|
||||
const AUDITED_NON_NET_FETCH_CALLS = new Map<string, number>([
|
||||
// Isolated cookie-jar session, proxied by createOpenCodeRequestSession before any request.
|
||||
['main/rate-limits/opencode-go-usage-fetcher.ts', 2],
|
||||
// Isolated cookie-jar session that does NOT apply the proxy — a pre-existing gap, not a
|
||||
// regression: no proxy has ever reached this partition. Keep it listed so it stays visible.
|
||||
['main/rate-limits/minimax-request-context.ts', 2],
|
||||
// Injected HttpClient, not a session: resolves to net.fetch on defaultSession
|
||||
// (main/host/electron-http-client.ts) or to the global-fetch-audited Node fallback.
|
||||
['main/jira/authenticated-request.ts', 1]
|
||||
])
|
||||
|
||||
// `globalThis.fetch` / `global.fetch` belong to global-fetch-call-site-audit.test.ts.
|
||||
// `\s*` before `(`: the formatter never emits `net.fetch (url)`, but an unformatted call must not
|
||||
// be a hole in a guard whose whole job is to fail on the call nobody reviewed.
|
||||
const FETCH_CALL = /\.fetch\s*\(/g
|
||||
const RECEIVER_IDENTIFIER = /(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*$/
|
||||
const DEFAULT_SESSION_RECEIVERS = new Set(['net', 'globalThis', 'global'])
|
||||
const NET_REQUEST_CALL = /(?<![.\w$])net\.(?:fetch|request)\s*\(/g
|
||||
// Matches `{ session: x }` and the `{ url, session }` shorthand both `net.request` overloads take.
|
||||
const SESSION_SCOPED_OPTION = /(?:^|[{,\s])(?:session|partition)\s*[:,}]/
|
||||
|
||||
/** Text between the call's parentheses, skipping string bodies so quoted parens don't unbalance. */
|
||||
function callArgumentText(content: string, callEnd: number): string {
|
||||
let depth = 0
|
||||
let quote: string | null = null
|
||||
for (let index = callEnd - 1; index < content.length; index += 1) {
|
||||
const char = content[index]!
|
||||
if (quote) {
|
||||
if (char === '\\') {
|
||||
index += 1
|
||||
} else if (char === quote) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === "'" || char === '"' || char === '`') {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
if (char === '(') {
|
||||
depth += 1
|
||||
} else if (char === ')') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return content.slice(callEnd, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return content.slice(callEnd)
|
||||
}
|
||||
|
||||
function auditedSourceFiles(mainRoot: string): { file: string; content: string }[] {
|
||||
const files: { file: string; content: string }[] = []
|
||||
for (const entry of readdirSync(mainRoot, { recursive: true, withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.ts')) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
entry.name.endsWith('.test.ts') ||
|
||||
entry.name.endsWith('.test-fixtures.ts') ||
|
||||
entry.name.endsWith('.d.ts')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const filePath = join(entry.parentPath, entry.name)
|
||||
files.push({
|
||||
file: `main/${relative(mainRoot, filePath).split(sep).join('/')}`,
|
||||
content: readFileSync(filePath, 'utf8')
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
describe('proxy-guarded fetch call-site audit (main)', () => {
|
||||
const sources = auditedSourceFiles(__dirname)
|
||||
|
||||
it('keeps every net.fetch/net.request on the guarded default session', () => {
|
||||
const offenders: string[] = []
|
||||
for (const { file, content } of sources) {
|
||||
for (const match of content.matchAll(NET_REQUEST_CALL)) {
|
||||
const args = callArgumentText(content, match.index + match[0].length)
|
||||
if (SESSION_SCOPED_OPTION.test(args)) {
|
||||
offenders.push(`${file}:${content.slice(0, match.index).split('\n').length}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
offenders.sort(),
|
||||
'This request names its own session/partition, so it is not covered by ' +
|
||||
'installElectronProxyRequestGuard(session.defaultSession) and startup never applies the ' +
|
||||
'persisted proxy to it. Either drop the option, or apply the proxy to that session ' +
|
||||
'yourself (see main/rate-limits/opencode-go-request-session.ts) and allowlist it here.'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps every non-default-session fetcher audited with its expected count', () => {
|
||||
const found = new Map<string, number>()
|
||||
for (const { file, content } of sources) {
|
||||
const hits = [...content.matchAll(FETCH_CALL)].filter((match) => {
|
||||
const receiver = RECEIVER_IDENTIFIER.exec(content.slice(0, match.index))?.[1]
|
||||
// A chained (`session.fromPartition(...).fetch(`) or member (`ctx.session.fetch(`)
|
||||
// receiver has no bare trailing identifier, and is never the default session.
|
||||
return receiver === undefined || !DEFAULT_SESSION_RECEIVERS.has(receiver)
|
||||
}).length
|
||||
if (hits > 0) {
|
||||
found.set(file, hits)
|
||||
}
|
||||
}
|
||||
|
||||
const drifted = [...found]
|
||||
.filter(([file, count]) => AUDITED_NON_NET_FETCH_CALLS.get(file) !== count)
|
||||
.map(([file, count]) => `${file}: found ${count} call(s)`)
|
||||
.sort()
|
||||
expect(
|
||||
drifted,
|
||||
'A session.fromPartition(...) session is not covered by ' +
|
||||
'installElectronProxyRequestGuard(session.defaultSession), so nothing holds its requests ' +
|
||||
'until the proxy lands and startup never applies the proxy to it. Apply the proxy to that ' +
|
||||
'session yourself (see main/rate-limits/opencode-go-request-session.ts), then update ' +
|
||||
'AUDITED_NON_NET_FETCH_CALLS.'
|
||||
).toEqual([])
|
||||
|
||||
const stale = [...AUDITED_NON_NET_FETCH_CALLS.keys()].filter((file) => !found.has(file)).sort()
|
||||
expect(stale, 'Remove audited entries whose .fetch( calls are gone.').toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -66,7 +66,12 @@ describe('startup ordering', () => {
|
||||
)
|
||||
expect(desktopStartup).toContain('recordRuntimeRpcStartFailure(')
|
||||
// Why: `void`, not `await` — awaiting the dialog would park the rest of startup behind a modal.
|
||||
expect(desktopStartup).toMatch(/void showRuntimeRpcStartupFailureDialog\(\s*win,/)
|
||||
// It chains off the i18n barrier (published before this phase starts) so the translated strings
|
||||
// it reads are loaded, which is a wait on i18n only, never on the dialog itself.
|
||||
expect(desktopStartup).toMatch(
|
||||
/void state\.mainProcessI18nReady\.then\(\(\) =>\s*showRuntimeRpcStartupFailureDialog\(\s*win,/
|
||||
)
|
||||
expect(desktopStartup).not.toMatch(/await[^\n]*showRuntimeRpcStartupFailureDialog\(/)
|
||||
// Why (#11025): a bare console.error here is exactly what left the CLI dead but the app healthy.
|
||||
expect(desktopStartup).not.toContain(
|
||||
"console.error('[runtime] Failed to start local RPC transport:'"
|
||||
|
||||
@@ -139,7 +139,22 @@ export async function initializeReadyFoundation(): Promise<void> {
|
||||
})
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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<void>((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<typeof initializeMainProcessReady>[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<typeof initializeMainProcessReady>[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('waits for i18n before the only launch-phase dialog that reads a translated string', () => {
|
||||
const ready = readStartupSource('main-process-ready.ts')
|
||||
const launch = readStartupSource('main-process-runtime-launch.ts')
|
||||
|
||||
// Published before the launch phase starts, or the barrier the dialog awaits is still the
|
||||
// default resolved promise.
|
||||
const publishIndex = ready.indexOf('state.mainProcessI18nReady = ')
|
||||
expect(publishIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(ready.indexOf('initializeMainProcessRuntimeLaunch(options)')).toBeGreaterThan(
|
||||
publishIndex
|
||||
)
|
||||
expect(launch).toMatch(
|
||||
/state\.mainProcessI18nReady\.then\(\(\) =>\s*\n?\s*showRuntimeRpcStartupFailureDialog\(/
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { initializeMainProcessI18nAndMenu } from './main-process-i18n-menu'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
import { initializeReadyFoundation } from './main-process-ready-foundation'
|
||||
import { initializeReadyRuntimeServices } from './main-process-ready-runtime'
|
||||
import {
|
||||
@@ -12,6 +13,10 @@ export async function initializeMainProcessReady(
|
||||
): Promise<void> {
|
||||
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).
|
||||
const i18nAndMenuReady = initializeMainProcessI18nAndMenu()
|
||||
state.mainProcessI18nReady = i18nAndMenuReady.catch(() => {})
|
||||
await Promise.all([i18nAndMenuReady, initializeMainProcessRuntimeLaunch(options)])
|
||||
}
|
||||
|
||||
@@ -120,6 +120,9 @@ async function launchServeMode(
|
||||
runtimeRpc: OrcaRuntimeRpcServer,
|
||||
serveOptions: NonNullable<ReturnType<typeof getServeOptions>>
|
||||
): Promise<void> {
|
||||
// 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
|
||||
@@ -226,8 +229,17 @@ async function launchDesktopMode(
|
||||
)
|
||||
])
|
||||
if (!runtimeRpcStartResult.ok) {
|
||||
void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error)
|
||||
// Why gated: this dialog is the only launch-phase text read through translateMain, and i18n
|
||||
// now settles alongside this phase — without the wait a non-English user could get the
|
||||
// English defaultValue fallback. Still off the renderer's path (it is failure-only).
|
||||
void state.mainProcessI18nReady.then(() =>
|
||||
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 {
|
||||
|
||||
@@ -100,6 +100,13 @@ 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(),
|
||||
// Why published: i18n/menu init no longer precedes the launch phase, so the one launch-phase
|
||||
// path that reads a translated string (the runtime-RPC startup failure dialog) waits on this.
|
||||
// Never rejects: the phase's own failure is surfaced by initializeMainProcessReady.
|
||||
mainProcessI18nReady: 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).
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,15 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Record<string, string | readonly string[]>>
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user