diff --git a/config/scripts/renderer-boot-graph.mjs b/config/scripts/renderer-boot-graph.mjs index d99c5468e78..03cc9db7739 100644 --- a/config/scripts/renderer-boot-graph.mjs +++ b/config/scripts/renderer-boot-graph.mjs @@ -105,16 +105,16 @@ export function verifyRendererBootGraph(root = process.cwd()) { console.error(` ${chunk}`) } console.error('Escape them in the source (e.g. \\u2014 inside String.raw regex sources).') - return 1 } - if (violations.length === 0) { - return 0 + if (violations.length > 0) { + console.error('Payloads that must stay off the renderer boot graph are preloaded again:') + for (const violation of violations) { + console.error(` ${violation.label} -> ${violation.chunk}`) + } + console.error('') + console.error( + 'Load them after first paint (see primeTerminalWebglAddon) or from a route chunk.' + ) } - console.error('Payloads that must stay off the renderer boot graph are preloaded again:') - for (const violation of violations) { - console.error(` ${violation.label} -> ${violation.chunk}`) - } - console.error('') - console.error('Load them after first paint (see primeTerminalWebglAddon) or from a route chunk.') - return 1 + return twoByteChunks.length > 0 || violations.length > 0 ? 1 : 0 } diff --git a/config/scripts/renderer-boot-graph.test.mjs b/config/scripts/renderer-boot-graph.test.mjs new file mode 100644 index 00000000000..d4784af213a --- /dev/null +++ b/config/scripts/renderer-boot-graph.test.mjs @@ -0,0 +1,41 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { verifyRendererBootGraph } from './renderer-boot-graph.mjs' + +it.each([ + [false, false], + [true, false], + [false, true], + [true, true] +])('reports both boot guards independently (two-byte=%s, payload=%s)', (twoByte, payload) => { + const root = mkdtempSync(join(tmpdir(), 'orca-boot-guards-')) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + const renderer = join(root, 'out', 'renderer') + const i18n = join(root, 'src', 'renderer', 'src', 'i18n') + mkdirSync(join(renderer, 'assets'), { recursive: true }) + mkdirSync(join(i18n, 'locales'), { recursive: true }) + writeFileSync(join(i18n, 'locales', 'en.json'), JSON.stringify({ pruned: 'x'.repeat(50) })) + writeFileSync(join(i18n, 'en-runtime-required.json'), '{}') + writeFileSync( + join(renderer, 'index.html'), + '' + ) + writeFileSync( + join(renderer, 'assets', 'index.js'), + JSON.stringify(`${twoByte ? '\u2014' : ''}${payload ? 'WebGL2 not supported' : ''}`) + ) + + expect(verifyRendererBootGraph(root)).toBe(twoByte || payload ? 1 : 0) + const output = errors.mock.calls.flat().join('\n') + expect(output.includes('Boot chunks contain chars above U+00FF')).toBe(twoByte) + expect(output.includes('@xterm/addon-webgl -> index.js')).toBe(payload) + } finally { + errors.mockRestore() + log.mockRestore() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/daemon/macos-daemon-launchd.test.ts b/src/main/daemon/macos-daemon-launchd.test.ts index 981860aafd3..922a920c2ee 100644 --- a/src/main/daemon/macos-daemon-launchd.test.ts +++ b/src/main/daemon/macos-daemon-launchd.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import type * as TimerPromises from 'node:timers/promises' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { DaemonEndpointIdentity } from './daemon-hello-protocol' -import { DAEMON_V8_FLAGS } from './daemon-launched-child-spawn' import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' const { state, runProcessMock, materializeMock, ensureWithinMock, disconnectMock, recoverMock } = @@ -233,7 +232,7 @@ it('launches the copied helper on plain Node and leaves no inherited credentials : [] expect(args.slice(0, 5)).toEqual([ helperExec(), - ...DAEMON_V8_FLAGS, + '--max-semi-space-size=4', helperEntry(), '--socket', options.socketPath diff --git a/src/renderer/src/lib/monaco-ts-worker-idle-stop.test.ts b/src/renderer/src/lib/monaco-ts-worker-idle-stop.test.ts index 36238fba759..7e6e0c5aee6 100644 --- a/src/renderer/src/lib/monaco-ts-worker-idle-stop.test.ts +++ b/src/renderer/src/lib/monaco-ts-worker-idle-stop.test.ts @@ -1,30 +1,42 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { installMonacoTsWorkerIdleStop } from './monaco-ts-worker-idle-stop' +function createModel(languageId: string) { + return { getLanguageId: () => languageId } +} + function createFakeMonaco(languages: string[]) { - const listeners: (() => void)[] = [] + const models = languages.map(createModel) const defaults = () => ({ getCompilerOptions: vi.fn(() => ({ jsx: 1 })), setCompilerOptions: vi.fn() }) const typescriptDefaults = defaults() const javascriptDefaults = defaults() - const subscribe = (listener: () => void) => { - listeners.push(listener) - return { dispose: vi.fn() } - } + const onWillDisposeModel = vi.fn< + (listener: (model: ReturnType) => void) => { dispose(): void } + >(() => ({ dispose: vi.fn() })) + const onDidChangeModelLanguage = vi.fn< + (listener: (event: { model: ReturnType; oldLanguage: string }) => void) => { + dispose(): void + } + >(() => ({ dispose: vi.fn() })) const monaco = { - editor: { - getModels: () => languages.map((id) => ({ getLanguageId: () => id })), - onWillDisposeModel: subscribe, - onDidChangeModelLanguage: subscribe - }, + editor: { getModels: () => models, onWillDisposeModel, onDidChangeModelLanguage }, typescript: { typescriptDefaults, javascriptDefaults } } return { monaco, - languages, - listeners, + models, + disposeModel(model: ReturnType) { + onWillDisposeModel.mock.calls[0][0](model) + models.splice(models.indexOf(model), 1) + }, + changeLanguage(model: ReturnType, language: string) { + const oldLanguage = model.getLanguageId() + model.getLanguageId = () => language + onDidChangeModelLanguage.mock.calls[0][0]({ model, oldLanguage }) + }, typescriptDefaults, javascriptDefaults } @@ -33,27 +45,51 @@ function createFakeMonaco(languages: string[]) { afterEach(() => vi.useRealTimers()) describe('installMonacoTsWorkerIdleStop', () => { - it('stops the TS/JS workers once no TS/JS model remains for the idle delay', () => { + it('stops on the original deadline despite unrelated model events', () => { vi.useFakeTimers() - const fake = createFakeMonaco(['typescript']) + const fake = createFakeMonaco(['typescript', 'markdown', 'plaintext']) installMonacoTsWorkerIdleStop(fake.monaco, 1000) - fake.languages.splice(0) - fake.listeners[0]() - vi.advanceTimersByTime(999) + fake.disposeModel(fake.models[0]) + vi.advanceTimersByTime(500) + fake.disposeModel(fake.models[0]) + fake.changeLanguage(fake.models[0], 'markdown') + vi.advanceTimersByTime(499) expect(fake.typescriptDefaults.setCompilerOptions).not.toHaveBeenCalled() vi.advanceTimersByTime(1) expect(fake.typescriptDefaults.setCompilerOptions).toHaveBeenCalledWith({ jsx: 1 }) expect(fake.javascriptDefaults.setCompilerOptions).toHaveBeenCalledWith({ jsx: 1 }) }) - it('keeps the worker while a TS/JS model is still open', () => { + it('rechecks reopened TS/JS models and waits for their departure', () => { vi.useFakeTimers() - const fake = createFakeMonaco(['typescript', 'markdown']) + const fake = createFakeMonaco(['typescript']) installMonacoTsWorkerIdleStop(fake.monaco, 1000) - fake.listeners[0]() + fake.disposeModel(fake.models[0]) + fake.models.push(createModel('javascript')) vi.advanceTimersByTime(1000) expect(fake.typescriptDefaults.setCompilerOptions).not.toHaveBeenCalled() + fake.changeLanguage(fake.models[0], 'markdown') + vi.advanceTimersByTime(999) + expect(fake.javascriptDefaults.setCompilerOptions).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(fake.javascriptDefaults.setCompilerOptions).toHaveBeenCalledTimes(1) + }) + + it('cancels the pending stop and model subscriptions on cleanup', () => { + vi.useFakeTimers() + const fake = createFakeMonaco(['javascript']) + const dispose = installMonacoTsWorkerIdleStop(fake.monaco, 1000) + fake.disposeModel(fake.models[0]) + dispose() + vi.advanceTimersByTime(1000) + expect(fake.javascriptDefaults.setCompilerOptions).not.toHaveBeenCalled() + for (const event of [ + fake.monaco.editor.onWillDisposeModel, + fake.monaco.editor.onDidChangeModelLanguage + ]) { + expect(event.mock.results[0].value.dispose).toHaveBeenCalledOnce() + } }) }) diff --git a/src/renderer/src/lib/monaco-ts-worker-idle-stop.ts b/src/renderer/src/lib/monaco-ts-worker-idle-stop.ts index 87bab1977dc..2a977439c33 100644 --- a/src/renderer/src/lib/monaco-ts-worker-idle-stop.ts +++ b/src/renderer/src/lib/monaco-ts-worker-idle-stop.ts @@ -3,7 +3,8 @@ export const TS_WORKER_IDLE_STOP_MS = 60_000 const TS_LANGUAGE_IDS = new Set(['typescript', 'javascript']) -type ModelEvent = (listener: () => void) => { dispose(): void } +type Model = { getLanguageId(): string } +type ModelEvent = (listener: (event: Event) => void) => { dispose(): void } type LanguageDefaults = { getCompilerOptions(): Options setCompilerOptions(options: Options): void @@ -11,9 +12,9 @@ type LanguageDefaults = { /** The slice of the monaco namespace this reads; the real namespace satisfies it structurally. */ export type MonacoTsWorkerHost = { editor: { - getModels(): readonly { getLanguageId(): string }[] - onWillDisposeModel: ModelEvent - onDidChangeModelLanguage: ModelEvent + getModels(): readonly Model[] + onWillDisposeModel: ModelEvent + onDidChangeModelLanguage: ModelEvent<{ model: Model; oldLanguage: string }> } typescript: { typescriptDefaults: LanguageDefaults @@ -49,8 +50,16 @@ export function installMonacoTsWorkerIdleStop( } const subscriptions = [ - monaco.editor.onWillDisposeModel(schedule), - monaco.editor.onDidChangeModelLanguage(schedule) + monaco.editor.onWillDisposeModel((model) => { + if (TS_LANGUAGE_IDS.has(model.getLanguageId())) { + schedule() + } + }), + monaco.editor.onDidChangeModelLanguage(({ model, oldLanguage }) => { + if (TS_LANGUAGE_IDS.has(oldLanguage) && !TS_LANGUAGE_IDS.has(model.getLanguageId())) { + schedule() + } + }) ] return () => { if (timer !== null) {