Skip to content
Draft
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
12 changes: 12 additions & 0 deletions docs/user/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
40 changes: 39 additions & 1 deletion packages/shared/src/legacyCliLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
}
},
);
37 changes: 35 additions & 2 deletions packages/shared/src/legacyCliLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand All @@ -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,
);
});
`;
}
53 changes: 53 additions & 0 deletions scripts/build-npm-platform-packages.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,6 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import {
buildNpmPlatformPackages,
NPM_LAUNCHER_SCRIPT,
NpmPackagesArchivesMissingError,
} from "./build-npm-platform-packages.ts";

Expand Down Expand Up @@ -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<string, string>;
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);
}),
);
});
24 changes: 19 additions & 5 deletions scripts/build-npm-platform-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
* bundleDependencies needs an arborist tree these flattened installs are
* not), whereas `npm publish <tarball>` 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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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* (
Expand Down
10 changes: 9 additions & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
73 changes: 73 additions & 0 deletions scripts/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<number | null>((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<void>((resolve) => server.close(() => resolve()));
await NodeFSP.rm(root, { recursive: true, force: true });
}
});
});
Loading