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
78 changes: 78 additions & 0 deletions apps/server/src/cli/servicePreflight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Cause from "effect/Cause";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as TestClock from "effect/testing/TestClock";

import { NodePtyModuleLoaderRef, NodePtyModuleLoadError } from "../terminal/NodePtyAdapter.ts";
import { checkPtySpawns } from "./servicePreflight.ts";

const withNodePty = (load: () => Promise<typeof import("node-pty")>) =>
Layer.mergeAll(
NodeServices.layer,
Layer.succeed(HostProcessPlatform, "linux"),
Layer.succeed(HostProcessArchitecture, "x64"),
Layer.succeed(NodePtyModuleLoaderRef, load),
);

it.effect("does not block an update on a host that cannot open PTYs", () =>
checkPtySpawns.pipe(
Effect.provide(
withNodePty(() =>
Promise.resolve({
spawn: () => {
throw new Error("open /dev/ptmx failed");
},
} as unknown as typeof import("node-pty")),
),
),
),
);

it.effect("fails when the runtime's node-pty cannot load", () =>
Effect.gen(function* () {
const exit = yield* checkPtySpawns.pipe(
Effect.provide(withNodePty(() => Promise.reject(new Error("invalid ELF header")))),
Effect.exit,
);
assert.isTrue(Exit.isFailure(exit));
if (Exit.isFailure(exit)) {
assert.instanceOf(Cause.squash(exit.cause), NodePtyModuleLoadError);
}
}),
);

it.effect("kills a PTY that never exits and fails the preflight", () =>
Effect.gen(function* () {
const kills: Array<string | undefined> = [];
const spawned = yield* Deferred.make<void>();
const fiber = yield* checkPtySpawns.pipe(
Effect.provide(
withNodePty(() =>
Promise.resolve({
spawn: () => {
Deferred.doneUnsafe(spawned, Effect.void);
return {
pid: 42,
kill: (signal?: string) => kills.push(signal),
onExit: () => ({ dispose: () => {} }),
};
},
} as unknown as typeof import("node-pty")),
),
),
Effect.exit,
Effect.forkChild,
);
yield* Deferred.await(spawned);
yield* TestClock.adjust("10 seconds");
const exit = yield* Fiber.join(fiber);
assert.isTrue(Exit.isFailure(exit));
assert.equal(kills.length, 1);
}),
);
47 changes: 46 additions & 1 deletion apps/server/src/cli/servicePreflight.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,61 @@
import { HostProcessEnvironment, isHostWindows } from "@t3tools/shared/hostProcess";
import * as Console from "effect/Console";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import { Command, Flag } from "effect/unstable/cli";

import { runServicePreflight } from "../cloud/servicePreflight.ts";
import * as NodePtyAdapter from "../terminal/NodePtyAdapter.ts";
import * as PtyAdapter from "../terminal/PtyAdapter.ts";

/**
* A native PTY addon built for the wrong libc can load and then segfault on its
* first spawn, which a running server only reaches once a terminal opens.
* Spawning one here turns that crash into a failed preflight, so the candidate
* runtime is rejected before it replaces a working one.
*/
export const checkPtySpawns = Effect.gen(function* () {
if (yield* isHostWindows) return;
const pty = yield* PtyAdapter.PtyAdapter;
const exited = yield* Deferred.make<void>();
const child = yield* pty.spawn({
shell: "/bin/sh",
args: ["-c", "exit 0"],
cwd: "/",
cols: 80,
rows: 24,
env: yield* HostProcessEnvironment,
});
child.onExit(() => Deferred.doneUnsafe(exited, Effect.void));
// Stays under the self-update caller's 30s limit so a stalled PTY fails here,
// with the child cleaned up, rather than by the caller killing this process.
yield* Deferred.await(exited).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(10),
orElse: () =>
Effect.sync(() => child.kill()).pipe(
Effect.andThen(Effect.die(new Error("The preflight PTY did not exit within 10s."))),
),
}),
);
}).pipe(
// A host that cannot open PTYs at all fails the same way on every version;
// blocking on it would only stop that host from ever updating.
Effect.catchTag("PtySpawnError", () => Effect.void),
Effect.provide(NodePtyAdapter.layer),
);

export const servicePreflightCommand = Command.make("__service-preflight", {
databasePath: Flag.String("database-path"),
launcherProtocol: Flag.Int("launcher-protocol"),
}).pipe(
Command.unlisted,
Command.withHandler(({ databasePath, launcherProtocol }) =>
Console.log(JSON.stringify(runServicePreflight({ databasePath, launcherProtocol }))).pipe(
checkPtySpawns.pipe(
Effect.andThen(
Console.log(JSON.stringify(runServicePreflight({ databasePath, launcherProtocol }))),
),
Effect.asVoid,
),
),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/cli/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
HostProcessExecutablePath,
HostProcessInvokedAs,
HostProcessIsExecutable,
HostProcessLinuxLibc,
HostProcessPlatform,
HostProcessWorkingDirectory,
} from "@t3tools/shared/hostProcess";
Expand Down Expand Up @@ -348,6 +349,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
const runner = yield* ProcessRunner.ProcessRunner;
const platform = yield* HostProcessPlatform;
const arch = yield* HostProcessArchitecture;
const linuxLibc = yield* HostProcessLinuxLibc;
const environment = yield* HostProcessEnvironment;
const httpClient = yield* HttpClient.HttpClient;
const service = yield* BootService.BootService;
Expand Down Expand Up @@ -500,6 +502,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
httpClient,
platform,
arch,
linuxLibc,
releaseBaseUrl: environment[CLI_RELEASE_BASE_URL_ENV]?.trim() || undefined,
validate: (paths) =>
runner
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
HostProcessArchitecture,
HostProcessExecutablePath,
HostProcessIsExecutable,
HostProcessLinuxLibc,
HostProcessPlatform,
HostProcessUserId,
} from "@t3tools/shared/hostProcess";
Expand Down Expand Up @@ -564,6 +565,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
const distribution = (yield* HostProcessIsExecutable) ? "archive" : "npm";
const platform = yield* HostProcessPlatform;
const arch = yield* HostProcessArchitecture;
const linuxLibc = yield* HostProcessLinuxLibc;
const uid = yield* HostProcessUserId;
const httpClient = yield* HttpClient.HttpClient;
const releaseBaseUrl = Option.getOrUndefined(
Expand Down Expand Up @@ -784,6 +786,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
httpClient,
platform,
arch,
linuxLibc,
releaseBaseUrl,
validate: (runtime) =>
runner
Expand Down
74 changes: 74 additions & 0 deletions apps/server/src/cloud/pinnedRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
path,
platform: "linux",
arch: "x64",
linuxLibc: "gnu",
httpClient: releaseHttpClient(yield* validChecksums, requests),
releaseBaseUrl: "https://releases.example/download",
runner: extractingRunner(fs, path, commands),
Expand Down Expand Up @@ -134,6 +135,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
path,
platform: "linux",
arch: "x64",
linuxLibc: "gnu",
httpClient: client,
runner: extractingRunner(fs, path),
validate: () => Effect.void,
Expand Down Expand Up @@ -200,6 +202,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
path,
platform: "linux",
arch: "x64",
linuxLibc: "gnu",
httpClient: client,
runner: extractingRunner(fs, path),
validate: () => Effect.die("must not validate an interrupted archive"),
Expand Down Expand Up @@ -236,6 +239,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
path,
platform: "linux",
arch: "x64",
linuxLibc: "gnu",
httpClient: releaseHttpClient("", requests),
runner: ProcessRunner.ProcessRunner.of({
run: (input) =>
Expand Down Expand Up @@ -306,6 +310,69 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
}),
);

// node-pty's Linux prebuilds are glibc-only and segfault on musl, so musl
// installs must compile it. Everywhere else the prebuilds are correct.
it.effect.each([
{ platform: "linux", linuxLibc: "musl", buildFromSource: true },
{ platform: "linux", linuxLibc: "gnu", buildFromSource: false },
{ platform: "darwin", linuxLibc: "musl", buildFromSource: false },
{ platform: "win32", linuxLibc: "musl", buildFromSource: false },
] as const)(
"builds native npm dependencies from source only on musl: $platform/$linuxLibc",
({ platform, linuxLibc, buildFromSource }) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-npm-libc-" });
const envs: Array<NodeJS.ProcessEnv | undefined> = [];
yield* ensurePinnedRuntimeInstalled({
distribution: "npm",
baseDir,
version,
fs,
path,
platform,
arch: "x64",
linuxLibc,
httpClient: releaseHttpClient(""),
runner: ProcessRunner.ProcessRunner.of({
run: (input) =>
Effect.gen(function* () {
envs.push(input.env);
const staging = input.args[input.args.indexOf("--prefix") + 1]!;
const packageDir = path.join(staging, "node_modules/t3");
yield* fs
.makeDirectory(path.join(packageDir, "dist"), { recursive: true })
.pipe(Effect.orDie);
yield* fs
.writeFileString(path.join(packageDir, "dist/bin.mjs"), "runtime")
.pipe(Effect.orDie);
yield* fs
.writeFileString(
path.join(packageDir, "package.json"),
'{"name":"@rtvision/t3","version":"1.2.3"}',
)
.pipe(Effect.orDie);
return {
stdout: "",
stderr: "",
code: ChildProcessSpawner.ExitCode(0),
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
};
}),
}),
validate: () => Effect.void,
});
assert.deepEqual(envs, [
buildFromSource ? { npm_config_build_from_source: "true" } : undefined,
]);
}),
);

it.effect("refuses an archive whose checksum does not match the release", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand All @@ -319,6 +386,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
path,
platform: "linux",
arch: "x64",
linuxLibc: "gnu",
httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`),
runner: extractingRunner(fs, path, commands),
validate: () => Effect.die("must not validate an unverified archive"),
Expand All @@ -345,6 +413,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
path,
platform: "linux",
arch: "x64",
linuxLibc: "gnu",
httpClient: releaseHttpClient(yield* validChecksums),
runner: extractingRunner(fs, path),
validate: (staging) =>
Expand Down Expand Up @@ -376,6 +445,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
path,
platform: "linux",
arch: "x64",
linuxLibc: "gnu",
httpClient: releaseHttpClient(yield* validChecksums),
runner: extractingRunner(fs, path),
validate: () =>
Expand Down Expand Up @@ -409,6 +479,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
path,
platform: "linux",
arch: "x64",
linuxLibc: "gnu",
httpClient: releaseHttpClient(""),
runner: ProcessRunner.ProcessRunner.of({
run: (input) =>
Expand Down Expand Up @@ -464,6 +535,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
path,
platform: "linux",
arch: "x64",
linuxLibc: "gnu",
httpClient: releaseHttpClient(yield* validChecksums),
runner: extractingRunner(fs, path),
validate: () => Effect.void,
Expand Down Expand Up @@ -493,6 +565,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
path,
platform: "linux",
arch: "x64",
linuxLibc: "gnu",
httpClient: releaseHttpClient(yield* validChecksums, requests),
runner: extractingRunner(fs, path),
validate: (paths) =>
Expand Down Expand Up @@ -527,6 +600,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
path,
platform: "linux",
arch: "x64",
linuxLibc: "gnu",
httpClient: releaseHttpClient(yield* validChecksums),
runner,
validate: () => Effect.void,
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/cloud/pinnedRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
parseChecksums,
} from "@t3tools/shared/cliRelease";

import type { HostLinuxLibc } from "@t3tools/shared/hostProcess";
import { T3_NPM_PACKAGE, T3_NPM_REGISTRY } from "@t3tools/shared/releasePackage";
import * as ProcessRunner from "../processRunner.ts";

Expand Down Expand Up @@ -135,6 +136,7 @@ interface PinnedRuntimeInstallInput {
) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>;
readonly platform: NodeJS.Platform;
readonly arch: string;
readonly linuxLibc: HostLinuxLibc;
readonly httpClient: HttpClient.HttpClient;
readonly releaseBaseUrl?: string | undefined;
readonly onProgress?: (progress: PinnedRuntimeProgress) => void;
Expand Down Expand Up @@ -360,6 +362,14 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(
T3_NPM_REGISTRY,
`t3@npm:${T3_NPM_PACKAGE}@${input.version}`,
],
// node-pty ships glibc-only Linux prebuilds and selects them without
// checking libc. On musl they load (under gcompat) and then segfault
// on the first spawn; this makes its install script compile instead.
// The other native dependencies ship musl packages and ignore it.
env:
input.platform === "linux" && input.linuxLibc === "musl"
? { npm_config_build_from_source: "true" }
: undefined,
timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
maxOutputBytes: 64 * 1024,
outputMode: "truncate",
Expand Down
Loading
Loading