diff --git a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts index c3c6857e8..395104730 100644 --- a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts +++ b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.test.ts @@ -28,6 +28,7 @@ */ import { Readable } from 'node:stream'; +import v8 from 'node:v8'; import { afterAll, afterEach, @@ -660,6 +661,28 @@ describe('GeminiTTSProvider.synthesize audio output', () => { expect(wav.subarray(44)).toEqual(pcm); }); + // V8 stops optimizing regexps once a process holds enough compiled code, + // as long-running servers do. + it('streams audio over 4 MB of base64 with regexp optimization off', async () => { + const pcm = Buffer.alloc(4 * 1024 * 1024, 7); + replyWith( + interaction({ content: [audioBlock(pcm, 'audio/L16;rate=24000')] }), + ); + + v8.setFlagsFromString('--no-regexp-optimization'); + let wav: Buffer; + try { + const result = (await withTestActor(() => + makeProvider().synthesize({ text: 'hi' }), + )) as { stream: Readable }; + wav = await readAll(result.stream); + } finally { + v8.setFlagsFromString('--regexp-optimization'); + } + expect(wav.length).toBe(44 + pcm.length); + expect(wav.subarray(44).equals(pcm)).toBe(true); + }); + it('wraps PCM without a declared rate at 24 kHz', async () => { replyWith( interaction({ diff --git a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts index 1d7295847..c4541aaa0 100644 --- a/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts +++ b/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts @@ -154,7 +154,17 @@ interface Interaction { const WAV_MIME_TYPES = new Set(['audio/wav', 'audio/x-wav', 'audio/wave']); const PCM_MIME_TYPES = new Set(['audio/l16', 'audio/pcm']); -const BASE64_PATTERN = /^[A-Za-z0-9+/_-]+={0,2}$/; +// Find a bad character instead of matching `^[...]+$`: when V8 compiles a +// regexp unoptimized, the anchored loop backtracks per character and overflows +// its stack on audio over ~4 MB. +const NON_BASE64_CHAR = /[^A-Za-z0-9+/_-]/; + +const isBase64 = (data: string): boolean => { + let end = data.length; + if (data.endsWith('==')) end -= 2; + else if (data.endsWith('=')) end -= 1; + return end > 0 && !NON_BASE64_CHAR.test(data.slice(0, end)); +}; const SPLIT_TEXT_HINT = 'Split the text into shorter requests.'; @@ -526,7 +536,7 @@ export class GeminiTTSProvider implements ITTSProvider { const { data, mime_type: mimeType } = audio; if ( typeof data !== 'string' || - !BASE64_PATTERN.test(data) || + !isBase64(data) || typeof mimeType !== 'string' ) { throw upstreamFailure('Gemini TTS returned invalid audio data'); diff --git a/src/backend/util/appIcon.test.ts b/src/backend/util/appIcon.test.ts index 86715c5c4..aa9b87797 100644 Binary files a/src/backend/util/appIcon.test.ts and b/src/backend/util/appIcon.test.ts differ diff --git a/src/backend/util/appIcon.ts b/src/backend/util/appIcon.ts index 1b288db7f..4af68fa60 100644 --- a/src/backend/util/appIcon.ts +++ b/src/backend/util/appIcon.ts @@ -71,7 +71,6 @@ export interface AppIconHostConfig extends TrustedIconHostConfig { pub_port?: number; } -const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; // `data:/[;param[=value]]…,`. The parameter list is // matched as a group of its own so it can be checked exhaustively — the // previous prefix-scan only looked at the bytes before the first `;` or `,` @@ -185,7 +184,6 @@ function sniffRawBase64Image( if (typeof value !== 'string') return null; const trimmed = value.trim(); if (trimmed.length < 16) return null; - if (!RAW_BASE64_REGEX.test(trimmed)) return null; const bytes = decodeStrictBase64(trimmed); if (!bytes) return null; const mime = sniffImageMime(bytes);