fix(electron): validate reused Python runtimes before startup
This commit is contained in:
@@ -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!
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user