| ... | ... | @@ -10,6 +10,7 @@ import * as Effect from "effect/Effect"; |
| 10 | 10 | import * as Fiber from "effect/Fiber"; |
| 11 | 11 | import * as FileSystem from "effect/FileSystem"; |
| 12 | 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; |
| 13 | 14 | import * as Result from "effect/Result"; |
| 14 | 15 | import * as Sink from "effect/Sink"; |
| 15 | 16 | import * as Stream from "effect/Stream"; |
| ... | ... | @@ -250,7 +251,7 @@ describe("ssh tunnel scripts", () => { |
| 250 | 251 | |
| 251 | 252 | assert.include( |
| 252 | 253 | launch, |
| 253 | | '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', |
| 254 | '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && server_running "$REMOTE_PID"', |
| 254 | 255 | ); |
| 255 | 256 | assert.include(launch, "RUNNER_CHANGED=1"); |
| 256 | 257 | assert.include(launch, "ensure_remote_node_path()"); |
| ... | ... | @@ -299,7 +300,10 @@ describe("ssh tunnel scripts", () => { |
| 299 | 300 | assert.include(launch, "resolve_default_runtime_port()"); |
| 300 | 301 | assert.include(launch, 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'); |
| 301 | 302 | assert.include(launch, "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))"); |
| 302 | | assert.include(launch, 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"'); |
| 303 | assert.include( |
| 304 | launch, |
| 305 | 'if [ -n "$DEFAULT_REMOTE_PORT" ] && [ "$REMOTE_MANAGED" = "managed" ] && [ "$DEFAULT_RUNTIME_PID" = "$REMOTE_PID" ]; then', |
| 306 | ); |
| 303 | 307 | assert.include(launch, 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); |
| 304 | 308 | assert.include(launch, 'rm -f "$PID_FILE"'); |
| 305 | 309 | assert.include(launch, "printf 'external\\n' >\"$MANAGED_FILE\""); |
| ... | ... | @@ -492,6 +496,59 @@ describe("ssh tunnel scripts", () => { |
| 492 | 496 | }).pipe(Effect.provide(layerProcess)); |
| 493 | 497 | }); |
| 494 | 498 | |
| 499 | it.effect("replaces a stale SSH forward without stopping the remote server", () => |
| 500 | Effect.gen(function* () { |
| 501 | const probeStarted = yield* Deferred.make<void>(); |
| 502 | let stale = false; |
| 503 | let forwards = 0; |
| 504 | let stops = 0; |
| 505 | let kills = 0; |
| 506 | const spawner = ChildProcessSpawner.make((command) => |
| 507 | Effect.sync(() => { |
| 508 | const args = commandArgs(command); |
| 509 | if (args.includes("-N")) { |
| 510 | forwards += 1; |
| 511 | stale = false; |
| 512 | return makeRunningProcess(() => { |
| 513 | kills += 1; |
| 514 | }); |
| 515 | } |
| 516 | if (args.includes("--")) return makeSuccessfulProcess('{"remotePort":3773}\n'); |
| 517 | if (args.includes("sh")) stops += 1; |
| 518 | return makeSuccessfulProcess(""); |
| 519 | }), |
| 520 | ); |
| 521 | const httpClient = HttpClient.make((request) => |
| 522 | stale |
| 523 | ? Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)) |
| 524 | : Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))), |
| 525 | ); |
| 526 | const layer = Layer.mergeAll( |
| 527 | NodeServices.layer, |
| 528 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 529 | Layer.succeed(HttpClient.HttpClient, httpClient), |
| 530 | Layer.succeed(NetService.NetService, testNetService), |
| 531 | SshAuth.SshPasswordPrompt.disabledLayer, |
| 532 | SshTunnel.SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), |
| 533 | ); |
| 534 | yield* Effect.gen(function* () { |
| 535 | const manager = yield* SshTunnel.SshEnvironmentManager; |
| 536 | const target = { alias: "devbox", hostname: "devbox", username: null, port: null }; |
| 537 | yield* manager.ensureEnvironment(target); |
| 538 | stale = true; |
| 539 | const reconnect = yield* Effect.forkChild(manager.ensureEnvironment(target)); |
| 540 | yield* Deferred.await(probeStarted); |
| 541 | yield* TestClock.adjust("2 seconds"); |
| 542 | yield* Fiber.join(reconnect); |
| 543 | assert.equal(forwards, 2); |
| 544 | assert.equal(kills, 1); |
| 545 | assert.equal(stops, 0); |
| 546 | yield* manager.disconnectEnvironment(target); |
| 547 | assert.equal(stops, 1); |
| 548 | }).pipe(Effect.provide(layer), Effect.scoped); |
| 549 | }), |
| 550 | ); |
| 551 | |
| 495 | 552 | it.effect.each(["successful stop", "failed stop"] as const)( |
| 496 | 553 | "closes the tunnel scope and starts fresh after a %s", |
| 497 | 554 | (mode) => { |
| ... | ... | @@ -767,6 +824,39 @@ describe("remote port picker", () => { |
| 767 | 824 | ); |
| 768 | 825 | }); |
| 769 | 826 | |
| 827 | // Runs a generated script with an isolated HOME, as an SSH remote would. |
| 828 | const runScript = (home: string, args: ReadonlyArray<string>) => |
| 829 | Effect.gen(function* () { |
| 830 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 831 | const child = yield* spawner.spawn( |
| 832 | ChildProcess.make("sh", args, { |
| 833 | env: { PATH: process.env.PATH ?? "", HOME: home }, |
| 834 | extendEnv: false, |
| 835 | }), |
| 836 | ); |
| 837 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 838 | [ |
| 839 | child.stdout.pipe( |
| 840 | Stream.decodeText(), |
| 841 | Stream.runFold( |
| 842 | () => "", |
| 843 | (acc, chunk) => acc + chunk, |
| 844 | ), |
| 845 | ), |
| 846 | child.stderr.pipe( |
| 847 | Stream.decodeText(), |
| 848 | Stream.runFold( |
| 849 | () => "", |
| 850 | (acc, chunk) => acc + chunk, |
| 851 | ), |
| 852 | ), |
| 853 | child.exitCode.pipe(Effect.map(Number)), |
| 854 | ], |
| 855 | { concurrency: "unbounded" }, |
| 856 | ); |
| 857 | return { stdout, stderr, exitCode }; |
| 858 | }); |
| 859 | |
| 770 | 860 | // The archive runner is generated shell; string assertions cannot prove the |
| 771 | 861 | // lock excludes concurrent installers. Run the real script against a tiny |
| 772 | 862 | // fake archive served from a file:// mirror. |
| ... | ... | @@ -776,37 +866,7 @@ describe("archive runner script", () => { |
| 776 | 866 | const windowsHost = hostPlatform === "win32"; |
| 777 | 867 | const archiveVersion = "1.2.3-preview.20260911.4"; |
| 778 | 868 | |
| 779 | | const runRunner = (home: string, runner: string) => |
| 780 | | Effect.gen(function* () { |
| 781 | | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 782 | | const child = yield* spawner.spawn( |
| 783 | | ChildProcess.make("sh", [runner, "--version"], { |
| 784 | | env: { PATH: process.env.PATH ?? "", HOME: home }, |
| 785 | | extendEnv: false, |
| 786 | | }), |
| 787 | | ); |
| 788 | | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 789 | | [ |
| 790 | | child.stdout.pipe( |
| 791 | | Stream.decodeText(), |
| 792 | | Stream.runFold( |
| 793 | | () => "", |
| 794 | | (acc, chunk) => acc + chunk, |
| 795 | | ), |
| 796 | | ), |
| 797 | | child.stderr.pipe( |
| 798 | | Stream.decodeText(), |
| 799 | | Stream.runFold( |
| 800 | | () => "", |
| 801 | | (acc, chunk) => acc + chunk, |
| 802 | | ), |
| 803 | | ), |
| 804 | | child.exitCode.pipe(Effect.map(Number)), |
| 805 | | ], |
| 806 | | { concurrency: "unbounded" }, |
| 807 | | ); |
| 808 | | return { stdout, stderr, exitCode }; |
| 809 | | }); |
| 869 | const runRunner = (home: string, runner: string) => runScript(home, [runner, "--version"]); |
| 810 | 870 | |
| 811 | 871 | // A fake "executable" that answers --version, packed the way the release |
| 812 | 872 | // workflow packs the real archive: one top-level directory named after the |
| ... | ... | @@ -879,3 +939,365 @@ describe("archive runner script", () => { |
| 879 | 939 | 60_000, |
| 880 | 940 | ); |
| 881 | 941 | }); |
| 942 | |
| 943 | // Reuse decisions also live in generated shell. Drive the real launch script |
| 944 | // against a fake server that answers readiness probes and writes the default |
| 945 | // runtime file the way `t3 serve` does. |
| 946 | describe("launch script", () => { |
| 947 | const windowsHost = HostProcess.Platform.defaultValue() === "win32"; |
| 948 | const FAKE_SERVER = `const fs = require("node:fs"); |
| 949 | const http = require("node:http"); |
| 950 | const path = require("node:path"); |
| 951 | const args = process.argv.slice(2); |
| 952 | const flag = (name) => args[args.indexOf(name) + 1]; |
| 953 | // --answer-once stalls every request after the first, like a busy server; |
| 954 | // --exit-after-answer exits right after the first answer. |
| 955 | const answerOnce = args.includes("--answer-once"); |
| 956 | const exitAfterAnswer = args.includes("--exit-after-answer"); |
| 957 | let answered = false; |
| 958 | const server = http.createServer((_request, response) => { |
| 959 | if (answerOnce && answered) return; |
| 960 | answered = true; |
| 961 | response.end("ok", () => { |
| 962 | if (exitAfterAnswer) process.exit(0); |
| 963 | }); |
| 964 | }); |
| 965 | server.listen(Number(flag("--port")), "127.0.0.1", () => { |
| 966 | const port = server.address().port; |
| 967 | const userdata = path.join(flag("--base-dir"), "userdata"); |
| 968 | fs.mkdirSync(userdata, { recursive: true }); |
| 969 | fs.writeFileSync( |
| 970 | path.join(userdata, "server-runtime.json"), |
| 971 | JSON.stringify({ version: 1, pid: process.pid, port, origin: "http://127.0.0.1:" + port }), |
| 972 | ); |
| 973 | console.log(port); |
| 974 | }); |
| 975 | `; |
| 976 | |
| 977 | const isAlive = (pid: number) => { |
| 978 | try { |
| 979 | process.kill(pid, 0); |
| 980 | return true; |
| 981 | } catch { |
| 982 | return false; |
| 983 | } |
| 984 | }; |
| 985 | |
| 986 | const freeLoopbackPort = Effect.callback<number>((resume) => { |
| 987 | const server = NodeNet.createServer(); |
| 988 | server.listen(0, "127.0.0.1", () => { |
| 989 | const address = server.address(); |
| 990 | server.close(() => |
| 991 | resume(Effect.succeed(typeof address === "object" && address ? address.port : 0)), |
| 992 | ); |
| 993 | }); |
| 994 | }); |
| 995 | |
| 996 | // Whether anything still accepts connections on the port. Unlike a PID |
| 997 | // check, this cannot mistake an unreaped, exited server for a running one. |
| 998 | const isListening = (port: number) => |
| 999 | Effect.callback<boolean>((resume) => { |
| 1000 | const socket = NodeNet.connect(port, "127.0.0.1"); |
| 1001 | socket.once("connect", () => { |
| 1002 | socket.destroy(); |
| 1003 | resume(Effect.succeed(true)); |
| 1004 | }); |
| 1005 | socket.once("error", () => resume(Effect.succeed(false))); |
| 1006 | }); |
| 1007 | |
| 1008 | const launchResult = (stdout: string) => JSON.parse(stdout.trim().split("\n").at(-1) ?? ""); |
| 1009 | |
| 1010 | // Writes the launch script for a fake server into a temporary HOME. The |
| 1011 | // seeded remembered port keeps the test off the default 3773, which a live |
| 1012 | // T3 on this machine may own. |
| 1013 | const prepareLaunch = Effect.gen(function* () { |
| 1014 | const fs = yield* FileSystem.FileSystem; |
| 1015 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-launch-script-" }); |
| 1016 | const home = `${root}/home`; |
| 1017 | const stateDir = `${home}/.t3/ssh-launch/test-state`; |
| 1018 | const fakeServer = `${root}/fake-t3.cjs`; |
| 1019 | const launch = `${root}/launch.sh`; |
| 1020 | yield* fs.makeDirectory(stateDir, { recursive: true }); |
| 1021 | yield* fs.writeFileString(`${stateDir}/port`, `${yield* freeLoopbackPort}\n`); |
| 1022 | yield* fs.writeFileString(fakeServer, FAKE_SERVER); |
| 1023 | yield* fs.writeFileString( |
| 1024 | launch, |
| 1025 | SshTunnel.buildRemoteLaunchScript({ nodeScriptPath: fakeServer }), |
| 1026 | ); |
| 1027 | const readManagedPid = fs |
| 1028 | .readFileString(`${stateDir}/pid`) |
| 1029 | .pipe(Effect.map((pid) => Number(pid.trim()))); |
| 1030 | // Launched servers outlive the script, so stop each PID the script |
| 1031 | // recorded, including one it recorded just before a failed assertion. |
| 1032 | // SIGKILL also ends a server a test left stopped. |
| 1033 | const launchedPids: Array<number> = []; |
| 1034 | yield* Effect.addFinalizer(() => |
| 1035 | readManagedPid.pipe( |
| 1036 | Effect.map((pid) => [...launchedPids, pid]), |
| 1037 | Effect.orElseSucceed(() => launchedPids), |
| 1038 | Effect.map((pids) => { |
| 1039 | for (const pid of pids) { |
| 1040 | if (isAlive(pid)) process.kill(pid, "SIGKILL"); |
| 1041 | } |
| 1042 | }), |
| 1043 | ), |
| 1044 | ); |
| 1045 | const runLaunchScript = runScript(home, [launch, "test-state"]); |
| 1046 | const runLaunch = runLaunchScript.pipe( |
| 1047 | Effect.map((result) => { |
| 1048 | assert.equal(result.exitCode, 0, result.stderr); |
| 1049 | return launchResult(result.stdout); |
| 1050 | }), |
| 1051 | ); |
| 1052 | const launchManaged = Effect.gen(function* () { |
| 1053 | const launched = yield* runLaunch; |
| 1054 | const pid = yield* readManagedPid; |
| 1055 | launchedPids.push(pid); |
| 1056 | assert.equal(launched.serverKind, "managed"); |
| 1057 | return { launched, pid }; |
| 1058 | }); |
| 1059 | // Starts a separate server on the default home, as a service would, and |
| 1060 | // returns the port it reports. An unreaped server runs under a parent that |
| 1061 | // never waits on it, so once it exits it lingers as a zombie. |
| 1062 | const startDefaultHomeServer = ( |
| 1063 | flags: ReadonlyArray<string>, |
| 1064 | options?: { readonly unreaped?: boolean }, |
| 1065 | ) => |
| 1066 | Effect.gen(function* () { |
| 1067 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1068 | const args = [fakeServer, "serve", "--port", "0", "--base-dir", `${home}/.t3`, ...flags]; |
| 1069 | const server = yield* spawner.spawn( |
| 1070 | options?.unreaped |
| 1071 | ? ChildProcess.make("sh", ["-c", 'node "$@" & exec sleep 600', "sh", ...args]) |
| 1072 | : ChildProcess.make("node", args), |
| 1073 | ); |
| 1074 | const port = yield* server.stdout.pipe( |
| 1075 | Stream.decodeText(), |
| 1076 | Stream.splitLines, |
| 1077 | Stream.runHead, |
| 1078 | ); |
| 1079 | return Number(Option.getOrThrow(port)); |
| 1080 | }); |
| 1081 | return { |
| 1082 | home, |
| 1083 | stateDir, |
| 1084 | launch, |
| 1085 | readManagedPid, |
| 1086 | runLaunchScript, |
| 1087 | runLaunch, |
| 1088 | launchManaged, |
| 1089 | startDefaultHomeServer, |
| 1090 | }; |
| 1091 | }); |
| 1092 | |
| 1093 | it.effect.skipIf(windowsHost)( |
| 1094 | "reuses its own managed server and yields only to a different default-home server", |
| 1095 | () => |
| 1096 | Effect.gen(function* () { |
| 1097 | const fs = yield* FileSystem.FileSystem; |
| 1098 | const { stateDir, readManagedPid, runLaunch, launchManaged, startDefaultHomeServer } = |
| 1099 | yield* prepareLaunch; |
| 1100 | const { launched, pid: managedPid } = yield* launchManaged; |
| 1101 | |
| 1102 | // The managed server wrote the default runtime file itself, so a |
| 1103 | // reconnect must keep it rather than stop it as an external server. |
| 1104 | assert.deepEqual(yield* runLaunch, launched); |
| 1105 | assert.equal(yield* readManagedPid, managedPid); |
| 1106 | assert.isTrue(isAlive(managedPid)); |
| 1107 | |
| 1108 | // A remembered port that is stale must not hide our own server: the |
| 1109 | // runtime file names its port. |
| 1110 | yield* fs.writeFileString(`${stateDir}/port`, `${yield* freeLoopbackPort}\n`); |
| 1111 | assert.deepEqual(yield* runLaunch, launched); |
| 1112 | assert.equal(yield* readManagedPid, managedPid); |
| 1113 | assert.equal( |
| 1114 | (yield* fs.readFileString(`${stateDir}/port`)).trim(), |
| 1115 | `${launched.remotePort}`, |
| 1116 | ); |
| 1117 | |
| 1118 | // A different server on the default home, such as a service, still |
| 1119 | // takes over from the managed one. |
| 1120 | const servicePort = yield* startDefaultHomeServer([]); |
| 1121 | assert.deepEqual(yield* runLaunch, { |
| 1122 | remotePort: servicePort, |
| 1123 | serverKind: "external", |
| 1124 | }); |
| 1125 | assert.isFalse(yield* isListening(launched.remotePort)); |
| 1126 | assert.isFalse(yield* fs.exists(`${stateDir}/pid`)); |
| 1127 | }).pipe(Effect.provide(NodeServices.layer)), |
| 1128 | 60_000, |
| 1129 | ); |
| 1130 | |
| 1131 | // A stopped process keeps its listener but answers nothing, like a server |
| 1132 | // stuck in a long SQLite stall. |
| 1133 | it.effect.skipIf(windowsHost)( |
| 1134 | "waits for its busy managed server instead of replacing it", |
| 1135 | () => |
| 1136 | Effect.gen(function* () { |
| 1137 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1138 | const { readManagedPid, runLaunch, launchManaged } = yield* prepareLaunch; |
| 1139 | const { launched, pid } = yield* launchManaged; |
| 1140 | |
| 1141 | // It resumes after the two 2-second probes that used to replace it. |
| 1142 | process.kill(pid, "SIGSTOP"); |
| 1143 | yield* spawner.spawn(ChildProcess.make("sh", ["-c", `sleep 5; kill -CONT ${pid}`])); |
| 1144 | assert.deepEqual(yield* runLaunch, launched); |
| 1145 | assert.equal(yield* readManagedPid, pid); |
| 1146 | assert.isTrue(isAlive(pid)); |
| 1147 | }).pipe(Effect.provide(NodeServices.layer)), |
| 1148 | 60_000, |
| 1149 | ); |
| 1150 | |
| 1151 | it.effect.skipIf(windowsHost)( |
| 1152 | "fails without stopping a managed server that never becomes ready", |
| 1153 | () => |
| 1154 | Effect.gen(function* () { |
| 1155 | const fs = yield* FileSystem.FileSystem; |
| 1156 | const { stateDir, launch, readManagedPid, runLaunchScript, launchManaged } = |
| 1157 | yield* prepareLaunch; |
| 1158 | const { launched, pid } = yield* launchManaged; |
| 1159 | |
| 1160 | // Shorten the one-minute readiness budget so the test stays fast. |
| 1161 | const script = yield* fs.readFileString(launch); |
| 1162 | assert.include(script, 'wait_ready "60000"'); |
| 1163 | yield* fs.writeFileString( |
| 1164 | launch, |
| 1165 | script.replaceAll('wait_ready "60000"', 'wait_ready "1500"'), |
| 1166 | ); |
| 1167 | process.kill(pid, "SIGSTOP"); |
| 1168 | const result = yield* runLaunchScript; |
| 1169 | assert.equal(result.exitCode, 1, result.stdout); |
| 1170 | assert.include(result.stderr, "the server was left running"); |
| 1171 | assert.isTrue(isAlive(pid)); |
| 1172 | assert.equal(yield* readManagedPid, pid); |
| 1173 | assert.equal( |
| 1174 | (yield* fs.readFileString(`${stateDir}/port`)).trim(), |
| 1175 | `${launched.remotePort}`, |
| 1176 | ); |
| 1177 | assert.equal((yield* fs.readFileString(`${stateDir}/managed`)).trim(), "managed"); |
| 1178 | }).pipe(Effect.provide(NodeServices.layer)), |
| 1179 | 60_000, |
| 1180 | ); |
| 1181 | |
| 1182 | it.effect.skipIf(windowsHost)( |
| 1183 | "still replaces its managed server when the runner changes", |
| 1184 | () => |
| 1185 | Effect.gen(function* () { |
| 1186 | const fs = yield* FileSystem.FileSystem; |
| 1187 | const { stateDir, readManagedPid, runLaunch, launchManaged } = yield* prepareLaunch; |
| 1188 | const { pid: previousPid } = yield* launchManaged; |
| 1189 | |
| 1190 | // A different runner on disk means the client updated: replace the server. |
| 1191 | yield* fs.writeFileString(`${stateDir}/run-t3.sh`, "# previous runner\n"); |
| 1192 | assert.equal((yield* runLaunch).serverKind, "managed"); |
| 1193 | const replacementPid = yield* readManagedPid; |
| 1194 | assert.notEqual(replacementPid, previousPid); |
| 1195 | assert.isFalse(isAlive(previousPid)); |
| 1196 | assert.isTrue(isAlive(replacementPid)); |
| 1197 | }).pipe(Effect.provide(NodeServices.layer)), |
| 1198 | 60_000, |
| 1199 | ); |
| 1200 | |
| 1201 | it.effect.skipIf(windowsHost)( |
| 1202 | "replaces a managed server that exited but was not reaped", |
| 1203 | () => |
| 1204 | Effect.gen(function* () { |
| 1205 | const fs = yield* FileSystem.FileSystem; |
| 1206 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1207 | const { home, stateDir, readManagedPid, runLaunch, launchManaged } = yield* prepareLaunch; |
| 1208 | const { launched, pid } = yield* launchManaged; |
| 1209 | process.kill(pid, "SIGKILL"); |
| 1210 | |
| 1211 | // A crashed server whose parent never reaps it stays a zombie, which |
| 1212 | // kill -0 still reports. Its pid file and runtime record keep naming it. |
| 1213 | const parent = yield* spawner.spawn( |
| 1214 | ChildProcess.make("sh", ["-c", "sleep 600 & echo $!; exec sleep 600"]), |
| 1215 | ); |
| 1216 | const zombie = Number( |
| 1217 | Option.getOrThrow( |
| 1218 | yield* parent.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.runHead), |
| 1219 | ), |
| 1220 | ); |
| 1221 | process.kill(zombie, "SIGKILL"); |
| 1222 | assert.include((yield* runScript(home, ["-c", `ps -o stat= -p ${zombie}`])).stdout, "Z"); |
| 1223 | yield* fs.writeFileString(`${stateDir}/pid`, `${zombie}\n`); |
| 1224 | yield* fs.writeFileString( |
| 1225 | `${home}/.t3/userdata/server-runtime.json`, |
| 1226 | JSON.stringify({ |
| 1227 | version: 1, |
| 1228 | pid: zombie, |
| 1229 | port: launched.remotePort, |
| 1230 | origin: `http://127.0.0.1:${launched.remotePort}`, |
| 1231 | }), |
| 1232 | ); |
| 1233 | |
| 1234 | assert.equal((yield* runLaunch).serverKind, "managed"); |
| 1235 | const replacementPid = yield* readManagedPid; |
| 1236 | assert.notEqual(replacementPid, zombie); |
| 1237 | assert.isTrue(isAlive(replacementPid)); |
| 1238 | }).pipe(Effect.provide(NodeServices.layer)), |
| 1239 | 60_000, |
| 1240 | ); |
| 1241 | |
| 1242 | it.effect.skipIf(windowsHost)( |
| 1243 | "adopts a server it has no managed record for instead of restarting it", |
| 1244 | () => |
| 1245 | Effect.gen(function* () { |
| 1246 | const fs = yield* FileSystem.FileSystem; |
| 1247 | const { stateDir, launchManaged, runLaunch } = yield* prepareLaunch; |
| 1248 | const { launched, pid } = yield* launchManaged; |
| 1249 | |
| 1250 | // An interrupted launch, or a recycled PID, can leave a PID that matches |
| 1251 | // the runtime file without the managed marker. A changed runner must not |
| 1252 | // turn that into a restart of a server this launcher cannot claim. |
| 1253 | yield* fs.remove(`${stateDir}/managed`); |
| 1254 | yield* fs.writeFileString(`${stateDir}/run-t3.sh`, "# superseded runner\n"); |
| 1255 | assert.deepEqual(yield* runLaunch, { |
| 1256 | remotePort: launched.remotePort, |
| 1257 | serverKind: "external", |
| 1258 | }); |
| 1259 | assert.isTrue(isAlive(pid)); |
| 1260 | }).pipe(Effect.provide(NodeServices.layer)), |
| 1261 | 60_000, |
| 1262 | ); |
| 1263 | |
| 1264 | it.effect.skipIf(windowsHost)( |
| 1265 | "keeps an adopted default-home server without launching another beside it", |
| 1266 | () => |
| 1267 | Effect.gen(function* () { |
| 1268 | const fs = yield* FileSystem.FileSystem; |
| 1269 | const { stateDir, runLaunch, startDefaultHomeServer } = yield* prepareLaunch; |
| 1270 | // Ownership files from an earlier managed launch that lost its PID. |
| 1271 | yield* fs.writeFileString(`${stateDir}/managed`, "managed\n"); |
| 1272 | const servicePort = yield* startDefaultHomeServer(["--answer-once"]); |
| 1273 | |
| 1274 | // The server answers adoption and then stalls. A second readiness |
| 1275 | // probe would launch another server on the same home. |
| 1276 | assert.deepEqual(yield* runLaunch, { remotePort: servicePort, serverKind: "external" }); |
| 1277 | assert.isFalse(yield* fs.exists(`${stateDir}/pid`)); |
| 1278 | }).pipe(Effect.provide(NodeServices.layer)), |
| 1279 | 60_000, |
| 1280 | ); |
| 1281 | |
| 1282 | it.effect.skipIf(windowsHost)( |
| 1283 | "launches a replacement when the adopted server exits during the handover", |
| 1284 | () => |
| 1285 | Effect.gen(function* () { |
| 1286 | const { readManagedPid, runLaunch, launchManaged, startDefaultHomeServer } = |
| 1287 | yield* prepareLaunch; |
| 1288 | const { pid: previousPid } = yield* launchManaged; |
| 1289 | yield* startDefaultHomeServer(["--exit-after-answer"], { unreaped: true }); |
| 1290 | |
| 1291 | // The service answers adoption and then exits while the managed |
| 1292 | // server is being stopped, staying a zombie, so the script must launch |
| 1293 | // a new server rather than return the dead one. The freed port may be |
| 1294 | // reused. |
| 1295 | const result = yield* runLaunch; |
| 1296 | assert.equal(result.serverKind, "managed"); |
| 1297 | const replacementPid = yield* readManagedPid; |
| 1298 | assert.notEqual(replacementPid, previousPid); |
| 1299 | assert.isTrue(isAlive(replacementPid)); |
| 1300 | }).pipe(Effect.provide(NodeServices.layer)), |
| 1301 | 60_000, |
| 1302 | ); |
| 1303 | }); |