From 25c83796d87a8faa7986fdeffc2dc74daaede9aa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 10:11:48 -0700 Subject: [PATCH] fix: time out system font listing (#3786) --- src/main/system-fonts.test.ts | 47 +++++++++++++++++++++++++++++++++++ src/main/system-fonts.ts | 27 +++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/main/system-fonts.test.ts diff --git a/src/main/system-fonts.test.ts b/src/main/system-fonts.test.ts new file mode 100644 index 000000000..3f63d8930 --- /dev/null +++ b/src/main/system-fonts.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, killMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + killMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + +function expectedFallbackFont(): string { + if (process.platform === 'darwin') { + return 'SF Mono' + } + if (process.platform === 'win32') { + return 'Cascadia Mono' + } + return 'JetBrains Mono' +} + +describe('listSystemFontFamilies', () => { + afterEach(() => { + vi.useRealTimers() + vi.resetModules() + execFileMock.mockReset() + killMock.mockReset() + }) + + it('falls back when the platform font command never exits', async () => { + vi.useFakeTimers() + execFileMock.mockReturnValue({ kill: killMock }) + + const { listSystemFontFamilies } = await import('./system-fonts') + const fontsPromise = listSystemFontFamilies() + let resolvedFonts: string[] | null = null + fontsPromise.then((fonts) => { + resolvedFonts = fonts + }) + + await vi.advanceTimersByTimeAsync(60_000) + + expect(resolvedFonts).not.toBeNull() + expect(resolvedFonts).toContain(expectedFallbackFont()) + expect(killMock).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/system-fonts.ts b/src/main/system-fonts.ts index 7839f447a..e1b1b032c 100644 --- a/src/main/system-fonts.ts +++ b/src/main/system-fonts.ts @@ -2,6 +2,7 @@ import { execFile } from 'child_process' let cachedFonts: string[] | null = null let fontsPromise: Promise | null = null +const SYSTEM_FONT_LIST_TIMEOUT_MS = 15_000 export async function listSystemFontFamilies(): Promise { if (cachedFonts) { @@ -96,13 +97,37 @@ $fonts.Families | ForEach-Object { $_.Name } function execFileText(command: string, args: string[], maxBuffer: number): Promise { return new Promise((resolve, reject) => { - execFile(command, args, { encoding: 'utf8', maxBuffer }, (error, stdout) => { + let settled = false + let timer: ReturnType | undefined + const child = execFile(command, args, { encoding: 'utf8', maxBuffer }, (error, stdout) => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } if (error) { reject(error) return } resolve(stdout) }) + if (!settled) { + timer = setTimeout(() => { + if (settled) { + return + } + settled = true + // Why: font discovery is a startup convenience; a stuck OS font tool + // should fall back instead of keeping settings IPC pending forever. + child.kill() + reject(new Error(`Timed out listing system fonts with ${command}`)) + }, SYSTEM_FONT_LIST_TIMEOUT_MS) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + } }) }