authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 16:43:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 16:43:45-07:00
logf33fdc992488e36ccb70cbb71d55e630b5184cbd
treeeb2f5009c91f6bf4b456a53261be4c0a57d86fef
parent087cfb8ae262f344f7e409f5a8c0eda6f0ef12f9
signature Signed by PGP key B5690EEEBB952194

fix(ssh): exec managed servers without npm wrappers (#9843)


3 files changed, 171 insertions(+), 6 deletions(-)

packages/ssh/src/runnerProcess.test.ts created+167
......@@ -0,0 +1,167 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10import * as Stream from "effect/Stream";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12import * as NodeNet from "node:net";
13
14import { buildRemoteT3RunnerScript } from "./tunnel.ts";
15
16const Started = Schema.Struct({
17 pid: Schema.Number,
18 port: Schema.Number,
19 args: Schema.Array(Schema.String),
20});
21const decodeStarted = Schema.decodeUnknownSync(Schema.fromJsonString(Started));
22
23describe.skipIf(HostProcessPlatform.defaultValue() === "win32")(
24 "remote runner process ownership",
25 () => {
26 it.live.each(["npx", "npm"] as const)(
27 "keeps the server PID and graceful shutdown through the %s fallback",
28 (packageManager) =>
29 Effect.gen(function* () {
30 const fs = yield* FileSystem.FileSystem;
31 const path = yield* Path.Path;
32 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
33 const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" });
34 const bin = path.join(fixture, "bin");
35 const cliPath = path.join(fixture, "installed cli.mjs");
36 const callsPath = path.join(fixture, "package-manager-calls.jsonl");
37 const packageSpec = "t3@0.0.35";
38 yield* fs.makeDirectory(bin);
39 yield* fs.symlink(process.execPath, path.join(bin, "node"));
40 yield* fs.writeFileString(
41 cliPath,
42 `#!/usr/bin/env node
43import * as net from "node:net";
44const server = net.createServer((socket) => {
45 socket.end();
46 server.close();
47});
48process.on("SIGTERM", () => server.close(() => {
49 process.stdout.write("graceful shutdown\\n");
50}));
51server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => {
52 process.stdout.write(JSON.stringify({
53 pid: process.pid,
54 port: server.address().port,
55 args: process.argv.slice(2),
56 }) + "\\n");
57});
58`,
59 );
60 yield* fs.chmod(cliPath, 0o700);
61 yield* fs.writeFileString(
62 path.join(bin, packageManager),
63 `#!/usr/bin/env node
64const fs = require("node:fs");
65const childProcess = require("node:child_process");
66const args = process.argv.slice(2);
67fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(args) + "\\n");
68if (args.includes("--package")) {
69 process.stdout.write(process.env.T3_TEST_CLI + "\\n");
70} else {
71 const child = childProcess.spawn(process.execPath, [process.env.T3_TEST_CLI, ...args], { stdio: "inherit" });
72 child.once("exit", (code) => { process.exitCode = code ?? 1; });
73}
74`,
75 );
76 yield* fs.chmod(path.join(bin, packageManager), 0o700);
77
78 const runServer = (port = 0) =>
79 Effect.gen(function* () {
80 const child = yield* spawner.spawn(
81 ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], {
82 cwd: fixture,
83 env: {
84 PATH: bin,
85 T3_TEST_CLI: cliPath,
86 T3_TEST_CALLS: callsPath,
87 T3_TEST_PORT: String(port),
88 },
89 detached: false,
90 stdin: Stream.make(
91 new TextEncoder().encode(buildRemoteT3RunnerScript({ packageSpec })),
92 ),
93 }),
94 );
95 const ready = yield* Deferred.make<typeof Started.Type>();
96 const stdout: string[] = [];
97 const output = yield* child.stdout.pipe(
98 Stream.decodeText(),
99 Stream.splitLines,
100 Stream.runForEach((line) =>
101 Effect.gen(function* () {
102 stdout.push(line);
103 if (stdout.length === 1) {
104 yield* Deferred.succeed(ready, decodeStarted(line));
105 }
106 }),
107 ),
108 Effect.forkScoped,
109 );
110 const stderr = yield* child.stderr.pipe(
111 Stream.decodeText(),
112 Stream.mkString,
113 Effect.forkScoped,
114 );
115 const receipt = yield* Effect.raceFirst(
116 Deferred.await(ready),
117 Fiber.join(output).pipe(
118 Effect.flatMap(() => Fiber.join(stderr)),
119 Effect.flatMap((message) =>
120 Effect.die(new Error(`Runner exited before listening: ${message}`)),
121 ),
122 ),
123 );
124 // A failed PID assertion must still close the owned fixture server, including an npm child.
125 yield* Effect.addFinalizer(() =>
126 Effect.gen(function* () {
127 if (yield* child.isRunning) {
128 yield* Effect.callback<void>((resume) => {
129 const connection = NodeNet.connect(receipt.port, "127.0.0.1");
130 connection.on("error", () => undefined);
131 connection.once("close", () => resume(Effect.void));
132 return Effect.sync(() => connection.destroy());
133 });
134 yield* child.exitCode;
135 }
136 }).pipe(Effect.orDie),
137 );
138 assert.equal(receipt.pid, child.pid);
139 assert.deepEqual(receipt.args, ["serve", "a path with spaces"]);
140 yield* child.kill({ killSignal: "SIGTERM" });
141 assert.equal(yield* child.exitCode, 0);
142 yield* Fiber.join(output);
143 assert.include(stdout, "graceful shutdown");
144 return receipt.port;
145 }).pipe(Effect.scoped);
146
147 const port = yield* runServer();
148 assert.equal(yield* runServer(port), port);
149 const calls = (yield* fs.readFileString(callsPath))
150 .trim()
151 .split("\n")
152 .map((line) => JSON.parse(line));
153 const expectedCall = [
154 ...(packageManager === "npm" ? ["exec"] : []),
155 "--yes",
156 "--package",
157 packageSpec,
158 "--",
159 "sh",
160 "-c",
161 "command -v t3",
162 ];
163 assert.deepEqual(calls, [expectedCall, expectedCall]);
164 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
165 );
166 },
167);
packages/ssh/src/tunnel.test.ts+1-4
......@@ -105,8 +105,7 @@ describe("ssh tunnel scripts", () => {
105105
106106 assert.include(script, "T3_NODE_SCRIPT_PATH=''");
107107 assert.include(script, 'exec t3 "$@"');
108 assert.include(script, "exec npx --yes 't3@latest' \"$@\"");
109 assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\"");
108 assert.include(script, 'exec "$T3_CLI_PATH" "$@"');
110109 assert.include(script, "could not install 't3@latest'");
111110 assert.include(script, "require_installed_t3_cli npx --yes --package 't3@latest'");
112111 assert.include(script, "require_installed_t3_cli npm exec --yes --package 't3@latest'");
......@@ -141,8 +140,6 @@ describe("ssh tunnel scripts", () => {
141140 packageSpec: "t3@nightly; touch /tmp/t3-owned",
142141 });
143142
144 assert.include(script, "exec npx --yes 't3@nightly; touch /tmp/t3-owned' \"$@\"");
145 assert.include(script, "exec npm exec --yes 't3@nightly; touch /tmp/t3-owned' -- \"$@\"");
146143 assert.include(
147144 script,
148145 "require_installed_t3_cli npx --yes --package 't3@nightly; touch /tmp/t3-owned'",
packages/ssh/src/tunnel.ts+3-2
......@@ -440,13 +440,14 @@ require_installed_t3_cli() {
440440 printf 'Remote host installed %s but npm produced no t3 executable, which usually means a native dependency (node-pty) failed to build. Install a C toolchain on the remote host (Debian/Ubuntu: build-essential, Fedora/RHEL: gcc-c++ make, macOS: xcode-select --install) and try again.\\n' @@T3_PACKAGE_SPEC@@ >&2
441441 return 1
442442}
443# The launcher records this PID, so exec the CLI without an npm wrapper process.
443444if command -v npx >/dev/null 2>&1; then
444445 require_installed_t3_cli npx --yes --package @@T3_PACKAGE_SPEC@@ || exit 1
445 exec npx --yes @@T3_PACKAGE_SPEC@@ "$@"
446 exec "$T3_CLI_PATH" "$@"
446447fi
447448if command -v npm >/dev/null 2>&1; then
448449 require_installed_t3_cli npm exec --yes --package @@T3_PACKAGE_SPEC@@ || exit 1
449 exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@"
450 exec "$T3_CLI_PATH" "$@"
450451fi
451452printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because node/npm/npx are unavailable on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2
452453exit 1