fix(electron): isolate runtime probes and reuse healthy fallback

This commit is contained in:
Palash Debnath
2026-09-17 21:06:12 +05:30
parent 7ed58cb5b7
commit 8a7ab51784
5 changed files with 79 additions and 10 deletions
+4
View File
@@ -32,3 +32,7 @@ modifying or taking ownership of the existing environment.
A new custom runtime destination remains selectable. Setup creates and owns it
only when that directory does not already exist; existing unowned roots stay
untouched even if their `project` subdirectory is missing.
Dependency checks ignore inherited `PYTHONPATH` and `PYTHONHOME`, matching backend
startup. If repair switches away from an unowned environment and a healthy
Electron runtime already exists, it is reused without reinstalling dependencies.
+29 -3
View File
@@ -433,9 +433,7 @@ it.each([true, false])(
mocks.existingProject = projectExists;
mocks.existingRoot = true;
mocks.ready.mockResolvedValue(true);
mocks.dependencies.mockImplementation(
async (project?: string) => !project?.includes('selected'),
);
mocks.dependencies.mockResolvedValue(false);
mocks.install.mockRejectedValue(new Error('offline'));
vi.stubGlobal(
'fetch',
@@ -481,3 +479,31 @@ it('installs into a newly selected custom destination that does not yet exist',
expect(mocks.runtimeConfig).toEqual({ root: selected, owned: true });
await supervisor.shutdown();
});
it('reuses a healthy default runtime after explicit setup leaves an unowned environment', async () => {
const { resolve, join } = await import('node:path');
mocks.runtimeConfig = { root: resolve('/selected/VoiceStudio'), owned: false };
mocks.existingRoot = true;
mocks.ready.mockResolvedValue(true);
mocks.dependencies.mockImplementation(async (project?: string) => !project?.includes('selected'));
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('no backend');
}),
);
vi.stubEnv('OMNIVOICE_BACKEND_CMD', '');
vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', '');
const supervisor = new BackendSupervisor();
await supervisor.start();
expect(supervisor.status.stage).toBe('setup_required');
const restart = vi.spyOn(supervisor, 'start').mockResolvedValue();
await supervisor.setupRuntime();
expect(mocks.install).not.toHaveBeenCalled();
expect(mocks.dependencies).toHaveBeenCalledWith(
join('/private/voicestudio', 'runtime', 'project'),
);
expect(restart).toHaveBeenCalledOnce();
restart.mockRestore();
await supervisor.shutdown();
});
+9
View File
@@ -563,6 +563,15 @@ export class BackendSupervisor extends EventEmitter<{
'Creating a separate Electron runtime; existing environment preserved.',
);
this.emitStatus();
const fallbackReusable =
((await runtimeReady(backendRoot(), project)) ||
(await runtimeCompatible(backendRoot(), project))) &&
(await runtimeDependenciesReady(project));
if (gen !== this.generation || controller.signal.aborted) return;
if (fallbackReusable) {
await this.start();
return;
}
}
if (
configured &&
+30 -1
View File
@@ -3,7 +3,10 @@ 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());
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});
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) => {
@@ -32,3 +35,29 @@ it.each([null, new Error('No module named uvicorn'), new Error('ETIMEDOUT'), new
);
},
);
it.each(['PYTHONPATH', 'PYTHONHOME'] as const)(
'isolates imports from inherited %s',
async (variable) => {
vi.stubEnv(variable, '/unrelated-python');
vi.mocked(execFile).mockImplementation(((
_command: unknown,
_args: unknown,
options: { env: NodeJS.ProcessEnv },
callback: (error: Error | null) => void,
) => {
const contaminated = Boolean(options.env[variable]);
callback(
variable === 'PYTHONPATH'
? contaminated
? null
: new Error('No module named uvicorn')
: contaminated
? new Error('invalid Python home')
: null,
);
}) as never);
expect(await runtimeDependenciesReady('/selected-runtime')).toBe(variable === 'PYTHONHOME');
expect(process.env[variable]).toBe('/unrelated-python');
},
);
+7 -6
View File
@@ -250,6 +250,12 @@ 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> {
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.PYTHONHOME;
delete env.PYTHONPATH;
env.HF_HUB_OFFLINE = '1';
env.TRANSFORMERS_OFFLINE = '1';
env.PYTHONNOUSERSITE = '1';
return new Promise((resolve) => {
execFile(
runtimePython(project),
@@ -259,12 +265,7 @@ export async function runtimeDependenciesReady(project: string): Promise<boolean
windowsHide: true,
timeout: 30_000,
maxBuffer: 256 * 1024,
env: {
...process.env,
HF_HUB_OFFLINE: '1',
TRANSFORMERS_OFFLINE: '1',
PYTHONNOUSERSITE: '1',
},
env,
},
(error) => resolve(!error),
);