1// @effect-diagnostics nodeBuiltinImport:off - Executes the generated remote launcher in an isolated shell with fake commands and clock.
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4import * as NodeURL from "node:url";
5import * as NodeChildProcess from "node:child_process";
6import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Sink from "effect/Sink";
11import * as Stream from "effect/Stream";
12import { ChildProcessSpawner } from "effect/process";
13import { launchOrReuseRemoteServer, type RemoteT3RunnerOptions } from "./tunnel.ts";
14
15// Capture exactly the script sent over SSH, then run it with fake remote commands.
16export const captureLauncherFor = Effect.fn("test.captureLauncher")(function* (
17 runner: RemoteT3RunnerOptions,
18) {
19 let script = "";
20 const spawner = ChildProcessSpawner.make((command) =>
21 Effect.gen(function* () {
22 if (command._tag === "StandardCommand") {
23 const stdin = command.options.stdin;
24 if (typeof stdin === "object" && "stream" in stdin && Stream.isStream(stdin.stream)) {
25 script = yield* stdin.stream.pipe(Stream.decodeText(), Stream.mkString);
26 }
27 }
28 return ChildProcessSpawner.makeHandle({
29 pid: ChildProcessSpawner.ProcessId(123),
30 stdout: Stream.make(new TextEncoder().encode('{"remotePort":3773}\n')),
31 stderr: Stream.empty,
32 all: Stream.empty,
33 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
34 isRunning: Effect.succeed(false),
35 kill: () => Effect.void,
36 stdin: Sink.drain,
37 getInputFd: () => Sink.drain,
38 getOutputFd: () => Stream.empty,
39 unref: Effect.succeed(Effect.void),
40 });
41 }),
42 );
43 yield* launchOrReuseRemoteServer(
44 { alias: "test", hostname: "test.invalid", username: null, port: null },
45 undefined,
46 runner,
47 ).pipe(
48 Effect.provide(
49 Layer.mergeAll(
50 NodeServices.layer,
51 NodeCrypto.layer,
52 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
53 ),
54 ),
55 );
56 return script;
57});
58
59export const captureLauncher = captureLauncherFor({ nodeScriptPath: "/fake/server.ts" });
60
61export function runLauncher(
62 script: string,
63 options: {
64 readonly marker?: unknown;
65 readonly archive?: boolean;
66 readonly clearMarkerOnReady?: boolean;
67 readonly recordedKind?: "managed" | "external";
68 readonly clockJump?: number;
69 readonly readyAt?: number;
70 readonly initialPort?: number;
71 // The desktop publishes runtime and removes the marker right after the first runtime read.
72 readonly publishAfterFirstRuntimeRead?: boolean;
73 },
74) {
75 const root = NodeURL.fileURLToPath(new URL("../../../", import.meta.url));
76 const home = NodeFS.mkdtempSync(NodePath.join(root, "packages/ssh/.ssh-launcher-test-"));
77 try {
78 const bin = NodePath.join(home, "bin");
79 const runtime = NodePath.join(home, ".t3/runtime");
80 NodeFS.mkdirSync(bin);
81 NodeFS.mkdirSync(runtime, { recursive: true });
82 const markerPath = NodePath.join(runtime, "desktop-update-in-progress");
83 if (options.marker)
84 NodeFS.writeFileSync(
85 markerPath,
86 typeof options.marker === "string" ? options.marker : JSON.stringify(options.marker),
87 );
88 NodeFS.writeFileSync(NodePath.join(home, "clock"), "0");
89 NodeFS.writeFileSync(NodePath.join(home, "events"), "");
90 const executable = (name: string, body: string) =>
91 NodeFS.writeFileSync(NodePath.join(bin, name), `#!/bin/sh\nset -eu\n${body}\n`, {
92 mode: 0o755,
93 });
94 NodeFS.writeFileSync(
95 NodePath.join(home, "clock.cjs"),
96 `
97const fs = require('node:fs');
98Date.now = () => Number(fs.readFileSync(process.env.HOME + '/clock', 'utf8'));
99`,
100 );
101 executable(
102 options.archive ? "test-node" : "node",
103 `
104case "\${2:-}" in
105 */server-runtime.json)
106 # A started server publishes its runtime record, which the launch waits for.
107 # The fake nohup runs in the background, so wait for it instead of racing it.
108 if [ -e "$HOME/picked-port" ]; then
109 while [ ! -e "$HOME/started-server" ]; do /bin/sleep 0.01; done
110 fi
111 if [ -e "$HOME/started-server" ]; then
112 printf 'record %s\\n' "$(cat "$HOME/clock")" >> "$HOME/events"
113 printf '%s 5999' "$(cat "$HOME/started-server")"
114 exit 0
115 fi
116 NOW="$(cat "$HOME/clock")"
117 printf 'runtime %s\\n' "$NOW" >> "$HOME/events"
118 if [ "$NOW" -ge "$TEST_READY_AT" ] || [ -e "$2" ]; then printf '%s %s' "$TEST_SERVER_PID" 4888
119 elif [ "$TEST_PUBLISH_AFTER_FIRST_READ" = 1 ]; then
120 mkdir -p "\${2%/*}"
121 printf '{}' > "$2"
122 rm -f "$HOME/.t3/runtime/desktop-update-in-progress"
123 exit 1
124 elif [ -n "$TEST_INITIAL_PORT" ]; then printf '%s %s' "$TEST_SERVER_PID" "$TEST_INITIAL_PORT"
125 else exit 1; fi ;;
126 */port)
127 printf 'start %s\\n' "$(cat "$HOME/clock")" >> "$HOME/events"
128 touch "$HOME/picked-port"
129 printf 5999 ;;
130 [0-9]*)
131 NOW="$(cat "$HOME/clock")"
132 printf 'probe %s %s\\n' "$2" "$NOW" >> "$HOME/events"
133 [ "$2" = 5999 ] || { [ "$2" = 4888 ] && { [ "$NOW" -ge "$TEST_READY_AT" ] || [ -e "$HOME/.t3/userdata/server-runtime.json" ]; }; } ;;
134 */desktop-update-in-progress)
135 printf 'marker %s\\n' "$(cat "$HOME/clock")" >> "$HOME/events"
136 exec "$TEST_REAL_NODE" --require "$HOME/clock.cjs" "$@" ;;
137 *) exec "$TEST_REAL_NODE" --require "$HOME/clock.cjs" "$@" ;;
138esac`,
139 );
140 executable(
141 "sleep",
142 `awk -v seconds="$1" '{ printf "%.0f", $1 + seconds * 1000 }' "$HOME/clock" > "$HOME/clock.next"
143mv "$HOME/clock.next" "$HOME/clock"
144if [ "$TEST_CLEAR_MARKER" = 1 ] && [ "$(cat "$HOME/clock")" -ge "$TEST_READY_AT" ]; then
145 rm -f "$HOME/.t3/runtime/desktop-update-in-progress"
146fi
147if [ -n "$TEST_CLOCK_JUMP" ] && [ ! -e "$HOME/jumped" ]; then
148 printf '%s' "$TEST_CLOCK_JUMP" > "$HOME/clock"
149 touch "$HOME/jumped"
150fi`,
151 );
152 executable("nohup", 'printf "%s" "$$" > "$HOME/started-server"');
153 // The test process stands in for a recorded server; report the serve command it would run.
154 executable(
155 "ps",
156 `if [ "$*" = "-ww -o args= -p $TEST_SERVER_PID" ]; then
157 printf '/bin/sh %s/.t3/ssh-launch/test-target/run-t3.sh serve --host 127.0.0.1 --port 5999\\n' "$HOME"
158 exit 0
159fi
160PATH=/usr/bin:/bin exec ps "$@"`,
161 );
162 if (options.recordedKind) {
163 const state = NodePath.join(home, ".t3/ssh-launch/test-target");
164 NodeFS.mkdirSync(state, { recursive: true });
165 NodeFS.writeFileSync(NodePath.join(state, "pid"), String(process.pid));
166 NodeFS.writeFileSync(NodePath.join(state, "port"), "5999");
167 NodeFS.writeFileSync(NodePath.join(state, "managed"), options.recordedKind);
168 const runner = script.split("cat >\"$RUNNER_NEXT\" <<'SH'\n")[1]?.split("\nSH\n")[0];
169 if (!runner) throw new Error("runner missing");
170 NodeFS.writeFileSync(NodePath.join(state, "run-t3.sh"), runner + "\n");
171 }
172 if (options.archive) {
173 executable("node", 'printf "unexpected host node invocation\\n" >&2; exit 1');
174 const version = NodePath.join(home, ".t3/runtime/versions/1.2.3");
175 NodeFS.mkdirSync(version, { recursive: true });
176 NodeFS.writeFileSync(NodePath.join(version, ".install-complete"), "1.2.3");
177 NodeFS.writeFileSync(
178 NodePath.join(home, "helper.ts"),
179 `
180import * as Effect from "effect/Effect";
181import * as NodeServices from "@effect/platform-node/NodeServices";
182import { Command } from "effect/cli";
183import { sshHelperCommand } from ${JSON.stringify(NodePath.join(root, "apps/server/src/cli/sshHelper.ts"))};
184await Effect.runPromise(Command.runWith(sshHelperCommand, { version: "1.2.3" })(process.argv.slice(2)).pipe(Effect.provide(NodeServices.layer)));
185`,
186 );
187 NodeFS.writeFileSync(
188 NodePath.join(version, "t3"),
189 `#!/bin/sh
190set -eu
191if [ "$1" = --version ]; then printf 1.2.3; exit; fi
192[ "$1" = __ssh-helper ]
193case "$2" in
194 update-remaining) printf 'marker %s\\n' "$(cat "$HOME/clock")" >> "$HOME/events"; exec "$TEST_REAL_NODE" --require "$HOME/clock.cjs" "$HOME/helper.ts" "$2" "$3" ;;
195 runtime-port | pick-port | wait-ready) exec "$HOME/bin/test-node" - "$3" ;;
196 *) exit 1 ;;
197esac
198`,
199 { mode: 0o755 },
200 );
201 }
202 const result = NodeChildProcess.spawnSync("/bin/sh", ["-s", "--", "test-target"], {
203 input: `kill() {
204 if [ "$1" = -0 ]; then command kill "$@"; else
205 printf 'kill %s\\n' "$*" >> "$HOME/events"
206 return 1
207 fi
208}
209${script}`,
210 encoding: "utf8",
211 timeout: 30_000,
212 env: {
213 ...process.env,
214 HOME: home,
215 PATH: `${bin}:/usr/bin:/bin`,
216 TEST_REAL_NODE: process.execPath,
217 TEST_CLEAR_MARKER: options.clearMarkerOnReady ? "1" : "0",
218 TEST_CLOCK_JUMP: options.clockJump === undefined ? "" : String(options.clockJump),
219 TEST_SERVER_PID: String(process.pid),
220 TEST_READY_AT: String(options.readyAt ?? 1_000_000),
221 TEST_INITIAL_PORT: options.initialPort === undefined ? "" : String(options.initialPort),
222 TEST_PUBLISH_AFTER_FIRST_READ: options.publishAfterFirstRuntimeRead ? "1" : "0",
223 },
224 });
225 if (result.error || result.status !== 0)
226 throw new Error(
227 `launcher failed: ${result.error ?? result.status}\n${result.stderr}\n${result.stdout}\n${NodeFS.readFileSync(NodePath.join(home, "events"), "utf8")}`,
228 );
229 return {
230 output: JSON.parse(result.stdout.trim()),
231 events: NodeFS.readFileSync(NodePath.join(home, "events"), "utf8").trim().split("\n"),
232 now: Number(NodeFS.readFileSync(NodePath.join(home, "clock"), "utf8")),
233 markerExists: NodeFS.existsSync(markerPath),
234 };
235 } finally {
236 NodeFS.rmSync(home, { recursive: true, force: true });
237 }
238}