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
26 changes: 20 additions & 6 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";

import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts";
import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-executable-imports.ts";
import { injectLinuxArm64Sea } from "../../../scripts/lib/linux-arm64-sea.ts";
import rootPackageJson from "../../../package.json" with { type: "json" };
import * as HostProcess from "@t3tools/shared/HostProcess";
import { resolveSpawnCommand } from "@t3tools/shared/shell";
import {
ServerCliBuildAssetMissingError,
Expand Down Expand Up @@ -118,17 +120,20 @@ const buildExeCmd = Command.make(
const serverDir = path.join(repoRoot, "apps/server");

yield* Effect.log("[cli] Building single-executable...");
const target = Option.getOrUndefined(config.target);
// TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048
const linuxArm64 =
!target &&
(yield* HostProcess.Platform) === "linux" &&
(yield* HostProcess.Architecture) === "arm64";
const spawnCommand = yield* resolveSpawnCommand("vp", ["pack"]);
yield* runCommand(
ChildProcess.make(spawnCommand.command, spawnCommand.args, {
cwd: serverDir,
env: {
...process.env,
T3CODE_PACK_EXE: "1",
...Option.match(config.target, {
onNone: () => ({}),
onSome: (target) => ({ T3CODE_PACK_EXE_TARGET: target }),
}),
T3CODE_PACK_EXE: linuxArm64 ? "bundle" : "1",
T3CODE_PACK_EXE_TARGET: target ?? "",
},
stdout: config.verbose ? "inherit" : "ignore",
stderr: "inherit",
Expand All @@ -142,13 +147,22 @@ const buildExeCmd = Command.make(
if (specifiers.length > 0) {
return yield* new ServerCliExecutableImportError({ bundlePath, specifiers });
}
// TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048
if (linuxArm64) {
yield* injectLinuxArm64Sea({
bundlePath,
outputPath: path.join(serverDir, "dist-exe/t3"),
nodeVersion: rootPackageJson.engines.node,
verbose: config.verbose,
});
}
yield* Effect.log(
"[cli] Built dist-exe/t3 (expects client/, resource-monitor/, and the runtime-external node_modules beside it; scripts/build-cli-archive.ts assembles that tree)",
);
}),
).pipe(
Command.withDescription(
`Build the server as a Node single-executable. Native builds require Node ${rootPackageJson.engines.node}; explicit cross-target builds need a Node 25.7+ host for --build-sea. The binary still resolves native packages from a node_modules tree beside it.`,
`Build the server as a Node single-executable. Native builds require Node ${rootPackageJson.engines.node}; explicit cross-target builds need a Node 25.7+ host for --build-sea. Linux ARM64 builds only on a Linux ARM64 host. The binary still resolves native packages from a node_modules tree beside it.`,
),
);

Expand Down
13 changes: 10 additions & 3 deletions apps/server/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ if (!TELEMETRY_CHANNELS.includes(telemetryChannel)) {
}

// tsdown SEA needs one chunk (sourcemaps count) and Node >=25.7, so package separately.
const packExecutable = process.env.T3CODE_PACK_EXE === "1";
// TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048
// "1" builds dist-exe/t3 here; "bundle" stops at bin.mjs for scripts/cli.ts to inject (linux-arm64).
const packMode = process.env.T3CODE_PACK_EXE;
const packExecutable = packMode === "1" || packMode === "bundle";
// TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048
// No linux-arm64: --build-sea output cannot load on 16/64 KiB page kernels, so it builds natively.
const SEA_TARGETS = {
"darwin-arm64": { platform: "darwin", arch: "arm64" },
"darwin-x64": { platform: "darwin", arch: "x64" },
"linux-arm64": { platform: "linux", arch: "arm64" },
"linux-x64": { platform: "linux", arch: "x64" },
"win-arm64": { platform: "win", arch: "arm64" },
"win-x64": { platform: "win", arch: "x64" },
Expand Down Expand Up @@ -83,7 +87,10 @@ export default mergeConfig(
outDir: packExecutable ? "dist-exe" : "dist",
sourcemap: !packExecutable,
clean: true,
...(packExecutable
// TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048
// SEA can only resolve built-ins; keep dynamic imports inside its one module.
...(packExecutable ? { outputOptions: { codeSplitting: false } } : {}),
...(packMode === "1"
? {
exe: {
fileName: "t3",
Expand Down
2 changes: 1 addition & 1 deletion docs/operations/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ the version.
- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets.
- Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but the Windows archives is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected.
- The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The `t3` npm package exists for people who run `npx t3` or `npm install -g t3` themselves, and it downloads this same archive too; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`.
- The root `package.json` pins `engines.node` to the exact Node version used by development, CI and standalone executables. Keep it exact: cross-target builds download that version, and native executable builds reject a different host Node version. Runtime changes need a new T3 release version because installed runtimes are cached by that version.
- The root `package.json` pins `engines.node` to the exact Node version used by development, CI and standalone executables. Keep it exact: cross-target builds download that version, and native executable builds reject a different host Node version. TEMPORARY: Linux arm64 executables build only on a Linux arm64 host and are injected with postject, because Node's `--build-sea` output cannot load on 16 or 64 KiB page kernels. Revert when [nodejs/node#67048](https://github.com/nodejs/node/issues/67048) is fixed. Runtime changes need a new T3 release version because installed runtimes are cached by that version.
- macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries.
- Each archive is extracted and executed (`scripts/smoke-cli-archive.ts`) on its build runner, or on the `verify_runner` for Windows.
- After the GitHub Release is published, publishes the `t3` npm package with OIDC trusted publishing from the same workflow file. It is only a launcher (`scripts/npm-launcher/t3.mjs`): on first run it downloads its exact version's CLI archive from the GitHub Release, verifies it against `SHA256SUMS`, and unpacks it into `<T3 home>/runtime/versions/<version>`, the layout the install scripts and `t3 update` share. The executables are not on npm. They used to be, as `@t3code/t3-<platform>-<arch>` optional dependencies, but npm's publish-time scanning held those 80-250 MB packages back for up to an hour after the launcher was live, and every install in that window got a launcher with no executable. Those packages stay on npm at their old versions and get no new ones. Because the launcher reads from the release, `publish_cli` runs after `release`, never before.
Expand Down
7 changes: 4 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion scripts/build-cli-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
resolveFffNativeDependencies,
STAGE_INSTALL_ARGS,
} from "./build-desktop-artifact.ts";
import { assertLinuxArm64PageCompatibility } from "./lib/linux-arm64-sea.ts";
import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts";
import { signWithJsign } from "./lib/windows-jsign.ts";
import { windowsSystemTar } from "./lib/windows-tar.ts";
Expand Down Expand Up @@ -441,8 +442,13 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: {

yield* requireInput(
builtExecutable,
`Run \`node apps/server/scripts/cli.ts build-exe --target ${targetKey}\` first.`,
`Run \`node apps/server/scripts/cli.ts build-exe${targetKey === hostKey ? "" : ` --target ${targetKey}`}\` first.`,
);
// TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048
if (input.platform === "linux" && input.arch === "arm64") {
const bytes = yield* fs.readFile(builtExecutable);
yield* Effect.try(() => assertLinuxArm64PageCompatibility(bytes));
}
yield* requireInput(path.join(webClient, "index.html"), "Run `vp run --filter t3 build` first.");
yield* requireInput(
resourceMonitorDir,
Expand Down
64 changes: 64 additions & 0 deletions scripts/lib/linux-arm64-sea.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048
import { describe, expect, it } from "@effect/vitest";
import { assertLinuxArm64PageCompatibility } from "./linux-arm64-sea.ts";

function elf(loads: Array<{ offset: bigint; address: bigint; size: bigint }>) {
const bytes = new Uint8Array(64 + loads.length * 56);
const view = new DataView(bytes.buffer);
bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]);
view.setUint16(18, 183, true);
view.setBigUint64(32, 64n, true);
view.setUint16(54, 56, true);
view.setUint16(56, loads.length, true);
loads.forEach((load, index) => {
const start = 64 + index * 56;
view.setUint32(start, 1, true);
view.setBigUint64(start + 8, load.offset, true);
view.setBigUint64(start + 16, load.address, true);
view.setBigUint64(start + 40, load.size, true);
view.setBigUint64(start + 48, 0x1000n, true);
});
return bytes;
}

describe("Linux ARM64 SEA page compatibility", () => {
it("rejects the relocated SEA note from nightly 3512", () => {
expect(() =>
assertLinuxArm64PageCompatibility(
elf([{ offset: 0x8faf000n, address: 0x1015d000n, size: 0x1003000n }]),
),
).toThrow(/64 KiB/);
});

it("accepts congruent offsets even with a smaller declared alignment", () => {
expect(() =>
assertLinuxArm64PageCompatibility(
elf([
{ offset: 0n, address: 0x400000n, size: 0x10000n },
{ offset: 0x20000n, address: 0x420000n, size: 0x10000n },
]),
),
).not.toThrow();
});

it("rejects segments that share a 64 KiB page", () => {
expect(() =>
assertLinuxArm64PageCompatibility(
elf([
{ offset: 0n, address: 0x400000n, size: 0x11000n },
{ offset: 0x11000n, address: 0x411000n, size: 0x1000n },
]),
),
).toThrow(/overlap/);
});

it("rejects missing or malformed program headers", () => {
expect(() => assertLinuxArm64PageCompatibility(elf([]))).toThrow();
expect(() => assertLinuxArm64PageCompatibility(new Uint8Array(64))).toThrow();
expect(() =>
assertLinuxArm64PageCompatibility(
elf([{ offset: 0n, address: 0x400000n, size: 1n }]).subarray(0, 70),
),
).toThrow();
});
});
121 changes: 121 additions & 0 deletions scripts/lib/linux-arm64-sea.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// TEMPORARY one-time workaround for a Node --build-sea bug on Linux arm64.
// It breaks kernels with 16 KiB or 64 KiB pages. Revert when Node ships a fix.
// Node issue: https://github.com/nodejs/node/issues/67048
// Workaround PR: https://github.com/pingdotgg/t3code/pull/25825
import * as NodeModule from "node:module";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { ChildProcess, ChildProcessSpawner } from "effect/process";

class LinuxArm64SeaError extends Schema.TaggedError<LinuxArm64SeaError>()("LinuxArm64SeaError", {
message: Schema.String,
}) {}

const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));

const run = Effect.fn("LinuxArm64Sea.run")(function* (args: Array<string>, verbose: boolean) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const child = yield* spawner.spawn(
ChildProcess.make(process.execPath, args, {
stdout: verbose ? "inherit" : "ignore",
stderr: "inherit",
}),
);
const exitCode = yield* child.exitCode;
if (exitCode !== 0) {
return yield* new LinuxArm64SeaError({ message: `${args[0]} exited with code ${exitCode}.` });
}
});

// Builds the SEA blob with this Node and injects it into a copy of this Node with postject.
export const injectLinuxArm64Sea = Effect.fn("LinuxArm64Sea.inject")(function* (input: {
bundlePath: string;
outputPath: string;
nodeVersion: string;
verbose: boolean;
}) {
// The copied execPath is the shipped runtime; vp pack only checks its own Node.
if (process.versions.node !== input.nodeVersion) {
return yield* new LinuxArm64SeaError({
message: `Linux ARM64 executables must be built with Node ${input.nodeVersion}; running ${process.versions.node}.`,
});
}
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sea-" });
const blobPath = path.join(temp, "sea.blob");
const configPath = path.join(temp, "sea.json");
yield* fs.writeFileString(
configPath,
yield* encodeJson({
main: input.bundlePath,
output: blobPath,
mainFormat: "module",
disableExperimentalSEAWarning: true,
// Node SEA useCodeCache breaks import(), which the server needs.
useCodeCache: false,
}),
);
yield* run(["--experimental-sea-config", configPath], input.verbose);
yield* fs.copyFile(process.execPath, input.outputPath);
yield* fs.chmod(input.outputPath, 0o755);
const require = NodeModule.createRequire(import.meta.url);
yield* run(
[
require.resolve("postject/dist/cli.js"),
input.outputPath,
"NODE_SEA_BLOB",
blobPath,
"--sentinel-fuse",
"NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2",
],
input.verbose,
);
}, Effect.scoped);

// Kernels map LOAD segments in whole pages. Checking at 64 KiB covers 4, 16, and 64 KiB kernels.
export function assertLinuxArm64PageCompatibility(bytes: Uint8Array) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (
bytes.length < 64 ||
view.getUint32(0, false) !== 0x7f454c46 ||
bytes[4] !== 2 ||
bytes[5] !== 1 ||
view.getUint16(18, true) !== 183
) {
throw new Error("Expected a little-endian ELF64 ARM64 executable.");
}
const tableOffset = Number(view.getBigUint64(32, true));
const entrySize = view.getUint16(54, true);
const count = view.getUint16(56, true);
if (
!Number.isSafeInteger(tableOffset) ||
entrySize < 56 ||
count === 0 ||
tableOffset + entrySize * count > bytes.length
) {
throw new Error("Invalid ELF program header table.");
}
const pageSize = 0x10000n;
const ranges: Array<{ start: bigint; end: bigint }> = [];
for (let index = 0; index < count; index++) {
const header = tableOffset + index * entrySize;
if (view.getUint32(header, true) !== 1) continue;
const offset = view.getBigUint64(header + 8, true);
const address = view.getBigUint64(header + 16, true);
const size = view.getBigUint64(header + 40, true);
if (offset % pageSize !== address % pageSize) {
throw new Error(`ARM64 LOAD segment ${index} is incompatible with 64 KiB pages.`);
}
if (size === 0n) continue;
const start = address / pageSize;
const end = (address + size + pageSize - 1n) / pageSize;
if (ranges.some((range) => start < range.end && range.start < end)) {
throw new Error(`ARM64 LOAD segments overlap on a 64 KiB page (segment ${index}).`);
}
ranges.push({ start, end });
}
if (ranges.length === 0) throw new Error("ELF executable has no LOAD segments.");
}
1 change: 1 addition & 0 deletions scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@types/plist": "catalog:",
"@types/pngjs": "6.0.5",
"esbuild": "0.28.2",
"postject": "1.0.0-alpha.6",
"typescript": "catalog:",
"typescript-legacy": "npm:typescript@~6.0.3",
"vite-plus": "catalog:"
Expand Down
Loading