diff --git a/CHANGELOG.md b/CHANGELOG.md index 923f630..9020966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ sidecar protocol bumps the minor. leave a `-e2e-profile-*` directory behind. `pythonEnv.test.ts` now removes the directories it makes. +### Fixed +- The packaged sidecar runs from the managed env instead of the bundled project + directory. A working directory is an open handle that every process it spawns + inherits, so a sidecar rooted in the install directory kept a Windows update + from removing the old version — and the installer's app-running check could + not find the holder, because that check matches on executable path and the + interpreter lives in the env. The update dead-ended in "cannot be closed. + Please close it manually and click Retry", whose Retry re-runs the same check. + Nothing wanted that working directory: the app is installed into the env as a + wheel, and what it reads from the bundle it reads by absolute path. + ## [0.2.1] - 2026-09-02 Ground Crew's shell work from after the merge base, so it can move onto the diff --git a/de_shell/js/main/backendProcess.ts b/de_shell/js/main/backendProcess.ts index 950f154..f64a668 100644 --- a/de_shell/js/main/backendProcess.ts +++ b/de_shell/js/main/backendProcess.ts @@ -63,7 +63,8 @@ export function startBackend( stopping = false // fresh process — allow a future stopBackend() to run const [cmd, ...args] = pythonCmd const child = spawn(cmd, args, { - cwd, // run from the project root so `uv run` finds the app's pyproject.toml + cwd, // dev: the repo root, so `uv run` finds the app's pyproject.toml. + // Packaged: the managed env — never the install dir (see pythonEnv). // APL_BINARY_TRANSPORT=1: anyplotlib ships large image pixels as raw PLOTBIN // binary frames (no base64/JSON) which this runner demuxes — see the stdout // parser below. Verified end-to-end (pixel-correct via GPU readback); cuts the diff --git a/de_shell/js/main/pythonEnv.test.ts b/de_shell/js/main/pythonEnv.test.ts index bff50f4..fd2ecef 100644 --- a/de_shell/js/main/pythonEnv.test.ts +++ b/de_shell/js/main/pythonEnv.test.ts @@ -7,16 +7,25 @@ * an opaque `spawn uv ENOENT`. `findUv` resolves uv to an absolute path up * front: the PATH itself first, then the standard per-user install locations. * - * Run: `node --test src/pythonEnv.test.ts` (from packages/shell-main/), or via - * the `test:unit` npm script. + * The packaged branch has a second contract, and it is a Windows one: the + * sidecar must not RUN OUT OF the install directory. A working directory is an + * open handle that every process it spawns inherits, so a sidecar rooted there + * keeps the installer from removing the old version — while staying invisible + * to the app-running check, which can only match on executable path and finds + * this interpreter in the managed env instead. That dead-ended a Windows + * auto-update in "cannot be closed. Please close it manually and click Retry". + * + * Run: `node --test de_shell/js/main/pythonEnv.test.ts`, or via the `test:unit` + * npm script. */ import { test, after } from 'node:test' import assert from 'node:assert/strict' import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs' -import { join } from 'path' +import { dirname, join } from 'path' import { tmpdir } from 'os' +import { createHash } from 'crypto' import { configureShell } from './config.ts' -import { findUv, resolvePythonEnv } from './pythonEnv.ts' +import { findUv, resolvePythonEnv, venvPython } from './pythonEnv.ts' configureShell({ appId: 'testapp', @@ -135,3 +144,50 @@ test('dev-mode resolvePythonEnv leaves a bare uv when none is found (the spawn t } } }) + +/** A packaged layout whose managed env already matches the shipped lock, so + * resolvePythonEnv() answers from disk without shelling out to uv. */ +function packagedLayout(): { resourcesPath: string; userData: string; envDir: string } { + const root = mkdtempSync(join(tmpdir(), 'packaged-')) + const resourcesPath = join(root, 'resources') + const userData = join(root, 'userData') + const projectDir = join(resourcesPath, 'python') + const envDir = join(userData, 'python-env') + const lock = 'version = 1\n' + + mkdirSync(projectDir, { recursive: true }) + writeFileSync(join(projectDir, 'uv.lock'), lock) + + const python = venvPython(envDir) + mkdirSync(dirname(python), { recursive: true }) + writeFileSync(python, '') + writeFileSync( + join(envDir, '.testapp-lock-hash'), + createHash('sha256').update(Buffer.from(lock)).digest('hex'), + ) + return { resourcesPath, userData, envDir } +} + +test('packaged resolvePythonEnv does not run out of the install directory', async () => { + const { resourcesPath, userData } = packagedLayout() + + const resolved = await resolvePythonEnv({ + isPackaged: true, resourcesPath, projectRoot: '/unused', userData, + }) + + assert.ok( + !resolved.cwd.startsWith(resourcesPath), + `cwd ${resolved.cwd} is inside the install directory ${resourcesPath}`, + ) +}) + +test('packaged resolvePythonEnv runs from the managed env with the env interpreter', async () => { + const { resourcesPath, userData, envDir } = packagedLayout() + + const resolved = await resolvePythonEnv({ + isPackaged: true, resourcesPath, projectRoot: '/unused', userData, + }) + + assert.equal(resolved.cwd, envDir) + assert.deepEqual(resolved.cmd, [venvPython(envDir), '-m', 'testapp']) +}) diff --git a/de_shell/js/main/pythonEnv.ts b/de_shell/js/main/pythonEnv.ts index 1e534bb..b1a9ebf 100644 --- a/de_shell/js/main/pythonEnv.ts +++ b/de_shell/js/main/pythonEnv.ts @@ -171,7 +171,15 @@ export async function resolvePythonEnv(opts: EnsureOptions): Promise