Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ sidecar protocol bumps the minor.
leave a `<appId>-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
Expand Down
3 changes: 2 additions & 1 deletion de_shell/js/main/backendProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 60 additions & 4 deletions de_shell/js/main/pythonEnv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'])
})
10 changes: 9 additions & 1 deletion de_shell/js/main/pythonEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,15 @@ export async function resolvePythonEnv(opts: EnsureOptions): Promise<ResolvedPyt
} catch { /* a missing stamp just forces a re-sync next launch */ }
}

return { cmd: [pythonExe, '-m', cfg.pythonModule], cwd: bundledProject }
// Run from the ENV, not the bundled project: a working directory is an open
// handle on that directory, and every process the sidecar spawns inherits it.
// Rooted in the install directory, that handle is what stops a Windows update
// from removing the old version — and the installer cannot even find the
// holder, because an app-running check scans for processes whose EXECUTABLE
// is under the install directory and this interpreter lives out here. Nothing
// wants the project directory: the app is installed into the env as a wheel,
// and what it reads from the bundle it reads by absolute path.
return { cmd: [pythonExe, '-m', cfg.pythonModule], cwd: envDir }
}

function readSafe(p: string): string {
Expand Down
Loading