fix(electron): isolate runtime probes and reuse healthy fallback
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user