fix(electron): validate reused Python runtimes before startup

This commit is contained in:
Palash Debnath
2026-09-17 20:11:38 +05:30
parent a8581f899e
commit 834b305c05
6 changed files with 110 additions and 12 deletions
+2
View File
@@ -16,6 +16,8 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- Validate Python dependencies before reusing a desktop runtime and offer setup for incomplete environments (#2176)
- Repair CTranslate2 loading safely across ASR and translation, and retain the loaded Whisper model during CPU fallback (#2165) — thanks @guruthechosen!
- Avoid pedalboard wheels that crash on unsupported CPU instructions (#2080) — thanks @D3nii!
- Include cuDNN 8 compatibility libraries for CTranslate2 in CUDA containers (#2072) — thanks @basil-k-aji-dev!
+5
View File
@@ -18,3 +18,8 @@ permissions. No automatic installer-to-installer migration is provided.
The final Tauri updater feeds retain signed Tauri payloads at immutable URLs.
A Tauri updater must never receive an Electron installer.
Electron checks required Python imports before reusing an existing runtime. An
incomplete environment opens setup instead of repeatedly crashing; installation
still requires your explicit action. Automatic selection skips broken legacy
runtimes and uses the Electron runtime location, leaving Tauri data intact.
+28
View File
@@ -2,6 +2,7 @@
import { afterEach, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
const mocks = vi.hoisted(() => ({
dependencies: vi.fn(async () => true),
ready: vi.fn(async () => false),
compatible: vi.fn(async () => false),
interrupted: vi.fn(async () => false),
@@ -15,6 +16,7 @@ vi.mock('electron', () => ({ app: { isPackaged: true, getPath: () => '/private/v
vi.mock('node:child_process', () => ({ spawn: mocks.spawn, spawnSync: vi.fn() }));
vi.mock('node:fs/promises', () => ({ rm: mocks.rm }));
vi.mock('./runtime-project', () => ({
runtimeDependenciesReady: mocks.dependencies,
runtimeReady: mocks.ready,
runtimeCompatible: mocks.compatible,
runtimeInstallInterrupted: mocks.interrupted,
@@ -34,6 +36,9 @@ afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.clearAllMocks();
mocks.dependencies.mockResolvedValue(true);
mocks.ready.mockResolvedValue(false);
mocks.compatible.mockResolvedValue(false);
mocks.interrupted.mockResolvedValue(false);
mocks.promoteCaches.mockResolvedValue(undefined);
});
@@ -364,3 +369,26 @@ it('reserves setup before asynchronous checks and cancels stale preflight', asyn
expect(mocks.install).not.toHaveBeenCalled();
expect(supervisor.status.stage).toBe('idle');
});
it.each(['ready', 'compatible'] as const)(
'offers setup instead of spawning a %s runtime with missing dependencies',
async (kind) => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('no backend');
}),
);
vi.stubEnv('OMNIVOICE_BACKEND_CMD', '');
vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', '');
mocks[kind].mockResolvedValue(true);
mocks.dependencies.mockResolvedValue(false);
const supervisor = new BackendSupervisor();
await supervisor.start();
expect(supervisor.status.stage).toBe('setup_required');
expect(mocks.spawn).not.toHaveBeenCalled();
expect(mocks.install).not.toHaveBeenCalled();
expect(mocks.stage).not.toHaveBeenCalled();
await supervisor.shutdown();
},
);
+17 -12
View File
@@ -2,6 +2,7 @@ import {
installRuntime,
promoteLegacyRuntimeCaches,
runtimeCompatible,
runtimeDependenciesReady,
runtimeInstallInterrupted,
runtimeReady,
runtimePython,
@@ -482,11 +483,8 @@ export class BackendSupervisor extends EventEmitter<{
}
if (app.isPackaged && !parseBackendCmdOverride(process.env.OMNIVOICE_BACKEND_CMD)) {
const project = await this.resolveRuntimeProject();
if (
!(await runtimeReady(backendRoot(), project)) &&
!(await runtimeCompatible(backendRoot(), project))
) {
const { project, ready } = await this.resolveRuntimeProject();
if (!ready) {
if (gen === this.generation) {
this.runtimeInterrupted = await runtimeInstallInterrupted(project);
this.setStage('setup_required');
@@ -537,8 +535,9 @@ export class BackendSupervisor extends EventEmitter<{
this.setStage('installing', { message: undefined });
try {
const reusable =
(await runtimeReady(backendRoot(), project)) ||
(await runtimeCompatible(backendRoot(), project));
((await runtimeReady(backendRoot(), project)) ||
(await runtimeCompatible(backendRoot(), project))) &&
(await runtimeDependenciesReady(project));
if (gen !== this.generation || controller.signal.aborted) return;
if (reusable) {
await this.start();
@@ -797,7 +796,7 @@ export class BackendSupervisor extends EventEmitter<{
this.emitStatus();
}
private async resolveRuntimeProject(): Promise<string> {
private async resolveRuntimeProject(): Promise<{ project: string; ready: boolean }> {
const bundle = backendRoot();
const own = join(defaultRuntimeRoot(), 'project');
const configuredRoot = storedRuntimeRoot();
@@ -809,15 +808,18 @@ export class BackendSupervisor extends EventEmitter<{
...legacyTauriRuntimeProjects(),
].filter((candidate): candidate is string => Boolean(candidate));
for (const project of new Set(candidates.map((candidate) => resolve(candidate)))) {
if ((await runtimeReady(bundle, project)) || (await runtimeCompatible(bundle, project))) {
if (
((await runtimeReady(bundle, project)) || (await runtimeCompatible(bundle, project))) &&
(await runtimeDependenciesReady(project))
) {
this.runtimeProject = project;
if (project !== own && project !== configured)
this.pushLog('out', `Reusing compatible Tauri runtime: ${project}`);
return project;
return { project, ready: true };
}
}
this.runtimeProject = configured ?? own;
return this.runtimeProject;
return { project: this.runtimeProject, ready: false };
}
private emitStatus(): void {
@@ -889,7 +891,10 @@ export class BackendSupervisor extends EventEmitter<{
// Python passes this child-side descriptor to every nested operation.
// Reading the parent side keeps the ownership channel live and lets
// Node observe EOF only after the complete backend subtree releases it.
const drain = child.stdio?.[processOptions.drainFd] as NodeJS.ReadableStream | null | undefined;
const drain = child.stdio?.[processOptions.drainFd] as
| NodeJS.ReadableStream
| null
| undefined;
drain?.on('error', (error: unknown) => {
if (!isExpectedPipeClose(error)) {
this.pushLog('err', `Backend drain stream failed: ${errorMessage(error)}`);
@@ -0,0 +1,34 @@
// @vitest-environment node
import { afterEach, expect, it, vi } from 'vitest';
import { execFile } from 'node:child_process';
import { runtimeDependenciesReady, runtimePython } from './runtime-project';
vi.mock('node:child_process', () => ({ execFile: vi.fn() }));
afterEach(() => vi.clearAllMocks());
it.each([null, new Error('No module named uvicorn'), new Error('ETIMEDOUT'), new Error('ENOENT')])(
'validates imports using the selected interpreter and fails closed (%s)',
async (error) => {
vi.mocked(execFile).mockImplementation(((
_command: unknown,
_args: unknown,
_options: unknown,
callback: (error: Error | null) => void,
) => callback(error)) as never);
const project = '/runtime with spaces';
expect(await runtimeDependenciesReady(project)).toBe(error === null);
expect(execFile).toHaveBeenCalledWith(
runtimePython(project),
['-c', 'import fastapi, uvicorn, omnivoice, faster_whisper'],
expect.objectContaining({
cwd: project,
timeout: 30_000,
windowsHide: true,
env: expect.objectContaining({
HF_HUB_OFFLINE: '1',
TRANSFORMERS_OFFLINE: '1',
PYTHONNOUSERSITE: '1',
}),
}),
expect.any(Function),
);
},
);
+24
View File
@@ -1,3 +1,4 @@
import { execFile } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import {
cp,
@@ -247,6 +248,29 @@ export async function runtimeInstallInterrupted(project: string): Promise<boolea
}
}
/** Check the selected interpreter locally before trusting runtime metadata. */
export async function runtimeDependenciesReady(project: string): Promise<boolean> {
return new Promise((resolve) => {
execFile(
runtimePython(project),
['-c', 'import fastapi, uvicorn, omnivoice, faster_whisper'],
{
cwd: project,
windowsHide: true,
timeout: 30_000,
maxBuffer: 256 * 1024,
env: {
...process.env,
HF_HUB_OFFLINE: '1',
TRANSFORMERS_OFFLINE: '1',
PYTHONNOUSERSITE: '1',
},
},
(error) => resolve(!error),
);
});
}
export async function runtimeReady(bundle: string, project: string): Promise<boolean> {
if (await runtimeIncomplete(project)) return false;
try {