From e472ecc3d22ae16ae23be6ceef8ef10ae44f5f06 Mon Sep 17 00:00:00 2001 From: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:18:32 +0800 Subject: [PATCH] fix(launcher): accept CRLF Python runtime records Signed-off-by: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> --- scripts/loopx-python.sh | 2 + .../python_record_line_endings.test.ts | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tests/control_plane_ts/python_record_line_endings.test.ts diff --git a/scripts/loopx-python.sh b/scripts/loopx-python.sh index c000a1a64a..c7e31a4dff 100644 --- a/scripts/loopx-python.sh +++ b/scripts/loopx-python.sh @@ -53,6 +53,8 @@ select_loopx_python() { authoritative=1 elif [ -f "${REPO_ROOT}/.loopx-python" ]; then IFS= read -r configured_python <"${REPO_ROOT}/.loopx-python" + # Installer records can use Windows CRLF; the CR is not part of the path. + configured_python="${configured_python%$'\r'}" fi if [ -n "${configured_python}" ]; then diff --git a/tests/control_plane_ts/python_record_line_endings.test.ts b/tests/control_plane_ts/python_record_line_endings.test.ts new file mode 100644 index 0000000000..cf982f2a54 --- /dev/null +++ b/tests/control_plane_ts/python_record_line_endings.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { resolveTestPython } from "../../scripts/test-python.mjs"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const shellPath = (path: string) => process.platform === "win32" + ? path.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive: string) => `/${drive.toLowerCase()}`) + : path; +const quote = (value: string) => `'${value.replace(/'/g, `'"'"'`)}'`; + +for (const [name, ending, recordedWorks, explicit] of [ + ["LF record", "\n", true, ""], + ["CRLF record", "\r\n", true, ""], + ["record without final newline", "", true, ""], + ["stale CRLF record", "\r\n", false, ""], + ["explicit runtime precedes record", "\r\n", true, "valid"], + ["invalid explicit runtime stops discovery", "\r\n", true, "missing"], +] as const) { + test(`recorded Python: ${name}`, t => { + const directory = mkdtempSync(join(tmpdir(), "loopx-python-record-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const scripts = join(directory, "scripts"); + mkdirSync(scripts); + copyFileSync(join(root, "scripts/loopx-python.sh"), join(scripts, "loopx-python.sh")); + const selected = join(directory, "recorded runtime with spaces"); + const fallback = join(directory, ".venv/bin/python"); + mkdirSync(join(directory, ".venv/bin"), { recursive: true }); + const python = resolveTestPython(); + const override = join(directory, "explicit runtime"); + for (const [path, label] of [[selected, "recorded"], [fallback, "fallback"], [override, "explicit"]]) { + writeFileSync(path, `#!/bin/sh\nexport LOOPX_SELECTED=${label}\nexec ${quote(shellPath(python))} "$@"\n`, { mode: 0o755 }); + } + writeFileSync(join(directory, ".loopx-python"), shellPath(recordedWorks ? selected : selected + "-missing") + ending); + // Resolve Bash before restricting the child's PATH, including on Git Bash. + const discovered = spawnSync("bash", ["-c", process.platform === "win32" + ? 'cygpath -w "$(command -v bash)"' : "command -v bash"], { encoding: "utf8" }); + assert.equal(discovered.status, 0, discovered.stderr); + const env: NodeJS.ProcessEnv = { ...process.env, HOME: shellPath(directory), PATH: "/usr/bin:/bin" }; + delete env.LOOPX_PYTHON; + if (explicit) env.LOOPX_PYTHON = shellPath(explicit === "valid" ? override : override + "-missing"); + const result = spawnSync(discovered.stdout.trim(), [join(scripts, "loopx-python.sh"), "--exec", "-c", + "import json,os,sys; print(json.dumps([os.environ['LOOPX_SELECTED'],sys.executable]))"], { + cwd: directory, env, encoding: "utf8", timeout: 10_000, + }); + if (explicit === "missing") { + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /LOOPX_PYTHON is set but does not resolve/); + } else { + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout.trim()), [explicit ? "explicit" : recordedWorks ? "recorded" : "fallback", python]); + if (recordedWorks) assert.equal(result.stderr, ""); + else assert.match(result.stderr, /Ignoring non-functional Python recorded/); + } + }); +}