Skip to content
Merged
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
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
4 changes: 4 additions & 0 deletions upcoming_changes/4.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
The packaged sidecar runs from its Python environment rather than the install
directory, so a Windows update can remove the old version. Its working
directory used to hold the install directory open, and the update stalled at
"cannot be closed. Please close it manually and click Retry".
Loading