diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index baed2610286..1fe2b86aae7 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -23,6 +23,7 @@ describe("sshEnvironment", () => { const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({ requestId: "prompt-1", destination: "devbox", + operation: "send-prompt-request", cause: new Error("renderer send failed"), }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index f0b5b1bd8ef..5ec7dd65d1e 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -17,7 +17,13 @@ interface SentMessage { readonly args: readonly unknown[]; } -function makeTestWindow() { +function makeTestWindow( + options: { + readonly isDestroyedError?: unknown; + readonly isMinimizedError?: unknown; + readonly sendError?: unknown; + } = {}, +) { const listeners = new Map void>>(); const sentMessages: SentMessage[] = []; let destroyed = false; @@ -26,8 +32,18 @@ function makeTestWindow() { let focused = false; const window = { - isDestroyed: () => destroyed, - isMinimized: () => minimized, + isDestroyed: () => { + if (options.isDestroyedError !== undefined) { + throw options.isDestroyedError; + } + return destroyed; + }, + isMinimized: () => { + if (options.isMinimizedError !== undefined) { + throw options.isMinimizedError; + } + return minimized; + }, restore: () => { restored = true; minimized = false; @@ -45,7 +61,11 @@ function makeTestWindow() { }, webContents: { send: (channel: string, ...args: readonly unknown[]) => { - sentMessages.push({ channel, args }); + const message = { channel, args }; + sentMessages.push(message); + if (options.sendError !== undefined) { + throw options.sendError; + } }, }, }; @@ -55,6 +75,7 @@ function makeTestWindow() { sentMessages, isRestored: () => restored, isFocused: () => focused, + closedListenerCount: () => listeners.get("closed")?.size ?? 0, close: () => { destroyed = true; const closedListeners = [...(listeners.get("closed") ?? [])]; @@ -107,6 +128,7 @@ describe("DesktopSshPasswordPrompts", () => { }) .pipe(Effect.forkScoped); + yield* Effect.yieldNow; yield* Effect.yieldNow; assert.equal(testWindow.sentMessages.length, 1); const sent = testWindow.sentMessages[0]; @@ -143,4 +165,85 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(error.destination, "devbox"); }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); }); + + it.effect("cleans up a prompt that fails during renderer delivery", () => { + const cause = new Error("renderer unavailable"); + const testWindow = makeTestWindow({ sendError: cause }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const error = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); + assert.equal(error.operation, "send-prompt-request"); + assert.equal(error.destination, "devbox"); + const requestId = error.requestId; + if (requestId === null) { + assert.fail("renderer delivery failures must retain their request id"); + } + assert.equal(testWindow.closedListenerCount(), 0); + + const resolveError = yield* prompts + .resolve({ requestId, password: "secret" }) + .pipe(Effect.flip); + assert.instanceOf(resolveError, DesktopSshPasswordPrompts.DesktopSshPromptExpiredError); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); + + it.effect("keeps a submitted password when a later presentation step fails", () => { + const testWindow = makeTestWindow({ + isMinimizedError: new Error("failed to read minimized state"), + }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const requestFiber = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + const sent = testWindow.sentMessages[0]; + assert.ok(sent); + const request = sent.args[0] as { readonly requestId: string }; + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); + const password = yield* Fiber.join(requestFiber); + + assert.equal(password, "secret"); + assert.equal(testWindow.isFocused(), false); + assert.equal(testWindow.closedListenerCount(), 0); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); + + it.effect("classifies a failed initial window availability check", () => { + const testWindow = makeTestWindow({ isDestroyedError: new Error("window unavailable") }); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const error = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); + assert.equal(error.operation, "check-window-before-request"); + assert.equal(error.requestId, null); + assert.deepEqual(testWindow.sentMessages, []); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index c933bca3cb0..aa25d8135c7 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -20,7 +20,26 @@ const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; type DesktopSshPasswordPromptResolutionInput = typeof DesktopSshPasswordPromptResolutionInputSchema.Type; -const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; +const DesktopSshPromptWindowAvailabilityStage = Schema.Literals([ + "before-request", + "before-presentation", + "after-send", + "after-restore", +]); + +const DesktopSshPromptPresentationOperation = Schema.Literals([ + "check-window-before-request", + "check-window-before-presentation", + "register-window-close-listener", + "send-prompt-request", + "check-window-after-send", + "check-window-minimized", + "restore-window", + "check-window-after-restore", + "focus-window", + "remove-window-close-listener", +]); +type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type; export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass()( "DesktopSshPromptRequestIdGenerationError", @@ -38,20 +57,22 @@ export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorCl "DesktopSshPromptWindowUnavailableError", { destination: Schema.String, + requestId: Schema.NullOr(Schema.String), + stage: DesktopSshPromptWindowAvailabilityStage, }, ) { override get message(): string { - return WINDOW_UNAVAILABLE_MESSAGE; + const request = this.requestId === null ? "before a request id was assigned" : this.requestId; + return `T3 Code window is unavailable during ${this.stage} for SSH authentication to ${this.destination} (request: ${request}).`; } } -const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError); - export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass()( "DesktopSshPromptPresentationError", { - requestId: Schema.String, + requestId: Schema.NullOr(Schema.String), destination: Schema.String, + operation: DesktopSshPromptPresentationOperation, cause: Schema.Defect(), }, ) { @@ -263,9 +284,29 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( "desktop.sshPasswordPrompts.request", )(function* (input) { const window = yield* electronWindow.main; - if (Option.isNone(window) || window.value.isDestroyed()) { + if (Option.isNone(window)) { return yield* new DesktopSshPromptWindowUnavailableError({ destination: input.destination, + requestId: null, + stage: "before-request", + }); + } + + const unavailableBeforeRequest = yield* Effect.try({ + try: () => window.value.isDestroyed(), + catch: (cause) => + new DesktopSshPromptPresentationError({ + requestId: null, + destination: input.destination, + operation: "check-window-before-request", + cause, + }), + }); + if (unavailableBeforeRequest) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + requestId: null, + stage: "before-request", }); } @@ -319,11 +360,25 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( ), ); }; - const cleanup = Effect.sync(() => { + const runPresentationOperation = ( + operation: DesktopSshPromptPresentationOperation, + evaluate: () => A, + ) => + Effect.try({ + try: evaluate, + catch: (cause) => + new DesktopSshPromptPresentationError({ + requestId, + destination: input.destination, + operation, + cause, + }), + }); + const cleanup = runPresentationOperation("remove-window-close-listener", () => { if (!window.value.isDestroyed()) { window.value.removeListener("closed", cancelOnWindowClosed); } - }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid); + }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid); const waitForPassword = Deferred.await(deferred).pipe( Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), Effect.flatMap( @@ -339,40 +394,73 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( }), ), ); + const preferSubmittedPassword = (error: DesktopSshPasswordPromptRequestError) => + Deferred.poll(deferred).pipe( + Effect.flatMap( + Option.match({ + onSome: (completion) => completion, + onNone: () => + Ref.get(pendingRef).pipe( + Effect.flatMap((entries) => + entries.has(requestId) ? Effect.fail(error) : Deferred.await(deferred), + ), + ), + }), + ), + ); - return yield* Effect.try({ - try: () => { - if (window.value.isDestroyed()) { - throw new DesktopSshPromptWindowUnavailableError({ - destination: input.destination, - }); - } - window.value.once("closed", cancelOnWindowClosed); - window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); - if (window.value.isDestroyed()) { - throw new DesktopSshPromptWindowUnavailableError({ + return yield* Effect.gen(function* () { + const unavailableBeforePresentation = yield* runPresentationOperation( + "check-window-before-presentation", + () => window.value.isDestroyed(), + ); + if (unavailableBeforePresentation) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + requestId, + stage: "before-presentation", + }); + } + yield* runPresentationOperation("register-window-close-listener", () => + window.value.once("closed", cancelOnWindowClosed), + ); + return yield* Effect.gen(function* () { + yield* runPresentationOperation("send-prompt-request", () => + window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest), + ); + yield* Effect.yieldNow; + const unavailableAfterSend = yield* runPresentationOperation( + "check-window-after-send", + () => window.value.isDestroyed(), + ); + if (unavailableAfterSend) { + return yield* new DesktopSshPromptWindowUnavailableError({ destination: input.destination, + requestId, + stage: "after-send", }); } - if (window.value.isMinimized()) { - window.value.restore(); + const minimized = yield* runPresentationOperation("check-window-minimized", () => + window.value.isMinimized(), + ); + if (minimized) { + yield* runPresentationOperation("restore-window", () => window.value.restore()); } - if (window.value.isDestroyed()) { - throw new DesktopSshPromptWindowUnavailableError({ + const unavailableAfterRestore = yield* runPresentationOperation( + "check-window-after-restore", + () => window.value.isDestroyed(), + ); + if (unavailableAfterRestore) { + return yield* new DesktopSshPromptWindowUnavailableError({ destination: input.destination, + requestId, + stage: "after-restore", }); } - window.value.focus(); - }, - catch: (cause) => - isDesktopSshPromptWindowUnavailableError(cause) - ? cause - : new DesktopSshPromptPresentationError({ - requestId, - destination: input.destination, - cause, - }), - }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); + yield* runPresentationOperation("focus-window", () => window.value.focus()); + return yield* waitForPassword; + }).pipe(Effect.catch(preferSubmittedPassword)); + }).pipe(Effect.ensuring(cleanup)); }); return DesktopSshPasswordPrompts.of({