diff --git a/docs/user/install.md b/docs/user/install.md index 2a7e7dc7e749..6df863567499 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -53,6 +53,18 @@ node apps/server/dist/bin.mjs `t3 update` and the background service do not apply to a server run this way; update it with `git pull` and a rebuild. +### Oracle Linux (UEK8) + +Oracle's UEK8 kernel refuses to run the `t3` CLI (`ENOEXEC` / "Exec format +error"). The CLI is a Node single-executable whose embedded JavaScript is a +`PT_NOTE` larger than 4 MB, and UEK8 rejects notes that size. The desktop app +is unaffected. + +Boot Oracle's RHCK (or any mainline-based kernel) instead of UEK8, patch the +installed binary's `PT_NOTE` `p_filesz` down to 4 MB (reapply after every +update), or build from source the same way as Intel Macs above and run +`node apps/server/dist/bin.mjs`. + ## Desktop app Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), diff --git a/packages/shared/src/legacyCliLauncher.test.ts b/packages/shared/src/legacyCliLauncher.test.ts index b6630f468a83..332f639e217b 100644 --- a/packages/shared/src/legacyCliLauncher.test.ts +++ b/packages/shared/src/legacyCliLauncher.test.ts @@ -6,7 +6,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { expect, it } from "vite-plus/test"; -import { legacyCliLauncherScript } from "./legacyCliLauncher.ts"; +import { legacyCliLauncherScript, linuxCliExecFormatErrorHint } from "./legacyCliLauncher.ts"; // oxlint-disable-next-line t3code/no-global-process-runtime -- This test launches a real host executable. const hostPlatform = NodeOS.platform(); @@ -60,3 +60,41 @@ process.send({ args: process.argv.slice(2) }); } }, ); + +it.skipIf(hostPlatform !== "linux")( + "hints at UEK8 when the platform executable cannot exec", + async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-legacy-enoexec-")); + const entry = NodePath.join(root, "node_modules/t3/dist/bin.mjs"); + const executable = NodePath.join( + root, + `node_modules/@t3code/t3-${hostPlatform}-${hostArch}/t3`, + ); + await NodeFSP.mkdir(NodePath.dirname(entry), { recursive: true }); + await NodeFSP.mkdir(NodePath.dirname(executable), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(NodePath.dirname(executable), "package.json"), + '{"type":"commonjs"}', + ); + await NodeFSP.writeFile(entry, legacyCliLauncherScript()); + await NodeFSP.writeFile(executable, "#!/bin/sh\nexit 126\n"); + await NodeFSP.chmod(executable, 0o755); + const child = NodeChildProcess.fork(entry, ["--version"], { silent: true }); + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + try { + const [code] = await NodeEvents.EventEmitter.once(child, "exit"); + expect(code).toBe(126); + expect(stderr).toContain(linuxCliExecFormatErrorHint); + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exit = NodeEvents.EventEmitter.once(child, "exit"); + child.kill("SIGTERM"); + await exit; + } + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/packages/shared/src/legacyCliLauncher.ts b/packages/shared/src/legacyCliLauncher.ts index b852e2310dba..5cc0c2d0284a 100644 --- a/packages/shared/src/legacyCliLauncher.ts +++ b/packages/shared/src/legacyCliLauncher.ts @@ -10,7 +10,24 @@ * Remove it once no supported release predates the executable (after the * first stable release that ships it). */ + +/** + * UEK8's `load_elf_binary()` returns ENOEXEC when a PT_NOTE exceeds 4 MB + * (`MAX_FILE_NOTE_SIZE`). The CLI is a Node SEA, so `NODE_SEA_BLOB` is large + * by design. The kernel only checks the PT_NOTE program header `p_filesz`, + * not the note payload: capping that field at 4 MB on the installed binary + * lets `execve` succeed, and must be reapplied after every update. Node spawn + * uses execvp, which retries ENOEXEC via /bin/sh, so the failure often + * arrives as status 126 with no error object. ENOEXEC can also mean a corrupt + * or wrong-architecture file; this is the likely cause on Linux when the file + * exists. + */ +export const linuxCliExecFormatErrorHint = + "t3: Oracle Linux UEK8 kernels reject Node SEA PT_NOTE segments larger than 4 MB (ENOEXEC). Boot Oracle RHCK or a mainline-based kernel; patch the installed binary's PT_NOTE p_filesz down to 4 MB (reapply after every update); or build from source and run node apps/server/dist/bin.mjs. ENOEXEC can also mean a corrupt or wrong-architecture file."; + +/** Return `dist/bin.mjs` source that forwards the process to the sibling platform `t3` executable. */ export function legacyCliLauncherScript(): string { + // Linux pipe/socket stderr writes are async; process.exit() would drop the UEK8 hint. return `import { spawn } from "node:child_process"; import { constants } from "node:os"; import { dirname, join } from "node:path"; @@ -22,6 +39,14 @@ const ipc = process.send !== undefined; const child = spawn(executable, process.argv.slice(2), { stdio: ipc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit", }); +const linuxHint = ${JSON.stringify(linuxCliExecFormatErrorHint + "\n")}; +let exiting = false; +const exitAfterWrite = (code, extra) => { + if (exiting) return; + exiting = true; + if (extra) process.stderr.write(extra, () => process.exit(code)); + else process.exit(code); +}; const fail = (error) => { if (!error) return; process.stderr.write("t3: " + error.message + "\\n"); @@ -36,7 +61,15 @@ if (ipc) { for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { process.on(signal, () => child.kill(signal)); } -child.on("error", (error) => { fail(error); process.exit(1); }); -child.on("exit", (code, signal) => process.exit(code ?? 128 + (constants.signals[signal] || 1))); +child.on("error", (error) => { + fail(error); + exitAfterWrite(1, error.code === "ENOEXEC" && process.platform === "linux" ? linuxHint : undefined); +}); +child.on("exit", (code, signal) => { + exitAfterWrite( + code ?? 128 + (constants.signals[signal] || 1), + code === 126 && process.platform === "linux" ? linuxHint : undefined, + ); +}); `; } diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index 5fc0d859fc3d..70f912d0a0c9 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -1,4 +1,5 @@ import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { linuxCliExecFormatErrorHint } from "@t3tools/shared/legacyCliLauncher"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -10,6 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildNpmPlatformPackages, + NPM_LAUNCHER_SCRIPT, NpmPackagesArchivesMissingError, } from "./build-npm-platform-packages.ts"; @@ -258,4 +260,55 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { assert.include(unsupported.stderr, "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/pingdotgg/t3code/releases"); }), ); + + it.effect.skipIf(HostProcessPlatform.defaultValue() !== "linux")( + "hints at UEK8 when the platform executable cannot exec", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostPlatform = yield* HostProcessPlatform; + const hostArch = yield* HostProcessArchitecture; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-enoexec-" }); + const platformDir = path.join(root, `@t3code/t3-${hostPlatform}-${hostArch}`); + const launcherDir = path.join(root, "t3"); + yield* fs.makeDirectory(platformDir, { recursive: true }); + yield* fs.makeDirectory(path.join(launcherDir, "bin"), { recursive: true }); + yield* fs.writeFileString(path.join(platformDir, "package.json"), '{"type":"commonjs"}'); + yield* fs.writeFileString(path.join(platformDir, "t3"), "#!/bin/sh\nexit 126\n"); + yield* fs.chmod(path.join(platformDir, "t3"), 0o755); + yield* fs.writeFileString(path.join(launcherDir, "bin/t3.js"), NPM_LAUNCHER_SCRIPT); + const env = { ...process.env, NODE_PATH: root } as Record; + const execvpFallback = yield* run(process.execPath, ["bin/t3.js", "--version"], { + cwd: launcherDir, + env, + }); + assert.equal(execvpFallback.exitCode, 126); + assert.include(execvpFallback.stderr, linuxCliExecFormatErrorHint); + + // Node spawnSync uses execvp, which retries ENOEXEC via /bin/sh. The + // raw error is still possible if that fallback is skipped. + yield* fs.writeFileString( + path.join(root, "enoexec-preload.cjs"), + [ + '"use strict";', + 'const childProcess = require("node:child_process");', + "childProcess.spawnSync = () => {", + ' const error = new Error("spawnSync ENOEXEC");', + ' error.code = "ENOEXEC";', + " return { error, status: null, signal: null };", + "};", + "", + ].join("\n"), + ); + const enoexec = yield* run( + process.execPath, + ["-r", path.join(root, "enoexec-preload.cjs"), "bin/t3.js", "--version"], + { cwd: launcherDir, env }, + ); + assert.equal(enoexec.exitCode, 1); + assert.include(enoexec.stderr, "failed to start"); + assert.include(enoexec.stderr, linuxCliExecFormatErrorHint); + }), + ); }); diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index 6a134d99e2c5..9f671dcbb044 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -19,7 +19,10 @@ * bundleDependencies needs an arborist tree these flattened installs are * not), whereas `npm publish ` uploads the bytes as given. */ -import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher"; +import { + legacyCliLauncherScript, + linuxCliExecFormatErrorHint, +} from "@t3tools/shared/legacyCliLauncher"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -190,7 +193,9 @@ export function npmLauncherPackageManifest( /** * The launcher every `npx t3` runs. Plain CommonJS with no dependencies so it * loads on any Node that npm itself runs on; the real work happens in the - * single-executable it execs. + * single-executable it execs. Linux ENOEXEC / exit 126 prints a UEK8 hint + * (RHCK, PT_NOTE `p_filesz` cap, or from-source) because Node spawnSync + * retries exec format errors through /bin/sh. */ export const NPM_LAUNCHER_SCRIPT = `#!/usr/bin/env node "use strict"; @@ -219,12 +224,21 @@ try { const executable = join(packageDir, process.platform === "win32" ? "t3.exe" : "t3"); const result = spawnSync(executable, process.argv.slice(2), { stdio: "inherit" }); +const linuxHint = ${JSON.stringify(linuxCliExecFormatErrorHint + "\n")}; +const exitAfterWrite = (code, extra) => { + if (extra) process.stderr.write(extra, () => process.exit(code)); + else process.exit(code); +}; if (result.error) { process.stderr.write("t3: failed to start " + executable + ": " + result.error.message + "\\n"); - process.exit(1); + exitAfterWrite(1, process.platform === "linux" && result.error.code === "ENOEXEC" ? linuxHint : undefined); +} else { + // A child killed by a signal has no status; report it the way a shell would. + exitAfterWrite( + result.status ?? 128 + (constants.signals[result.signal] || 1), + process.platform === "linux" && result.status === 126 ? linuxHint : undefined, + ); } -// A child killed by a signal has no status; report it the way a shell would. -process.exit(result.status ?? 128 + (constants.signals[result.signal] || 1)); `; const runCommand = Effect.fn("runCommand")(function* ( diff --git a/scripts/install.sh b/scripts/install.sh index 421d8d021aef..06be3143da11 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -208,7 +208,15 @@ else step "Extracting T3 Code..." tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1 rm -f "${staging}/${archive}" "${staging}/SHA256SUMS" - "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run" + # Some shells retry ENOEXEC as a script, so stderr may not mention it. + # Keep this diagnostic aligned with linuxCliExecFormatErrorHint in + # packages/shared/src/legacyCliLauncher.ts (PT_NOTE p_filesz cap). + if ! "${staging}/t3" --version >/dev/null 2>&1; then + if [ "$platform" = linux ]; then + fail "the downloaded executable does not run. This can be caused by Oracle Linux UEK8 kernels rejecting Node SEA PT_NOTE segments larger than 4 MB (ENOEXEC). Boot Oracle RHCK or a mainline-based kernel; patch the installed binary's PT_NOTE p_filesz down to 4 MB (reapply after every update); or build from source and run node apps/server/dist/bin.mjs. ENOEXEC can also mean a corrupt or wrong-architecture file." + fi + fail "the downloaded executable does not run" + fi printf '%s\n' "$version" > "${staging}/.install-complete" rm -rf "$target_dir" diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 9ded5cb51166..324b514fb453 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -112,4 +112,77 @@ describe.skipIf(HostProcessPlatform.defaultValue() !== "linux")("installer termi } }, ); + + it("hints at UEK8 when the extracted executable cannot exec", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-enoexec-")); + const version = "1.2.3"; + const installerPlatform = HostProcessPlatform.defaultValue() === "darwin" ? "darwin" : "linux"; + const stem = `t3-${version}-${installerPlatform}-${HostProcessArchitecture.defaultValue()}`; + const archiveName = `${stem}.tar.gz`; + await NodeFSP.mkdir(NodePath.join(root, stem)); + const elf = Buffer.alloc(64); + elf.write("\x7fELF"); + elf[4] = 2; + elf[5] = 1; + elf[6] = 1; + await NodeFSP.writeFile(NodePath.join(root, stem, "t3"), elf, { mode: 0o755 }); + NodeChildProcess.execFileSync("tar", [ + "-czf", + NodePath.join(root, archiveName), + "-C", + root, + stem, + ]); + const archive = await NodeFSP.readFile(NodePath.join(root, archiveName)); + const checksum = NodeCrypto.createHash("sha256").update(archive).digest("hex"); + const server = NodeHttp.createServer((request, response) => { + if (request.url?.endsWith("/SHA256SUMS")) { + response.end(`${checksum} ${archiveName}\n`); + } else { + response.end(archive); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + const child = NodeChildProcess.spawn( + "sh", + [NodePath.resolve(import.meta.dirname, "install.sh")], + { + env: { + ...process.env, + T3CODE_VERSION: version, + T3CODE_HOME: NodePath.join(root, "home"), + T3CODE_INSTALL_BIN_DIR: NodePath.join(root, "bin"), + T3CODE_RELEASE_BASE_URL: `http://127.0.0.1:${address.port}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + const collect = (chunk: Buffer) => { + output += chunk.toString(); + }; + child.stdout.on("data", collect); + child.stderr.on("data", collect); + try { + const code = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", resolve); + }); + expect(code).not.toBe(0); + expect(output).toContain("the downloaded executable does not run"); + expect(output).toContain("UEK8"); + expect(output).toContain("RHCK"); + expect(output).toContain("PT_NOTE"); + expect(output).toContain("p_filesz"); + expect(output).toContain("node apps/server/dist/bin.mjs"); + expect(await NodeFSP.readdir(NodePath.join(root, "home/runtime/versions"))).toEqual([]); + } finally { + if (child.exitCode === null) child.kill(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); });