diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f9e7b51f862a..cfa31817a006 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2156,6 +2156,93 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { + for (const scenario of [ + { name: "stalled signing", lines: ["signing"], authError: true }, + { + name: "stalled signing during fetch-all", + lines: ["signing"], + authError: true, + fetchAll: true, + }, + { name: "a stalled network handshake", lines: ["packet"], authError: false }, + { name: "a slow fetch after signing", lines: ["signing", "packet"], authError: false }, + { + name: "a second signing request", + lines: ["signing", "packet", "signing"], + authError: true, + }, + { name: "an older SSH client", lines: ["signing"], authError: false, oldSsh: true }, + { name: "a custom SSH command", lines: ["signing"], authError: false, customSsh: true }, + ]) { + it.effect(`limits SSH authorization waiting for ${scenario.name}`, () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const outputRead = yield* Deferred.make(); + const complete = yield* Deferred.make(); + let attempts = 0; + let released = false; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("unexpected command"); + if (scenario.oldSsh && command.command === "ssh") return makeNonRepositoryHandle(); + if (command.args[0] !== "fetch") { + return makeSuccessfulHandle( + scenario.customSsh ? "core.sshcommand custom-ssh\n" : "", + ); + } + attempts++; + if (scenario.customSsh) assert.isUndefined(command.options.env?.GIT_SSH_COMMAND); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + released = true; + }), + ); + return ChildProcessSpawner.makeHandle({ + ...makeSuccessfulHandle(""), + exitCode: Deferred.await(complete).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + stderr: Stream.encodeText( + Stream.make( + scenario.lines + .map((line) => + line === "signing" + ? "debug3: sshconnect2.c:sign_and_send_pubkey():1407 (bin=/usr/bin/ssh, pid=1): signing using ssh-ed25519 SHA256:test\n" + : "debug3: packet.c:ssh_packet_send2_wrapped():1245 (bin=/usr/bin/ssh, pid=1): send packet: type 50\n", + ) + .join(""), + ).pipe(Stream.ensuring(Deferred.succeed(outputRead, undefined))), + ), + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(ServerConfigLayer), + ); + const fetching = yield* driver + .fetchRemote({ + cwd, + remoteName: "origin", + ...(scenario.fetchAll ? {} : { refName: "main" }), + }) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(outputRead); + yield* TestClock.adjust("11 seconds"); + if (!scenario.authError) yield* Deferred.succeed(complete, undefined); + const result = yield* Fiber.join(fetching); + if (scenario.authError) { + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) + assert.include(result.failure.detail, "SSH key authorization"); + } else { + assert.isTrue(Result.isSuccess(result)); + } + assert.equal(attempts, 1); + assert.isTrue(released); + }), + ); + } + for (const failure of ["offline", "auth", "timeout"] as const) { it.effect(`does not retry a scoped fetch after ${failure}`, () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f11471235f1b..82a20a04204d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -11,6 +11,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -3271,7 +3272,89 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* env: STATUS_UPSTREAM_REFRESH_ENV, fallbackErrorDetail: `git fetch ${input.remoteName} failed`, }; - const fetchAll = executeGit("GitVcsDriver.fetchRemote", input.cwd, args, options); + const sshConfig = yield* executeGit( + "GitVcsDriver.fetchRemote.sshConfig", + input.cwd, + ["config", "--get-regexp", "^(core\\.sshcommand|ssh\\.variant)$"], + { allowNonZeroExit: true }, + ); + // ponytail: instrument only Git's default OpenSSH transport; custom commands need their own diagnostics. + const monitorSsh = + !process.env.GIT_SSH_COMMAND && + !process.env.GIT_SSH && + !process.env.GIT_SSH_VARIANT && + sshConfig.exitCode <= 1 && + !sshConfig.stdout.trim() && + (yield* commandSpawner + .exitCode( + ChildProcess.make( + "ssh", + ["-G", "-F", "none", "-o", "LogVerbose=sshconnect2.c:*:*", "localhost"], + { stdout: "ignore", stderr: "ignore" }, + ), + ) + .pipe( + Effect.map((code) => code === 0), + Effect.timeout("1 second"), + Effect.orElseSucceed(() => false), + )); + const fetch = Effect.fnUntraced(function* ( + fetchArgs: readonly string[], + allowNonZeroExit = false, + ) { + const signing = yield* Queue.sliding(1); + const execution = executeGitWithStableDiagnostics( + "GitVcsDriver.fetchRemote", + input.cwd, + fetchArgs, + { + ...options, + allowNonZeroExit, + ...(monitorSsh + ? { + env: { + ...options.env, + GIT_SSH_COMMAND: + "ssh -o 'LogVerbose=sshconnect2.c:sign_and_send_pubkey():*,packet.c:ssh_packet_send2_wrapped():*'", + }, + progress: { + onStderrLine: (line: string) => + Queue.offer( + signing, + line.startsWith("debug3:") && + line.includes("sign_and_send_pubkey") && + line.includes("signing using "), + ).pipe(Effect.asVoid), + }, + } + : {}), + }, + ); + if (!monitorSsh) return yield* execution; + // A sent packet clears the signing deadline before waiting on the network or fetching a pack. + return yield* execution.pipe( + Effect.raceFirst( + Stream.fromQueue(signing).pipe( + Stream.debounce("10 seconds"), + Stream.filter((pending) => pending), + Stream.runHead, + Effect.flatMap( + () => + new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.fetchRemote", + cwd: input.cwd, + args: fetchArgs, + }), + detail: + "SSH key authorization timed out. Unlock or approve your SSH key on the computer running T3 Code, then retry, or use an HTTPS remote.", + }), + ), + ), + ), + ); + }); + const fetchAll = fetch(args); if (input.refName === undefined) { return yield* fetchAll.pipe(Effect.asVoid); } @@ -3282,12 +3365,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...args, `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`, ]; - const result = yield* executeGitWithStableDiagnostics( - "GitVcsDriver.fetchRemote", - input.cwd, - scopedArgs, - { ...options, allowNonZeroExit: true }, - ); + const result = yield* fetch(scopedArgs, true); if (result.exitCode === 0) return; if ( result.stderr