From 18a594a92d47aa6e3d9dba5d3d30a6a24684a2b6 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 7 Oct 2026 16:56:52 -0500 Subject: [PATCH] fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418) --- electron/local-shell.cjs | 64 ++++++++++++++++--- electron/main.cjs | 39 +++++++---- .../tests/electron/local-shell.test.ts | 52 ++++++++++++++- 3 files changed, 132 insertions(+), 23 deletions(-) diff --git a/electron/local-shell.cjs b/electron/local-shell.cjs index ac633fec7..bbf965fb2 100644 --- a/electron/local-shell.cjs +++ b/electron/local-shell.cjs @@ -1,4 +1,16 @@ -function resolveLocalShell(platform, requestedShell, env = process.env) { +const fs = require("node:fs"); +const path = require("node:path"); + +function isUsableShell(file, exists) { + return !!file && (!path.isAbsolute(file) || exists(file)); +} + +function resolveLocalShell( + platform, + requestedShell, + env = process.env, + exists = fs.existsSync, +) { if (platform === "win32") { if (requestedShell === "wsl") { return { file: "wsl.exe", args: [] }; @@ -9,13 +21,47 @@ function resolveLocalShell(platform, requestedShell, env = process.env) { }; } - return { - file: - env.TERMIX_LOCAL_SHELL || - env.SHELL || - (platform === "darwin" ? "/bin/zsh" : "/bin/bash"), - args: ["-l"], - }; + // A SHELL left pointing at an uninstalled shell makes the spawn fail. + const fallbacks = + platform === "darwin" + ? ["/bin/zsh", "/bin/bash", "/bin/sh"] + : ["/bin/bash", "/bin/sh"]; + const file = + [env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find((candidate) => + isUsableShell(candidate, exists), + ) || fallbacks[0]; + return { file, args: ["-l"] }; } -module.exports = { resolveLocalShell }; +/** + * node-pty starts every shell through its spawn-helper binary, and a packaged + * copy that lost its execute bit fails with "posix_spawnp failed". Returns + * how many helpers were fixed. + */ +function repairSpawnHelpers(ptyDir, fsImpl = fs) { + const dir = ptyDir.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked"); + const candidates = [ + path.join(dir, "build", "Release", "spawn-helper"), + path.join( + dir, + "prebuilds", + `${process.platform}-${process.arch}`, + "spawn-helper", + ), + ]; + let fixed = 0; + for (const helper of candidates) { + try { + const mode = fsImpl.statSync(helper).mode; + if ((mode & 0o111) === 0) { + fsImpl.chmodSync(helper, mode | 0o755); + fixed += 1; + } + } catch { + // Missing or read-only; nothing to fix here. + } + } + return fixed; +} + +module.exports = { resolveLocalShell, repairSpawnHelpers }; diff --git a/electron/main.cjs b/electron/main.cjs index a42a28cce..ef89d4f5c 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -33,7 +33,7 @@ const { launchNativeRdp } = require("./native-rdp.cjs"); const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs"); const { quitApp } = require("./app-quit.cjs"); const { selectLinuxPasswordStore } = require("./linux-password-store.cjs"); -const { resolveLocalShell } = require("./local-shell.cjs"); +const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs"); const { registerLocalFileHandlers } = require("./local-files.cjs"); const localTerminalSessions = new Map(); @@ -3240,17 +3240,32 @@ ipcMain.handle("local-terminal-start", (event, dimensions = {}) => { const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24)); const sessionId = crypto.randomUUID(); const shellConfig = resolveLocalShell(process.platform, dimensions.shell); - const child = pty.spawn(shellConfig.file, shellConfig.args, { - name: "xterm-256color", - cols, - rows, - cwd: os.homedir(), - env: { - ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - }, - }); + const spawnShell = () => + pty.spawn(shellConfig.file, shellConfig.args, { + name: "xterm-256color", + cols, + rows, + cwd: os.homedir(), + env: { + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + }, + }); + let child; + try { + child = spawnShell(); + } catch (error) { + const ptyDir = path.dirname(require.resolve("node-pty/package.json")); + if ( + !String(error?.message).includes("posix_spawnp") || + repairSpawnHelpers(ptyDir) === 0 + ) { + throw error; + } + logToFile("[local-terminal] restored the spawn-helper execute bit"); + child = spawnShell(); + } const ownerId = event.sender.id; const session = { ownerId, process: child, ready: false, buffered: "" }; localTerminalSessions.set(sessionId, session); diff --git a/src/backend/tests/electron/local-shell.test.ts b/src/backend/tests/electron/local-shell.test.ts index d8f84c12d..1d085023e 100644 --- a/src/backend/tests/electron/local-shell.test.ts +++ b/src/backend/tests/electron/local-shell.test.ts @@ -2,13 +2,21 @@ import { createRequire } from "node:module"; import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); -const { resolveLocalShell } = +const { resolveLocalShell, repairSpawnHelpers } = require("../../../../electron/local-shell.cjs") as { resolveLocalShell: ( platform: NodeJS.Platform, requestedShell?: string, env?: NodeJS.ProcessEnv, + exists?: (file: string) => boolean, ) => { file: string; args: string[] }; + repairSpawnHelpers: ( + ptyDir: string, + fsImpl: { + statSync: (file: string) => { mode: number }; + chmodSync: (file: string, mode: number) => void; + }, + ) => number; }; describe("resolveLocalShell", () => { @@ -27,9 +35,49 @@ describe("resolveLocalShell", () => { }); it("preserves the configured shell on non-Windows platforms", () => { - expect(resolveLocalShell("linux", "wsl", { SHELL: "/bin/fish" })).toEqual({ + expect( + resolveLocalShell("linux", "wsl", { SHELL: "/bin/fish" }, () => true), + ).toEqual({ file: "/bin/fish", args: ["-l"], }); }); + + it("skips a configured shell that is not installed", () => { + const installed = new Set(["/bin/bash", "/bin/zsh"]); + expect( + resolveLocalShell( + "darwin", + undefined, + { SHELL: "/opt/homebrew/bin/fish" }, + (file) => installed.has(file), + ).file, + ).toBe("/bin/zsh"); + }); +}); + +describe("repairSpawnHelpers", () => { + it("restores the execute bit in the unpacked copy only when it is missing", () => { + const modes = new Map(); + const chmods: Array<[string, number]> = []; + const fsImpl = { + statSync: (file: string) => { + const mode = modes.get(file); + if (mode === undefined) throw new Error("ENOENT"); + return { mode }; + }, + chmodSync: (file: string, mode: number) => { + chmods.push([file, mode]); + }, + }; + const dir = + "/Applications/Termix.app/Contents/Resources/app.asar/node_modules/node-pty"; + const unpacked = dir.replace("app.asar", "app.asar.unpacked"); + const release = `${unpacked}/build/Release/spawn-helper`; + modes.set(release.split("/").join(require("node:path").sep), 0o644); + modes.set(release, 0o644); + expect(repairSpawnHelpers(dir, fsImpl)).toBe(1); + expect(chmods[0][1] & 0o111).not.toBe(0); + expect(chmods[0][0]).toContain("app.asar.unpacked"); + }); });