| author | |
| committer | |
| log | 13c134c10be163fa5345dd7c39371b6089e352bb |
| tree | 42eb6f2fadf6d8351fa3309b6264b81914fd35e6 |
| parent | 8f90b380f676735f1e3026b5173c45c22d265583 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>5 files changed, 528 insertions(+), 3 deletions(-)
apps/desktop/src/main.ts+6| ... | ... | @@ -17,6 +17,7 @@ import * as Electron from "electron"; |
| 17 | 17 | |
| 18 | 18 | import * as NetService from "@t3tools/shared/Net"; |
| 19 | 19 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 20 | import { isArchiveDistributedVersion } from "@t3tools/shared/cliRelease"; | |
| 20 | 21 | import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; |
| 21 | 22 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; |
| 22 | 23 | import serverPackageJson from "../../server/package.json" with { type: "json" }; |
| ... | ... | @@ -97,6 +98,11 @@ const resolveDesktopSshCliRunner = ( |
| 97 | 98 | nodeEngineRange: serverPackageJson.engines.node, |
| 98 | 99 | }; |
| 99 | 100 | } |
| 101 | // Preview builds ship as self-contained archives, so the remote runs the | |
| 102 | // same version this app is on without Node or npm. | |
| 103 | if (!environment.isDevelopment && isArchiveDistributedVersion(environment.appVersion)) { | |
| 104 | return { archiveVersion: environment.appVersion }; | |
| 105 | } | |
| 100 | 106 | return { |
| 101 | 107 | packageSpec: resolveRemoteT3CliPackageSpec({ |
| 102 | 108 | appVersion: environment.appVersion, |
apps/server/src/bin.ts+2| ... | ... | @@ -20,6 +20,7 @@ import { serviceCommand } from "./cli/service.ts"; |
| 20 | 20 | import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; |
| 21 | 21 | import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; |
| 22 | 22 | import { servicePreflightCommand } from "./cli/servicePreflight.ts"; |
| 23 | import { sshHelperCommand } from "./cli/sshHelper.ts"; | |
| 23 | 24 | import { themeCommand } from "./cli/theme.ts"; |
| 24 | 25 | import { triageCommand } from "./cli/triage.ts"; |
| 25 | 26 | |
| ... | ... | @@ -64,6 +65,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 64 | 65 | serviceLauncherCommand, |
| 65 | 66 | claudeHistoryCommand, |
| 66 | 67 | servicePreflightCommand, |
| 68 | sshHelperCommand, | |
| 67 | 69 | themeCommand, |
| 68 | 70 | triageCommand, |
| 69 | 71 | cloudEnabled ? connectCommand : connectUnavailableCommand, |
apps/server/src/cli/sshHelper.ts created+126| ... | ... | @@ -0,0 +1,126 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | // @effect-diagnostics globalTimers:off | |
| 3 | // @effect-diagnostics globalDateInEffect:off | |
| 4 | // The helpers mirror the inline Node snippets the SSH launch script used to | |
| 5 | // run, byte for byte in behaviour, so they stay on plain Node APIs. | |
| 6 | import * as NodeFS from "node:fs"; | |
| 7 | import * as NodeHttp from "node:http"; | |
| 8 | import * as NodeNet from "node:net"; | |
| 9 | ||
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import { Argument, Command } from "effect/unstable/cli"; | |
| 12 | ||
| 13 | /** | |
| 14 | * Small helpers the SSH launch script needs on the remote host. The script | |
| 15 | * used to run these as inline `node -` snippets; archive-distributed runtimes | |
| 16 | * have no Node on the remote, so the executable provides them instead. Output | |
| 17 | * and exit codes match the snippets exactly because the shell script parses | |
| 18 | * them. | |
| 19 | */ | |
| 20 | ||
| 21 | const tryPort = (port: number) => | |
| 22 | new Promise<number | false>((resolve) => { | |
| 23 | const server = NodeNet.createServer(); | |
| 24 | server.unref(); | |
| 25 | server.once("error", () => resolve(false)); | |
| 26 | server.listen(port, "127.0.0.1", () => { | |
| 27 | server.close((error) => resolve(error ? false : port)); | |
| 28 | }); | |
| 29 | }); | |
| 30 | ||
| 31 | /** Prints the first free loopback port from the preferred one, scanning `window` ports. */ | |
| 32 | const pickPort = Command.make("pick-port", { | |
| 33 | portFile: Argument.string("port-file"), | |
| 34 | defaultPort: Argument.integer("default-port"), | |
| 35 | scanWindow: Argument.integer("scan-window"), | |
| 36 | }).pipe( | |
| 37 | Command.withHandler(({ portFile, defaultPort, scanWindow }) => | |
| 38 | Effect.promise(async () => { | |
| 39 | const raw = NodeFS.existsSync(portFile) ? NodeFS.readFileSync(portFile, "utf8").trim() : ""; | |
| 40 | const preferred = Number.parseInt(raw, 10); | |
| 41 | const start = Number.isInteger(preferred) ? preferred : defaultPort; | |
| 42 | for (let port = start; port < start + scanWindow; port += 1) { | |
| 43 | if (await tryPort(port)) { | |
| 44 | process.stdout.write(String(port)); | |
| 45 | return; | |
| 46 | } | |
| 47 | } | |
| 48 | process.exitCode = 1; | |
| 49 | }), | |
| 50 | ), | |
| 51 | ); | |
| 52 | ||
| 53 | const probe = (port: number, probeTimeoutMs: number) => | |
| 54 | new Promise<boolean>((resolve) => { | |
| 55 | const request = NodeHttp.get( | |
| 56 | { hostname: "127.0.0.1", port, path: "/", timeout: probeTimeoutMs }, | |
| 57 | (response) => { | |
| 58 | response.resume(); | |
| 59 | response.once("end", () => { | |
| 60 | const status = response.statusCode ?? 0; | |
| 61 | resolve(status >= 200 && status < 300); | |
| 62 | }); | |
| 63 | }, | |
| 64 | ); | |
| 65 | request.once("timeout", () => { | |
| 66 | request.destroy(); | |
| 67 | resolve(false); | |
| 68 | }); | |
| 69 | request.once("error", () => resolve(false)); | |
| 70 | }); | |
| 71 | ||
| 72 | /** Exits 0 once the loopback server answers, 1 when the deadline passes first. */ | |
| 73 | const waitReady = Command.make("wait-ready", { | |
| 74 | port: Argument.integer("port"), | |
| 75 | timeoutMs: Argument.integer("timeout-ms"), | |
| 76 | probeTimeoutMs: Argument.integer("probe-timeout-ms"), | |
| 77 | }).pipe( | |
| 78 | Command.withHandler(({ port, timeoutMs, probeTimeoutMs }) => | |
| 79 | Effect.promise(async () => { | |
| 80 | const deadline = Date.now() + timeoutMs; | |
| 81 | while (Date.now() < deadline) { | |
| 82 | if (await probe(port, probeTimeoutMs)) return; | |
| 83 | await new Promise((resolve) => setTimeout(resolve, 100)); | |
| 84 | } | |
| 85 | process.exitCode = 1; | |
| 86 | }), | |
| 87 | ), | |
| 88 | ); | |
| 89 | ||
| 90 | /** Prints `<pid> <port>` for a live default-home server, or exits 1. */ | |
| 91 | const runtimePort = Command.make("runtime-port", { | |
| 92 | runtimeFile: Argument.string("runtime-file"), | |
| 93 | }).pipe( | |
| 94 | Command.withHandler(({ runtimeFile }) => | |
| 95 | Effect.sync(() => { | |
| 96 | try { | |
| 97 | // @effect-diagnostics-next-line preferSchemaOverJson:off - mirrors the shell snippet's loose parse. | |
| 98 | const runtime = JSON.parse(NodeFS.readFileSync(runtimeFile, "utf8")) as { | |
| 99 | pid?: unknown; | |
| 100 | port?: unknown; | |
| 101 | origin?: unknown; | |
| 102 | }; | |
| 103 | const pid = Number(runtime.pid); | |
| 104 | const port = Number(runtime.port); | |
| 105 | if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port)) { | |
| 106 | process.exitCode = 1; | |
| 107 | return; | |
| 108 | } | |
| 109 | const origin = new URL(String(runtime.origin ?? "")); | |
| 110 | if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) { | |
| 111 | process.exitCode = 1; | |
| 112 | return; | |
| 113 | } | |
| 114 | process.kill(pid, 0); | |
| 115 | process.stdout.write(`${pid} ${port}`); | |
| 116 | } catch { | |
| 117 | process.exitCode = 1; | |
| 118 | } | |
| 119 | }), | |
| 120 | ), | |
| 121 | ); | |
| 122 | ||
| 123 | export const sshHelperCommand = Command.make("__ssh-helper").pipe( | |
| 124 | Command.unlisted, | |
| 125 | Command.withSubcommands([pickPort, waitReady, runtimePort]), | |
| 126 | ); |
packages/ssh/src/tunnel.test.ts+217| ... | ... | @@ -1,10 +1,12 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import * as NetService from "@t3tools/shared/Net"; |
| 4 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | 5 | import * as Deferred from "effect/Deferred"; |
| 5 | 6 | import * as Duration from "effect/Duration"; |
| 6 | 7 | import * as Effect from "effect/Effect"; |
| 7 | 8 | import * as Fiber from "effect/Fiber"; |
| 9 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | 10 | import * as Layer from "effect/Layer"; |
| 9 | 11 | import * as Result from "effect/Result"; |
| 10 | 12 | import * as Sink from "effect/Sink"; |
| ... | ... | @@ -20,6 +22,7 @@ import { |
| 20 | 22 | buildRemotePairingScript, |
| 21 | 23 | buildRemoteStopScript, |
| 22 | 24 | buildRemoteT3RunnerScript, |
| 25 | SshInvalidArchiveVersionError, | |
| 23 | 26 | describeReadinessCause, |
| 24 | 27 | issueRemotePairingToken, |
| 25 | 28 | launchOrReuseRemoteServer, |
| ... | ... | @@ -130,6 +133,80 @@ describe("ssh tunnel scripts", () => { |
| 130 | 133 | assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); |
| 131 | 134 | }); |
| 132 | 135 | |
| 136 | it("installs and runs the release archive when an archive version is set", () => { | |
| 137 | const script = buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }); | |
| 138 | ||
| 139 | assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); | |
| 140 | assert.include( | |
| 141 | script, | |
| 142 | "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'", | |
| 143 | ); | |
| 144 | assert.include(script, 'T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION"'); | |
| 145 | assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"'); | |
| 146 | assert.include(script, "SHA256SUMS"); | |
| 147 | assert.include(script, 'exec "$T3_RUNTIME_DIR/t3" "$@"'); | |
| 148 | // Concurrent launches serialize on a per-version mkdir lock and recheck | |
| 149 | // the completion marker after acquiring it. | |
| 150 | assert.include( | |
| 151 | script, | |
| 152 | 'T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock"', | |
| 153 | ); | |
| 154 | // mkdir is the exclusive create; the pid follows atomically. A dead owner | |
| 155 | // is reclaimed at once, a never-published owner after a short grace. | |
| 156 | assert.include(script, 'while ! mkdir "$T3_LOCK" 2>/dev/null; do'); | |
| 157 | assert.include(script, 'mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid"'); | |
| 158 | assert.include(script, 'if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then'); | |
| 159 | assert.include(script, 'if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then'); | |
| 160 | assert.include(script, 'if [ "$T3_LOCK_WAITED" -ge 360 ]; then'); | |
| 161 | assert.include(script, '"$T3_STAGING/SHA256SUMS" 30'); | |
| 162 | assert.include(script, '"$T3_STAGING/$T3_ARCHIVE" 240'); | |
| 163 | assert.notInclude(script, "T3_LOCK_CANDIDATE"); | |
| 164 | assert.notInclude(script, "-mmin"); | |
| 165 | assert.equal(script.split("if ! t3_runtime_ready; then").length - 1, 2); | |
| 166 | assert.isBelow( | |
| 167 | script.indexOf('"$T3_STAGING/t3" --version'), | |
| 168 | script.indexOf('> "$T3_STAGING/.install-complete"'), | |
| 169 | ); | |
| 170 | // The archive branch execs before any of the Node discovery runs. | |
| 171 | assert.isBelow( | |
| 172 | script.indexOf('exec "$T3_RUNTIME_DIR/t3"'), | |
| 173 | script.indexOf("prepend_path_if_dir()"), | |
| 174 | ); | |
| 175 | ||
| 176 | const launch = buildRemoteLaunchScript({ | |
| 177 | archiveVersion: "1.2.3-preview.20260911.4", | |
| 178 | releaseBaseUrl: "https://mirror.example/t3/", | |
| 179 | }); | |
| 180 | assert.include(launch, "T3_ARCHIVE_MODE=1"); | |
| 181 | assert.include(launch, "T3_RELEASE_BASE_URL='https://mirror.example/t3'"); | |
| 182 | assert.include(launch, '"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE"'); | |
| 183 | assert.include(launch, '"$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT"'); | |
| 184 | assert.include(launch, '"$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"'); | |
| 185 | assert.include(buildRemoteLaunchScript(), "T3_ARCHIVE_MODE=0"); | |
| 186 | }); | |
| 187 | ||
| 188 | it("rejects archive versions that are not a single exact version segment", () => { | |
| 189 | for (const archiveVersion of [ | |
| 190 | "../other", | |
| 191 | "1.2.3/evil", | |
| 192 | "1.2.3\\evil", | |
| 193 | "1.2.3-preview.1 x", | |
| 194 | "1.2.3-preview.1\nrm -rf /", | |
| 195 | "v1.2.3", | |
| 196 | ]) { | |
| 197 | assert.throws( | |
| 198 | () => buildRemoteT3RunnerScript({ archiveVersion }), | |
| 199 | SshInvalidArchiveVersionError, | |
| 200 | undefined, | |
| 201 | archiveVersion, | |
| 202 | ); | |
| 203 | } | |
| 204 | assert.include( | |
| 205 | buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }), | |
| 206 | "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", | |
| 207 | ); | |
| 208 | }); | |
| 209 | ||
| 133 | 210 | it("does not hard-code a remote node engine range", () => { |
| 134 | 211 | const script = buildRemoteT3RunnerScript(); |
| 135 | 212 | |
| ... | ... | @@ -282,6 +359,33 @@ describe("ssh tunnel scripts", () => { |
| 282 | 359 | }).pipe(Effect.provide(processLayer)); |
| 283 | 360 | }); |
| 284 | 361 | |
| 362 | it.effect("gives cold archive launches a larger budget than npm launches", () => { | |
| 363 | const target = { | |
| 364 | alias: "devbox", | |
| 365 | hostname: "devbox.example.com", | |
| 366 | username: "julius", | |
| 367 | port: 2222, | |
| 368 | } as const; | |
| 369 | const spawner = ChildProcessSpawner.make(() => | |
| 370 | Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 800_000)), | |
| 371 | ); | |
| 372 | const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); | |
| 373 | const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); | |
| 374 | ||
| 375 | return Effect.gen(function* () { | |
| 376 | const fiber = yield* Effect.forkChild( | |
| 377 | launchOrReuseRemoteServer(target, undefined, { | |
| 378 | archiveVersion: "1.2.3-preview.20260911.4", | |
| 379 | }), | |
| 380 | ); | |
| 381 | yield* Effect.yieldNow; | |
| 382 | yield* TestClock.adjust(Duration.seconds(800)); | |
| 383 | ||
| 384 | const result = yield* Fiber.join(fiber); | |
| 385 | assert.equal(result.remotePort, 3774); | |
| 386 | }).pipe(Effect.provide(processLayer)); | |
| 387 | }); | |
| 388 | ||
| 285 | 389 | it("allows the remote port picker to run without a state file path", () => { |
| 286 | 390 | assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); |
| 287 | 391 | }); |
| ... | ... | @@ -588,3 +692,116 @@ describe("ssh tunnel scripts", () => { |
| 588 | 692 | }), |
| 589 | 693 | ); |
| 590 | 694 | }); |
| 695 | ||
| 696 | // The archive runner is generated shell; string assertions cannot prove the | |
| 697 | // lock excludes concurrent installers. Run the real script against a tiny | |
| 698 | // fake archive served from a file:// mirror. | |
| 699 | describe("archive runner script", () => { | |
| 700 | const hostPlatform = HostProcessPlatform.defaultValue(); | |
| 701 | const hostArch = HostProcessArchitecture.defaultValue(); | |
| 702 | const windowsHost = hostPlatform === "win32"; | |
| 703 | const archiveVersion = "1.2.3-preview.20260911.4"; | |
| 704 | ||
| 705 | const runRunner = (home: string, runner: string) => | |
| 706 | Effect.gen(function* () { | |
| 707 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 708 | const child = yield* spawner.spawn( | |
| 709 | ChildProcess.make("sh", [runner, "--version"], { | |
| 710 | env: { PATH: process.env.PATH ?? "", HOME: home }, | |
| 711 | extendEnv: false, | |
| 712 | }), | |
| 713 | ); | |
| 714 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 715 | [ | |
| 716 | child.stdout.pipe( | |
| 717 | Stream.decodeText(), | |
| 718 | Stream.runFold( | |
| 719 | () => "", | |
| 720 | (acc, chunk) => acc + chunk, | |
| 721 | ), | |
| 722 | ), | |
| 723 | child.stderr.pipe( | |
| 724 | Stream.decodeText(), | |
| 725 | Stream.runFold( | |
| 726 | () => "", | |
| 727 | (acc, chunk) => acc + chunk, | |
| 728 | ), | |
| 729 | ), | |
| 730 | child.exitCode.pipe(Effect.map(Number)), | |
| 731 | ], | |
| 732 | { concurrency: "unbounded" }, | |
| 733 | ); | |
| 734 | return { stdout, stderr, exitCode }; | |
| 735 | }); | |
| 736 | ||
| 737 | // A fake "executable" that answers --version, packed the way the release | |
| 738 | // workflow packs the real archive: one top-level directory named after the | |
| 739 | // stem, checksummed in SHA256SUMS. | |
| 740 | const makeMirror = Effect.fn("makeMirror")(function* (root: string) { | |
| 741 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 742 | const platform = hostPlatform === "darwin" ? "darwin" : "linux"; | |
| 743 | const arch = hostArch === "arm64" ? "arm64" : "x64"; | |
| 744 | const stem = `t3-${archiveVersion}-${platform}-${arch}`; | |
| 745 | const stage = `${root}/stage/${stem}`; | |
| 746 | const release = `${root}/mirror/v${archiveVersion}`; | |
| 747 | const script = [ | |
| 748 | "set -eu", | |
| 749 | `mkdir -p '${stage}' '${release}'`, | |
| 750 | `printf '#!/bin/sh\\necho t3 v${archiveVersion}\\n' > '${stage}/t3'`, | |
| 751 | `chmod +x '${stage}/t3'`, | |
| 752 | `tar -czf '${release}/${stem}.tar.gz' -C '${root}/stage' '${stem}'`, | |
| 753 | `cd '${release}' && (sha256sum '${stem}.tar.gz' 2>/dev/null || shasum -a 256 '${stem}.tar.gz') > SHA256SUMS`, | |
| 754 | ].join("\n"); | |
| 755 | const child = yield* spawner.spawn(ChildProcess.make("sh", ["-c", script])); | |
| 756 | assert.equal(Number(yield* child.exitCode), 0); | |
| 757 | return `file://${root}/mirror`; | |
| 758 | }); | |
| 759 | ||
| 760 | it.effect.skipIf(windowsHost)( | |
| 761 | "installs once when several launches race, and reclaims stale locks", | |
| 762 | () => | |
| 763 | Effect.gen(function* () { | |
| 764 | const fs = yield* FileSystem.FileSystem; | |
| 765 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-archive-runner-" }); | |
| 766 | const releaseBaseUrl = yield* makeMirror(root); | |
| 767 | const runner = `${root}/run-t3.sh`; | |
| 768 | yield* fs.writeFileString( | |
| 769 | runner, | |
| 770 | buildRemoteT3RunnerScript({ archiveVersion, releaseBaseUrl }), | |
| 771 | ); | |
| 772 | const home = `${root}/home`; | |
| 773 | yield* fs.makeDirectory(home, { recursive: true }); | |
| 774 | ||
| 775 | const results = yield* Effect.all( | |
| 776 | [runRunner(home, runner), runRunner(home, runner), runRunner(home, runner)], | |
| 777 | { concurrency: "unbounded" }, | |
| 778 | ); | |
| 779 | for (const result of results) { | |
| 780 | assert.equal(result.exitCode, 0, result.stderr); | |
| 781 | assert.include(result.stdout, `t3 v${archiveVersion}`); | |
| 782 | } | |
| 783 | const versionsDir = `${home}/.t3/runtime/versions`; | |
| 784 | assert.deepEqual(yield* fs.readDirectory(versionsDir), [archiveVersion]); | |
| 785 | assert.equal( | |
| 786 | (yield* fs.readFileString(`${versionsDir}/${archiveVersion}/.install-complete`)).trim(), | |
| 787 | archiveVersion, | |
| 788 | ); | |
| 789 | ||
| 790 | // A lock left by a crashed installer (dead pid) must not block the | |
| 791 | // next launch, and neither must one that never published a pid. | |
| 792 | const lock = `${versionsDir}/.${archiveVersion}.install.lock`; | |
| 793 | yield* fs.remove(`${versionsDir}/${archiveVersion}`, { recursive: true }); | |
| 794 | yield* fs.makeDirectory(lock); | |
| 795 | yield* fs.writeFileString(`${lock}/pid`, "999999\n"); | |
| 796 | const afterDead = yield* runRunner(home, runner); | |
| 797 | assert.equal(afterDead.exitCode, 0, afterDead.stderr); | |
| 798 | ||
| 799 | yield* fs.remove(`${versionsDir}/${archiveVersion}`, { recursive: true }); | |
| 800 | yield* fs.makeDirectory(lock); | |
| 801 | const afterUnowned = yield* runRunner(home, runner); | |
| 802 | assert.equal(afterUnowned.exitCode, 0, afterUnowned.stderr); | |
| 803 | assert.isFalse(yield* fs.exists(lock)); | |
| 804 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 805 | 60_000, | |
| 806 | ); | |
| 807 | }); |
packages/ssh/src/tunnel.ts+177-3| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | describeReadinessCause, |
| 7 | 7 | waitForHttpReady as waitForHttpReadyShared, |
| 8 | 8 | } from "@t3tools/shared/httpReadiness"; |
| 9 | import { cliReleaseDownloadBaseUrl } from "@t3tools/shared/cliRelease"; | |
| 9 | 10 | import * as NetService from "@t3tools/shared/Net"; |
| 10 | 11 | import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 11 | 12 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; |
| ... | ... | @@ -56,12 +57,28 @@ const SSH_READY_PROBE_TIMEOUT_MS = 1_000; |
| 56 | 57 | const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; |
| 57 | 58 | const REMOTE_READY_TIMEOUT_MS = 60_000; |
| 58 | 59 | const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; |
| 60 | // A cold archive launch also downloads and unpacks a ~70 MB release archive | |
| 61 | // and may wait on another installer's lock. The budgets nest: the checksum | |
| 62 | // file is tiny and the archive download is bounded; a waiter outlasts both | |
| 63 | // downloads plus extraction so it can reuse the result; and the SSH command | |
| 64 | // outlasts an install (own or waited-for) plus readiness, with slack for | |
| 65 | // verification and extraction, which have no timeout of their own. | |
| 66 | const REMOTE_ARCHIVE_CHECKSUMS_SECONDS = 30; | |
| 67 | const REMOTE_ARCHIVE_DOWNLOAD_SECONDS = 240; | |
| 68 | const REMOTE_ARCHIVE_LOCK_WAIT_SECONDS = 360; | |
| 69 | const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000; | |
| 59 | 70 | const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; |
| 60 | 71 | |
| 61 | 72 | export interface RemoteT3RunnerOptions { |
| 62 | 73 | readonly packageSpec?: string; |
| 63 | 74 | readonly nodeScriptPath?: string | null; |
| 64 | 75 | readonly nodeEngineRange?: string | null; |
| 76 | /** | |
| 77 | * Exact version whose release archive the remote installs and runs. Takes | |
| 78 | * precedence over `packageSpec`; the remote then needs neither Node nor npm. | |
| 79 | */ | |
| 80 | readonly archiveVersion?: string | null; | |
| 81 | readonly releaseBaseUrl?: string | null; | |
| 65 | 82 | } |
| 66 | 83 | |
| 67 | 84 | export interface SshEnvironmentManagerOptions { |
| ... | ... | @@ -108,6 +125,9 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { |
| 108 | 125 | } |
| 109 | 126 | |
| 110 | 127 | function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) { |
| 128 | if (runner?.archiveVersion?.trim()) { | |
| 129 | return { runner: "archive", archiveVersion: runner.archiveVersion.trim() }; | |
| 130 | } | |
| 111 | 131 | if (runner?.nodeScriptPath?.trim()) { |
| 112 | 132 | return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() }; |
| 113 | 133 | } |
| ... | ... | @@ -404,6 +424,103 @@ ensure_remote_node_path() { |
| 404 | 424 | |
| 405 | 425 | const REMOTE_RUNNER_SCRIPT = `#!/bin/sh |
| 406 | 426 | set -eu |
| 427 | T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@ | |
| 428 | if [ -n "$T3_ARCHIVE_VERSION" ]; then | |
| 429 | # Self-contained release archive: no Node, npm, or compiler on the remote. | |
| 430 | # Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it. | |
| 431 | T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@ | |
| 432 | T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION" | |
| 433 | t3_runtime_ready() { | |
| 434 | [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ] | |
| 435 | } | |
| 436 | if ! t3_runtime_ready; then | |
| 437 | mkdir -p "$HOME/.t3/runtime/versions" | |
| 438 | # Concurrent launches (two clients, a retry racing a slow first run) must | |
| 439 | # not both install: mkdir is the atomic lock and the ready check repeats | |
| 440 | # under it. | |
| 441 | T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock" | |
| 442 | # mkdir is the only portable atomic exclusive create (mv would silently | |
| 443 | # nest a candidate inside an existing lock). The owner publishes its pid | |
| 444 | # right after, so a lock with a live owner is never reclaimed however | |
| 445 | # slow its download is, and a lock whose owner is dead is reclaimed at | |
| 446 | # once. A lock with no pid at all is a crash between mkdir and the pid | |
| 447 | # write; it is reclaimed after a short grace so a live owner has time to | |
| 448 | # publish. | |
| 449 | T3_LOCK_WAITED=0 | |
| 450 | T3_LOCK_UNOWNED=0 | |
| 451 | while ! mkdir "$T3_LOCK" 2>/dev/null; do | |
| 452 | T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)" | |
| 453 | if [ -n "$T3_LOCK_OWNER" ]; then | |
| 454 | T3_LOCK_UNOWNED=0 | |
| 455 | if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then | |
| 456 | rm -rf "$T3_LOCK" | |
| 457 | continue | |
| 458 | fi | |
| 459 | else | |
| 460 | T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1)) | |
| 461 | if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then | |
| 462 | rm -rf "$T3_LOCK" | |
| 463 | continue | |
| 464 | fi | |
| 465 | fi | |
| 466 | if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then | |
| 467 | printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2 | |
| 468 | exit 1 | |
| 469 | fi | |
| 470 | sleep 1 | |
| 471 | T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1)) | |
| 472 | done | |
| 473 | printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid" | |
| 474 | trap 'rm -rf "$T3_LOCK"' EXIT | |
| 475 | fi | |
| 476 | if ! t3_runtime_ready; then | |
| 477 | case "$(uname -s)" in | |
| 478 | Darwin) T3_PLATFORM="darwin" ;; | |
| 479 | Linux) T3_PLATFORM="linux" ;; | |
| 480 | *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;; | |
| 481 | esac | |
| 482 | case "$(uname -m)" in | |
| 483 | arm64 | aarch64) T3_ARCH="arm64" ;; | |
| 484 | x86_64 | amd64) T3_ARCH="x64" ;; | |
| 485 | *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;; | |
| 486 | esac | |
| 487 | T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz" | |
| 488 | T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")" | |
| 489 | trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT | |
| 490 | t3_fetch() { | |
| 491 | if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2" | |
| 492 | elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2" | |
| 493 | else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1 | |
| 494 | fi | |
| 495 | } | |
| 496 | t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@ | |
| 497 | t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@ | |
| 498 | T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)" | |
| 499 | if command -v sha256sum >/dev/null 2>&1; then | |
| 500 | T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" | |
| 501 | else | |
| 502 | T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" | |
| 503 | fi | |
| 504 | if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then | |
| 505 | printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1 | |
| 506 | fi | |
| 507 | tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 | |
| 508 | rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS" | |
| 509 | # Prove the binary runs here (libc, arch) before marking it ready, or every | |
| 510 | # later launch would exec a broken install instead of retrying. | |
| 511 | if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then | |
| 512 | printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1 | |
| 513 | fi | |
| 514 | printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete" | |
| 515 | rm -rf "$T3_RUNTIME_DIR" | |
| 516 | mv "$T3_STAGING" "$T3_RUNTIME_DIR" | |
| 517 | fi | |
| 518 | if [ -n "\${T3_LOCK:-}" ]; then | |
| 519 | rm -rf "$T3_LOCK" | |
| 520 | trap - EXIT | |
| 521 | fi | |
| 522 | exec "$T3_RUNTIME_DIR/t3" "$@" | |
| 523 | fi | |
| 407 | 524 | @@T3_NODE_ENV_SCRIPT@@ |
| 408 | 525 | ensure_remote_node_path || true |
| 409 | 526 | T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ |
| ... | ... | @@ -473,16 +590,30 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then |
| 473 | 590 | fi |
| 474 | 591 | mv "$RUNNER_NEXT" "$RUNNER_FILE" |
| 475 | 592 | chmod 700 "$RUNNER_FILE" |
| 476 | if ! ensure_remote_node_path; then | |
| 593 | T3_ARCHIVE_MODE=@@T3_ARCHIVE_MODE@@ | |
| 594 | if [ "$T3_ARCHIVE_MODE" = "1" ]; then | |
| 595 | # The archive ships the helpers below inside the executable; the remote | |
| 596 | # needs no Node at all. Resolving the runner once here also downloads the | |
| 597 | # archive before the port and readiness probes rely on it. | |
| 598 | "$RUNNER_FILE" --version >/dev/null | |
| 599 | elif ! ensure_remote_node_path; then | |
| 477 | 600 | printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 |
| 478 | 601 | exit 1 |
| 479 | 602 | fi |
| 480 | 603 | pick_port() { |
| 604 | if [ "$T3_ARCHIVE_MODE" = "1" ]; then | |
| 605 | "$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" | |
| 606 | return | |
| 607 | fi | |
| 481 | 608 | node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE' |
| 482 | 609 | @@T3_PICK_PORT_SCRIPT@@ |
| 483 | 610 | NODE |
| 484 | 611 | } |
| 485 | 612 | wait_ready() { |
| 613 | if [ "$T3_ARCHIVE_MODE" = "1" ]; then | |
| 614 | "$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" | |
| 615 | return | |
| 616 | fi | |
| 486 | 617 | node - "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" <<'NODE' |
| 487 | 618 | @@T3_WAIT_READY_SCRIPT@@ |
| 488 | 619 | NODE |
| ... | ... | @@ -496,6 +627,10 @@ wait_for_pid_exit() { |
| 496 | 627 | done |
| 497 | 628 | } |
| 498 | 629 | resolve_default_runtime_port() { |
| 630 | if [ "$T3_ARCHIVE_MODE" = "1" ]; then | |
| 631 | "$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE" | |
| 632 | return | |
| 633 | fi | |
| 499 | 634 | node - "$DEFAULT_RUNTIME_FILE" <<'NODE' |
| 500 | 635 | const fs = require("node:fs"); |
| 501 | 636 | const runtimePath = process.argv[2] ?? ""; |
| ... | ... | @@ -582,7 +717,11 @@ fi |
| 582 | 717 | if [ -z "$REMOTE_PORT" ]; then |
| 583 | 718 | REMOTE_PORT="$(pick_port)" || true |
| 584 | 719 | if [ -z "$REMOTE_PORT" ]; then |
| 585 | printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2 | |
| 720 | if [ "$T3_ARCHIVE_MODE" = "1" ]; then | |
| 721 | printf 'Failed to find an available port on the remote host.\\n' >&2 | |
| 722 | else | |
| 723 | printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2 | |
| 724 | fi | |
| 586 | 725 | exit 1 |
| 587 | 726 | fi |
| 588 | 727 | nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$DEFAULT_SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null & |
| ... | ... | @@ -650,13 +789,42 @@ if [ -f "$LOG_FILE" ]; then |
| 650 | 789 | fi |
| 651 | 790 | `; |
| 652 | 791 | |
| 792 | export class SshInvalidArchiveVersionError extends Schema.TaggedError<SshInvalidArchiveVersionError>()( | |
| 793 | "SshInvalidArchiveVersionError", | |
| 794 | { archiveVersion: Schema.String }, | |
| 795 | ) { | |
| 796 | override get message(): string { | |
| 797 | return `'${this.archiveVersion}' is not an exact t3 version and cannot name a runtime directory.`; | |
| 798 | } | |
| 799 | } | |
| 800 | ||
| 801 | // The version becomes a directory name the runner removes and recreates, so | |
| 802 | // it must be one exact SemVer segment: no separators, no `..`, no shell | |
| 803 | // metacharacters beyond what SemVer allows. | |
| 804 | const EXACT_ARCHIVE_VERSION = | |
| 805 | /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; | |
| 806 | ||
| 653 | 807 | export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string { |
| 654 | 808 | const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest"); |
| 655 | 809 | const nodeScriptPath = input?.nodeScriptPath?.trim() || ""; |
| 810 | const archiveVersion = input?.archiveVersion?.trim() || ""; | |
| 811 | if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) { | |
| 812 | throw new SshInvalidArchiveVersionError({ archiveVersion }); | |
| 813 | } | |
| 814 | // Strip the `/v<version>` the helper appends: the script builds URLs itself. | |
| 815 | const releaseBaseUrl = cliReleaseDownloadBaseUrl("", input?.releaseBaseUrl ?? undefined).replace( | |
| 816 | /\/v$/u, | |
| 817 | "", | |
| 818 | ); | |
| 656 | 819 | return stripTrailingNewlines( |
| 657 | 820 | applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { |
| 658 | 821 | T3_PACKAGE_SPEC: packageSpec, |
| 659 | 822 | T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath), |
| 823 | T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion), | |
| 824 | T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl), | |
| 825 | T3_ARCHIVE_LOCK_WAIT_SECONDS: String(REMOTE_ARCHIVE_LOCK_WAIT_SECONDS), | |
| 826 | T3_ARCHIVE_DOWNLOAD_SECONDS: String(REMOTE_ARCHIVE_DOWNLOAD_SECONDS), | |
| 827 | T3_ARCHIVE_CHECKSUMS_SECONDS: String(REMOTE_ARCHIVE_CHECKSUMS_SECONDS), | |
| 660 | 828 | T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), |
| 661 | 829 | }), |
| 662 | 830 | ); |
| ... | ... | @@ -673,6 +841,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string |
| 673 | 841 | |
| 674 | 842 | export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { |
| 675 | 843 | return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { |
| 844 | T3_ARCHIVE_MODE: input?.archiveVersion?.trim() ? "1" : "0", | |
| 676 | 845 | T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), |
| 677 | 846 | T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), |
| 678 | 847 | T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), |
| ... | ... | @@ -725,7 +894,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo |
| 725 | 894 | const result = yield* runSshCommand(target, { |
| 726 | 895 | remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)], |
| 727 | 896 | stdin: buildRemoteLaunchScript(runner), |
| 728 | timeoutMs: REMOTE_LAUNCH_TIMEOUT_MS, | |
| 897 | timeoutMs: runner?.archiveVersion?.trim() | |
| 898 | ? REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS | |
| 899 | : REMOTE_LAUNCH_TIMEOUT_MS, | |
| 729 | 900 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), |
| 730 | 901 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), |
| 731 | 902 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), |
| ... | ... | @@ -783,6 +954,9 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT |
| 783 | 954 | const result = yield* runSshCommand(target, { |
| 784 | 955 | remoteCommandArgs: ["sh", "-s"], |
| 785 | 956 | stdin: buildRemotePairingScript(target, runner), |
| 957 | // Pairing may be the first command on a cold remote, so it can install | |
| 958 | // the archive on the way. | |
| 959 | ...(runner?.archiveVersion?.trim() ? { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS } : {}), | |
| 786 | 960 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), |
| 787 | 961 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), |
| 788 | 962 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), |