History

[codex] Structure desktop SSH prompt presentation failures (#3429)

Julius Marminge committed 4 months ago

Commit 569b3e51 parent d7ff7e73 files · +230 −38Browse files

Unified · Split

3 files
  1. apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +1 −0
  2. apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +107 −4
  3. apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +122 −34
apps/desktop/src/ssh/DesktopSshEnvironment.test.ts+1−0
@@ -23,6 +23,7 @@ describe("sshEnvironment", () => { · 22 unmodified lines
2323 const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({
2424 requestId: "prompt-1",
2525 destination: "devbox",
26+ operation: "send-prompt-request",
2627 cause: new Error("renderer send failed"),
2728 });
2829
apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts+107−4
@@ -17,7 +17,13 @@ interface SentMessage { · 16 unmodified lines
1717 readonly args: readonly unknown[];
1818 }
1919
20−function makeTestWindow() {
20+function makeTestWindow(
21+ options: {
22+ readonly isDestroyedError?: unknown;
23+ readonly isMinimizedError?: unknown;
24+ readonly sendError?: unknown;
25+ } = {},
26+) {
2127 const listeners = new Map<string, Set<() => void>>();
2228 const sentMessages: SentMessage[] = [];
2329 let destroyed = false;
@@ -26,8 +32,18 @@ function makeTestWindow() { · 2 unmodified lines
2632 let focused = false;
2733
2834 const window = {
29− isDestroyed: () => destroyed,
30− isMinimized: () => minimized,
35+ isDestroyed: () => {
36+ if (options.isDestroyedError !== undefined) {
37+ throw options.isDestroyedError;
38+ }
39+ return destroyed;
40+ },
41+ isMinimized: () => {
42+ if (options.isMinimizedError !== undefined) {
43+ throw options.isMinimizedError;
44+ }
45+ return minimized;
46+ },
3147 restore: () => {
3248 restored = true;
3349 minimized = false;
@@ -45,7 +61,11 @@ function makeTestWindow() { · 11 unmodified lines
4561 },
4662 webContents: {
4763 send: (channel: string, ...args: readonly unknown[]) => {
48− sentMessages.push({ channel, args });
64+ const message = { channel, args };
65+ sentMessages.push(message);
66+ if (options.sendError !== undefined) {
67+ throw options.sendError;
68+ }
4969 },
5070 },
5171 };
@@ -55,6 +75,7 @@ function makeTestWindow() { · 3 unmodified lines
5575 sentMessages,
5676 isRestored: () => restored,
5777 isFocused: () => focused,
78+ closedListenerCount: () => listeners.get("closed")?.size ?? 0,
5879 close: () => {
5980 destroyed = true;
6081 const closedListeners = [...(listeners.get("closed") ?? [])];
@@ -107,6 +128,7 @@ describe("DesktopSshPasswordPrompts", () => { · 46 unmodified lines
107128 })
108129 .pipe(Effect.forkScoped);
109130
131+ yield* Effect.yieldNow;
110132 yield* Effect.yieldNow;
111133 assert.equal(testWindow.sentMessages.length, 1);
112134 const sent = testWindow.sentMessages[0];
@@ -143,4 +165,85 @@ describe("DesktopSshPasswordPrompts", () => { · 30 unmodified lines
143165 assert.equal(error.destination, "devbox");
144166 }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
145167 });
168+
169+ it.effect("cleans up a prompt that fails during renderer delivery", () => {
170+ const cause = new Error("renderer unavailable");
171+ const testWindow = makeTestWindow({ sendError: cause });
172+
173+ return Effect.gen(function* () {
174+ const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
175+ const error = yield* prompts
176+ .request({
177+ destination: "devbox",
178+ username: "julius",
179+ prompt: "Enter the SSH password.",
180+ attempt: 1,
181+ })
182+ .pipe(Effect.flip);
183+
184+ assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError);
185+ assert.equal(error.operation, "send-prompt-request");
186+ assert.equal(error.destination, "devbox");
187+ const requestId = error.requestId;
188+ if (requestId === null) {
189+ assert.fail("renderer delivery failures must retain their request id");
190+ }
191+ assert.equal(testWindow.closedListenerCount(), 0);
192+
193+ const resolveError = yield* prompts
194+ .resolve({ requestId, password: "secret" })
195+ .pipe(Effect.flip);
196+ assert.instanceOf(resolveError, DesktopSshPasswordPrompts.DesktopSshPromptExpiredError);
197+ }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
198+ });
199+
200+ it.effect("keeps a submitted password when a later presentation step fails", () => {
201+ const testWindow = makeTestWindow({
202+ isMinimizedError: new Error("failed to read minimized state"),
203+ });
204+
205+ return Effect.gen(function* () {
206+ const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
207+ const requestFiber = yield* prompts
208+ .request({
209+ destination: "devbox",
210+ username: "julius",
211+ prompt: "Enter the SSH password.",
212+ attempt: 1,
213+ })
214+ .pipe(Effect.forkScoped);
215+
216+ yield* Effect.yieldNow;
217+ const sent = testWindow.sentMessages[0];
218+ assert.ok(sent);
219+ const request = sent.args[0] as { readonly requestId: string };
220+ yield* prompts.resolve({ requestId: request.requestId, password: "secret" });
221+ const password = yield* Fiber.join(requestFiber);
222+
223+ assert.equal(password, "secret");
224+ assert.equal(testWindow.isFocused(), false);
225+ assert.equal(testWindow.closedListenerCount(), 0);
226+ }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
227+ });
228+
229+ it.effect("classifies a failed initial window availability check", () => {
230+ const testWindow = makeTestWindow({ isDestroyedError: new Error("window unavailable") });
231+
232+ return Effect.gen(function* () {
233+ const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
234+ const error = yield* prompts
235+ .request({
236+ destination: "devbox",
237+ username: "julius",
238+ prompt: "Enter the SSH password.",
239+ attempt: 1,
240+ })
241+ .pipe(Effect.flip);
242+
243+ assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError);
244+ assert.equal(error.operation, "check-window-before-request");
245+ assert.equal(error.requestId, null);
246+ assert.deepEqual(testWindow.sentMessages, []);
247+ }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
248+ });
146249 });
apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts+122−34
@@ -20,7 +20,26 @@ const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; · 19 unmodified lines
2020 type DesktopSshPasswordPromptResolutionInput =
2121 typeof DesktopSshPasswordPromptResolutionInputSchema.Type;
2222
23−const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication.";
23+const DesktopSshPromptWindowAvailabilityStage = Schema.Literals([
24+ "before-request",
25+ "before-presentation",
26+ "after-send",
27+ "after-restore",
28+]);
29+
30+const DesktopSshPromptPresentationOperation = Schema.Literals([
31+ "check-window-before-request",
32+ "check-window-before-presentation",
33+ "register-window-close-listener",
34+ "send-prompt-request",
35+ "check-window-after-send",
36+ "check-window-minimized",
37+ "restore-window",
38+ "check-window-after-restore",
39+ "focus-window",
40+ "remove-window-close-listener",
41+]);
42+type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type;
2443
2544 export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass<DesktopSshPromptRequestIdGenerationError>()(
2645 "DesktopSshPromptRequestIdGenerationError",
@@ -38,20 +57,22 @@ export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorCl · 11 unmodified lines
3857 "DesktopSshPromptWindowUnavailableError",
3958 {
4059 destination: Schema.String,
60+ requestId: Schema.NullOr(Schema.String),
61+ stage: DesktopSshPromptWindowAvailabilityStage,
4162 },
4263 ) {
4364 override get message(): string {
44− return WINDOW_UNAVAILABLE_MESSAGE;
65+ const request = this.requestId === null ? "before a request id was assigned" : this.requestId;
66+ return `T3 Code window is unavailable during ${this.stage} for SSH authentication to ${this.destination} (request: ${request}).`;
4567 }
4668 }
4769
48−const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError);
49−
5070 export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass<DesktopSshPromptPresentationError>()(
5171 "DesktopSshPromptPresentationError",
5272 {
53− requestId: Schema.String,
73+ requestId: Schema.NullOr(Schema.String),
5474 destination: Schema.String,
75+ operation: DesktopSshPromptPresentationOperation,
5576 cause: Schema.Defect(),
5677 },
5778 ) {
@@ -263,9 +284,29 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( · 205 unmodified lines
263284 "desktop.sshPasswordPrompts.request",
264285 )(function* (input) {
265286 const window = yield* electronWindow.main;
266− if (Option.isNone(window) || window.value.isDestroyed()) {
287+ if (Option.isNone(window)) {
267288 return yield* new DesktopSshPromptWindowUnavailableError({
268289 destination: input.destination,
290+ requestId: null,
291+ stage: "before-request",
292+ });
293+ }
294+
295+ const unavailableBeforeRequest = yield* Effect.try({
296+ try: () => window.value.isDestroyed(),
297+ catch: (cause) =>
298+ new DesktopSshPromptPresentationError({
299+ requestId: null,
300+ destination: input.destination,
301+ operation: "check-window-before-request",
302+ cause,
303+ }),
304+ });
305+ if (unavailableBeforeRequest) {
306+ return yield* new DesktopSshPromptWindowUnavailableError({
307+ destination: input.destination,
308+ requestId: null,
309+ stage: "before-request",
269310 });
270311 }
271312
@@ -319,11 +360,25 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( · 47 unmodified lines
319360 ),
320361 );
321362 };
322− const cleanup = Effect.sync(() => {
363+ const runPresentationOperation = <A>(
364+ operation: DesktopSshPromptPresentationOperation,
365+ evaluate: () => A,
366+ ) =>
367+ Effect.try({
368+ try: evaluate,
369+ catch: (cause) =>
370+ new DesktopSshPromptPresentationError({
371+ requestId,
372+ destination: input.destination,
373+ operation,
374+ cause,
375+ }),
376+ });
377+ const cleanup = runPresentationOperation("remove-window-close-listener", () => {
323378 if (!window.value.isDestroyed()) {
324379 window.value.removeListener("closed", cancelOnWindowClosed);
325380 }
326− }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid);
381+ }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid);
327382 const waitForPassword = Deferred.await(deferred).pipe(
328383 Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)),
329384 Effect.flatMap(
@@ -339,40 +394,73 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( · 9 unmodified lines
339394 }),
340395 ),
341396 );
397+ const preferSubmittedPassword = (error: DesktopSshPasswordPromptRequestError) =>
398+ Deferred.poll(deferred).pipe(
399+ Effect.flatMap(
400+ Option.match({
401+ onSome: (completion) => completion,
402+ onNone: () =>
403+ Ref.get(pendingRef).pipe(
404+ Effect.flatMap((entries) =>
405+ entries.has(requestId) ? Effect.fail(error) : Deferred.await(deferred),
406+ ),
407+ ),
408+ }),
409+ ),
410+ );
342411
343− return yield* Effect.try({
344− try: () => {
345− if (window.value.isDestroyed()) {
346− throw new DesktopSshPromptWindowUnavailableError({
347− destination: input.destination,
348− });
349− }
350− window.value.once("closed", cancelOnWindowClosed);
351− window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest);
352− if (window.value.isDestroyed()) {
353− throw new DesktopSshPromptWindowUnavailableError({
412+ return yield* Effect.gen(function* () {
413+ const unavailableBeforePresentation = yield* runPresentationOperation(
414+ "check-window-before-presentation",
415+ () => window.value.isDestroyed(),
416+ );
417+ if (unavailableBeforePresentation) {
418+ return yield* new DesktopSshPromptWindowUnavailableError({
419+ destination: input.destination,
420+ requestId,
421+ stage: "before-presentation",
422+ });
423+ }
424+ yield* runPresentationOperation("register-window-close-listener", () =>
425+ window.value.once("closed", cancelOnWindowClosed),
426+ );
427+ return yield* Effect.gen(function* () {
428+ yield* runPresentationOperation("send-prompt-request", () =>
429+ window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest),
430+ );
431+ yield* Effect.yieldNow;
432+ const unavailableAfterSend = yield* runPresentationOperation(
433+ "check-window-after-send",
434+ () => window.value.isDestroyed(),
435+ );
436+ if (unavailableAfterSend) {
437+ return yield* new DesktopSshPromptWindowUnavailableError({
354438 destination: input.destination,
439+ requestId,
440+ stage: "after-send",
355441 });
356442 }
357− if (window.value.isMinimized()) {
358− window.value.restore();
443+ const minimized = yield* runPresentationOperation("check-window-minimized", () =>
444+ window.value.isMinimized(),
445+ );
446+ if (minimized) {
447+ yield* runPresentationOperation("restore-window", () => window.value.restore());
359448 }
360− if (window.value.isDestroyed()) {
361− throw new DesktopSshPromptWindowUnavailableError({
449+ const unavailableAfterRestore = yield* runPresentationOperation(
450+ "check-window-after-restore",
451+ () => window.value.isDestroyed(),
452+ );
453+ if (unavailableAfterRestore) {
454+ return yield* new DesktopSshPromptWindowUnavailableError({
362455 destination: input.destination,
456+ requestId,
457+ stage: "after-restore",
363458 });
364459 }
365− window.value.focus();
366− },
367− catch: (cause) =>
368− isDesktopSshPromptWindowUnavailableError(cause)
369− ? cause
370− : new DesktopSshPromptPresentationError({
371− requestId,
372− destination: input.destination,
373− cause,
374− }),
375− }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup));
460+ yield* runPresentationOperation("focus-window", () => window.value.focus());
461+ return yield* waitForPassword;
462+ }).pipe(Effect.catch(preferSubmittedPassword));
463+ }).pipe(Effect.ensuring(cleanup));
376464 });
377465
378466 return DesktopSshPasswordPrompts.of({