From 621d31ba34d0e320b5a2225f716d2d5591dbdbd6 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Tue, 29 Sep 2026 13:11:17 -0500 Subject: [PATCH 1/2] fix(server): build node-pty from source when installing the npm runtime on musl node-pty 1.2.0-beta.15 ships glibc-only Linux prebuilds and its install script selects them without checking libc. On Alpine they load under gcompat and then segfault on the first pty.spawn(), so 0.0.64 crash-loops the boot service as soon as a terminal opens. - Pass npm_config_build_from_source=true to the pinned npm runtime install on linux+musl, which makes node-pty fall through to node-gyp rebuild. keyring, fff and ffi-rs resolve their -musl packages and are unaffected. - Move libc detection to HostProcessLinuxLibc in @t3tools/shared/hostProcess and share it with the resource monitor. - Spawn a PTY in __service-preflight so a runtime whose node-pty crashes is rejected before a self-update publishes it. A host that cannot open PTYs at all still passes, so it is not locked out of updates. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/cli/servicePreflight.test.ts | 45 +++++++++++ apps/server/src/cli/servicePreflight.ts | 36 ++++++++- apps/server/src/cli/update.ts | 3 + apps/server/src/cloud/bootService.ts | 3 + apps/server/src/cloud/pinnedRuntime.test.ts | 74 +++++++++++++++++++ apps/server/src/cloud/pinnedRuntime.ts | 10 +++ apps/server/src/cloud/selfUpdate.ts | 3 + .../ResourceMonitorBinary.test.ts | 7 +- .../ResourceMonitorBinary.ts | 30 +------- packages/shared/src/hostProcess.ts | 24 ++++++ 10 files changed, 205 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/cli/servicePreflight.test.ts diff --git a/apps/server/src/cli/servicePreflight.test.ts b/apps/server/src/cli/servicePreflight.test.ts new file mode 100644 index 000000000000..04aa62196de2 --- /dev/null +++ b/apps/server/src/cli/servicePreflight.test.ts @@ -0,0 +1,45 @@ +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 Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; + +import { NodePtyModuleLoaderRef, NodePtyModuleLoadError } from "../terminal/NodePtyAdapter.ts"; +import { checkPtySpawns } from "./servicePreflight.ts"; + +const withNodePty = (load: () => Promise) => + 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); + } + }), +); diff --git a/apps/server/src/cli/servicePreflight.ts b/apps/server/src/cli/servicePreflight.ts index 94aea99091e0..24e9aaec4c20 100644 --- a/apps/server/src/cli/servicePreflight.ts +++ b/apps/server/src/cli/servicePreflight.ts @@ -1,8 +1,39 @@ +import { HostProcessEnvironment, isHostWindows } from "@t3tools/shared/hostProcess"; import * as Console from "effect/Console"; +import * as Deferred from "effect/Deferred"; 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(); + 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)); + yield* Deferred.await(exited); +}).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"), @@ -10,7 +41,10 @@ export const servicePreflightCommand = Command.make("__service-preflight", { }).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, ), ), diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 2b3dda0cd054..f75cee4baf24 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -4,6 +4,7 @@ import { HostProcessExecutablePath, HostProcessInvokedAs, HostProcessIsExecutable, + HostProcessLinuxLibc, HostProcessPlatform, HostProcessWorkingDirectory, } from "@t3tools/shared/hostProcess"; @@ -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; @@ -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 diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index c724db8eca12..5077cb9ac326 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -2,6 +2,7 @@ import { HostProcessArchitecture, HostProcessExecutablePath, HostProcessIsExecutable, + HostProcessLinuxLibc, HostProcessPlatform, HostProcessUserId, } from "@t3tools/shared/hostProcess"; @@ -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( @@ -784,6 +786,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { httpClient, platform, arch, + linuxLibc, releaseBaseUrl, validate: (runtime) => runner diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 1e45ab56a928..e1cc366ded99 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -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), @@ -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, @@ -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"), @@ -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) => @@ -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 = []; + 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; @@ -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"), @@ -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) => @@ -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: () => @@ -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) => @@ -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, @@ -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) => @@ -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, diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 00aff06c6c32..c27d0025a991 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -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"; @@ -135,6 +136,7 @@ interface PinnedRuntimeInstallInput { ) => Effect.Effect; readonly platform: NodeJS.Platform; readonly arch: string; + readonly linuxLibc: HostLinuxLibc; readonly httpClient: HttpClient.HttpClient; readonly releaseBaseUrl?: string | undefined; readonly onProgress?: (progress: PinnedRuntimeProgress) => void; @@ -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", diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 8ae7965ed1ae..16c5be7410ca 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -10,6 +10,7 @@ import { HostProcessArchitecture, HostProcessPlatform, HostProcessIsExecutable, + HostProcessLinuxLibc, HostProcessExecutablePath, } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; @@ -183,6 +184,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const path = yield* Path.Path; const platform = yield* HostProcessPlatform; const arch = yield* HostProcessArchitecture; + const linuxLibc = yield* HostProcessLinuxLibc; const nodeExecutable = yield* HostProcessExecutablePath; const distribution = (yield* HostProcessIsExecutable) ? "archive" : "npm"; // Standalone executables download from GitHub; Node daemons keep the npm layout. @@ -239,6 +241,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { httpClient, platform, arch, + linuxLibc, releaseBaseUrl, validate: (runtime) => runner diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 7fa23aac3908..9046bd119482 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessArchitecture, HostProcessEnvironment, + HostProcessLinuxLibc, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; @@ -60,7 +61,7 @@ describe("ResourceMonitorBinary", () => { Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), Effect.provideService(HostProcessPlatform, "linux"), Effect.provideService(HostProcessArchitecture, "x64"), - Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"), + Effect.provideService(HostProcessLinuxLibc, "musl"), Effect.provideService(HostProcessEnvironment, { T3CODE_RESOURCE_MONITOR_PATH: binaryPath, }), @@ -107,7 +108,7 @@ describe("ResourceMonitorBinary", () => { Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), Effect.provideService(HostProcessPlatform, "linux"), Effect.provideService(HostProcessArchitecture, "x64"), - Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "gnu"), + Effect.provideService(HostProcessLinuxLibc, "gnu"), Effect.provideService(HostProcessEnvironment, { T3CODE_RESOURCE_MONITOR_PATH: binaryPath, }), @@ -147,7 +148,7 @@ describe("ResourceMonitorBinary", () => { Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), Effect.provideService(HostProcessPlatform, "linux"), Effect.provideService(HostProcessArchitecture, "x64"), - Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"), + Effect.provideService(HostProcessLinuxLibc, "musl"), Effect.provideService(HostProcessEnvironment, {}), ); const error = yield* Effect.flip(service.resolve); diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index 52c123fe6625..b40dfba6ac91 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -1,6 +1,8 @@ import { + type HostLinuxLibc, HostProcessArchitecture, HostProcessEnvironment, + HostProcessLinuxLibc, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; @@ -66,30 +68,6 @@ function binaryName(platform: NodeJS.Platform): string { return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor"; } -export type ResourceMonitorLinuxLibc = "gnu" | "musl"; - -function detectResourceMonitorLinuxLibc(): ResourceMonitorLinuxLibc { - try { - const report = process.report?.getReport() as - | { - readonly header?: { - readonly glibcVersionRuntime?: unknown; - }; - } - | undefined; - return typeof report?.header?.glibcVersionRuntime === "string" ? "gnu" : "musl"; - } catch { - return "musl"; - } -} - -export const ResourceMonitorHostLinuxLibc = Context.Reference( - "t3/resourceTelemetry/ResourceMonitorHostLinuxLibc", - { - defaultValue: detectResourceMonitorLinuxLibc, - }, -); - function resourceMonitorPlatformKey( platform: NodeJS.Platform, architecture: NodeJS.Architecture, @@ -106,7 +84,7 @@ function resourceMonitorPlatformKey( function resourceMonitorRustTarget( platform: NodeJS.Platform, architecture: NodeJS.Architecture, - linuxLibc?: ResourceMonitorLinuxLibc, + linuxLibc?: HostLinuxLibc, ): string | undefined { if (platform === "darwin") { return architecture === "arm64" @@ -142,7 +120,7 @@ export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(fu const platform = yield* HostProcessPlatform; const architecture = yield* HostProcessArchitecture; const environment = yield* HostProcessEnvironment; - const linuxLibc = platform === "linux" ? yield* ResourceMonitorHostLinuxLibc : undefined; + const linuxLibc = platform === "linux" ? yield* HostProcessLinuxLibc : undefined; const executableName = binaryName(platform); const platformKey = resourceMonitorPlatformKey(platform, architecture); const rustTarget = resourceMonitorRustTarget(platform, architecture, linuxLibc); diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts index 9bd41a825044..12a238549619 100644 --- a/packages/shared/src/hostProcess.ts +++ b/packages/shared/src/hostProcess.ts @@ -18,6 +18,30 @@ export const HostProcessArchitecture = Context.Reference( }, ); +export type HostLinuxLibc = "gnu" | "musl"; + +function detectLinuxLibc(): HostLinuxLibc { + try { + const report = process.report?.getReport() as + | { readonly header?: { readonly glibcVersionRuntime?: unknown } } + | undefined; + return typeof report?.header?.glibcVersionRuntime === "string" ? "gnu" : "musl"; + } catch { + return "musl"; + } +} + +/** + * The C library this Node links against. Only meaningful on Linux: every + * non-glibc host reports "musl", so read it behind a platform check. + */ +export const HostProcessLinuxLibc = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessLinuxLibc", + { + defaultValue: detectLinuxLibc, + }, +); + export const HostProcessHostname = Context.Reference( "@t3tools/shared/hostProcess/HostProcessHostname", { From ed34d6e89fff40a3b0b9643acb795bb11a4ad4b5 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Tue, 29 Sep 2026 13:47:35 -0500 Subject: [PATCH 2/2] fix(server): bound the preflight PTY wait and kill a stalled child The self-update caller already kills the preflight after 30s, but a local 10s bound fails with a clear reason and cleans up the PTY child itself. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/cli/servicePreflight.test.ts | 33 ++++++++++++++++++++ apps/server/src/cli/servicePreflight.ts | 13 +++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/servicePreflight.test.ts b/apps/server/src/cli/servicePreflight.test.ts index 04aa62196de2..cbc7f981d7ba 100644 --- a/apps/server/src/cli/servicePreflight.test.ts +++ b/apps/server/src/cli/servicePreflight.test.ts @@ -2,9 +2,12 @@ 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"; @@ -43,3 +46,33 @@ it.effect("fails when the runtime's node-pty cannot load", () => } }), ); + +it.effect("kills a PTY that never exits and fails the preflight", () => + Effect.gen(function* () { + const kills: Array = []; + const spawned = yield* Deferred.make(); + 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); + }), +); diff --git a/apps/server/src/cli/servicePreflight.ts b/apps/server/src/cli/servicePreflight.ts index 24e9aaec4c20..ea54ef408d17 100644 --- a/apps/server/src/cli/servicePreflight.ts +++ b/apps/server/src/cli/servicePreflight.ts @@ -1,6 +1,7 @@ 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"; @@ -27,7 +28,17 @@ export const checkPtySpawns = Effect.gen(function* () { env: yield* HostProcessEnvironment, }); child.onExit(() => Deferred.doneUnsafe(exited, Effect.void)); - yield* Deferred.await(exited); + // 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.