diff --git a/app/backend-cli.js b/app/backend-cli.js index c0681c45..ea64c9de 100644 --- a/app/backend-cli.js +++ b/app/backend-cli.js @@ -19,18 +19,42 @@ const path = require('path'); const { spawn: _spawnRaw } = require('child_process'); -// Wrap spawn so every backend / ollama launch defaults to windowsHide:true. +// Wrap spawn so every backend / ollama launch defaults to windowsHide:true +// AND PYTHONUNBUFFERED:1. // The PyInstaller backend (stenoai.exe) and bundled ollama.exe are console -// subsystem binaries; without this Electron pops a visible console window on -// Windows for every recording, live-transcribe, query, and the long-lived -// `ollama serve` keeps one open for the whole session. No-op on macOS/Linux. -// Callers can still override by passing an explicit windowsHide. +// subsystem binaries; without windowsHide, Electron pops a visible console +// window on Windows for every recording, live-transcribe, query, and the +// long-lived `ollama serve` keeps one open for the whole session. No-op on +// macOS/Linux. +// PYTHONUNBUFFERED matters because stdout/stderr are piped (not a TTY) here, +// so Python defaults to block-buffering them -- a logger.info() call can sit +// unflushed for many minutes on a long operation (a multi-hour recording's +// ffmpeg preprocessing/diarization/transcription), making the pipeline look +// hung even while it's genuinely working, and starving the inactivity +// watchdog (TRANSCRIBE_INACTIVITY_MS) of the HEARTBEAT:/log lines it needs to +// tell real silence from buffered-but-alive. Harmless for non-Python +// binaries (ollama/ffmpeg) -- just an unused env var. +// Callers can still override either by passing an explicit windowsHide/env. function spawn(command, args, options) { + const unbufferedEnv = (existingEnv) => ({ + ...require('process').env, + PYTHONUNBUFFERED: '1', + ...(existingEnv || {}), + }); if (Array.isArray(args) || args === undefined || args === null) { - return _spawnRaw(command, args, { windowsHide: true, ...(options || {}) }); + const opts = options || {}; + return _spawnRaw(command, args, { + windowsHide: true, + ...opts, + env: unbufferedEnv(opts.env), + }); } - // 2-arg form: spawn(command, options) - return _spawnRaw(command, { windowsHide: true, ...args }); + // 2-arg form: spawn(command, options) -- `args` IS the options object here. + return _spawnRaw(command, { + windowsHide: true, + ...args, + env: unbufferedEnv(args.env), + }); } // Terminate a process AND its child processes. On Windows `process.kill(pid)` @@ -152,7 +176,14 @@ function createBackendCli({ if (code === 0) { resolve(stdout); } else { - reject(new Error(`Python script failed with code ${code}: ${stderr}`)); + const err = new Error(`Python script failed with code ${code}: ${stderr}`); + // Callers (see parsePythonFailureJson in main.js) recover a graceful + // {"success": false, "error": ...} a CLI command printed to stdout + // right before exiting non-zero -- without these, that message is + // unreachable and every failure looks like a generic crash. + err.stdout = stdout; + err.stderr = stderr; + reject(err); } }); diff --git a/app/backend-cli.test.js b/app/backend-cli.test.js index 7e97df9f..61db92b5 100644 --- a/app/backend-cli.test.js +++ b/app/backend-cli.test.js @@ -49,28 +49,42 @@ function recordingDeps(extra = {}) { // ---- spawn wrapper ------------------------------------------------------- -test('spawn defaults windowsHide:true for the (command, args[]) form', () => { +test('spawn defaults windowsHide:true and PYTHONUNBUFFERED:1 for the (command, args[]) form', () => { spawn('backend', ['a', 'b']); assert.deepStrictEqual(spawnCalls[0][0], 'backend'); assert.deepStrictEqual(spawnCalls[0][1], ['a', 'b']); - assert.deepStrictEqual(spawnCalls[0][2], { windowsHide: true }); + assert.strictEqual(spawnCalls[0][2].windowsHide, true); + assert.strictEqual(spawnCalls[0][2].env.PYTHONUNBUFFERED, '1'); }); -test('spawn lets a caller override windowsHide', () => { - spawn('backend', ['a'], { windowsHide: false, cwd: '/x' }); - assert.deepStrictEqual(spawnCalls[0][2], { windowsHide: false, cwd: '/x' }); +test('spawn lets a caller override windowsHide, and merges (not replaces) env', () => { + spawn('backend', ['a'], { windowsHide: false, cwd: '/x', env: { FOO: 'bar' } }); + const opts = spawnCalls[0][2]; + assert.strictEqual(opts.windowsHide, false); + assert.strictEqual(opts.cwd, '/x'); + assert.strictEqual(opts.env.FOO, 'bar'); + assert.strictEqual(opts.env.PYTHONUNBUFFERED, '1'); +}); + +test('spawn lets a caller override PYTHONUNBUFFERED itself', () => { + spawn('backend', ['a'], { env: { PYTHONUNBUFFERED: '0' } }); + assert.strictEqual(spawnCalls[0][2].env.PYTHONUNBUFFERED, '0'); }); test('spawn handles the 2-arg (command, options) form', () => { spawn('backend', { cwd: '/y' }); // Collapsed to the options-object overload; windowsHide defaulted in. - assert.deepStrictEqual(spawnCalls[0][1], { windowsHide: true, cwd: '/y' }); + const opts = spawnCalls[0][1]; + assert.strictEqual(opts.windowsHide, true); + assert.strictEqual(opts.cwd, '/y'); + assert.strictEqual(opts.env.PYTHONUNBUFFERED, '1'); }); test('spawn defaults options when args is null/undefined', () => { spawn('backend'); assert.deepStrictEqual(spawnCalls[0][1], undefined); - assert.deepStrictEqual(spawnCalls[0][2], { windowsHide: true }); + assert.strictEqual(spawnCalls[0][2].windowsHide, true); + assert.strictEqual(spawnCalls[0][2].env.PYTHONUNBUFFERED, '1'); }); // ---- killProcessTree ----------------------------------------------------- @@ -147,8 +161,8 @@ test('runPythonScript (non-silent) sanitizes the echoed argv and streams output' // The spawned argv is untouched; only the LOGGED echo is sanitized. assert.deepStrictEqual(spawnCalls[0][1], ['create-folder', 'secret']); assert.ok(rec.debug.includes('$ stenoai SANITIZED')); - // No extraEnv -> env is left undefined (inherit parent). - assert.strictEqual(spawnCalls[0][2].env, undefined); + // No extraEnv -> spawn()'s own PYTHONUNBUFFERED default is all that's set. + assert.strictEqual(spawnCalls[0][2].env.PYTHONUNBUFFERED, '1'); stubChild.emit('stdout', 'data', Buffer.from('one\ntwo')); assert.deepStrictEqual(rec.forwarded, [ diff --git a/app/e2e-mock-ipc.js b/app/e2e-mock-ipc.js index 1ed12fb5..4a62f281 100644 --- a/app/e2e-mock-ipc.js +++ b/app/e2e-mock-ipc.js @@ -23,6 +23,11 @@ const fs = require('fs'); const { EXPORT_CANCELED } = require('./ipc-sentinels'); +// A real (silent, zero-sample) 16-bit mono 16kHz WAV file's bytes, +// base64-encoded -- valid enough for the renderer's blob: URL +