From a896737df23419a1e5c6d7eae66a1acac0a8b8a5 Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Sat, 14 Mar 2026 14:08:34 -0500 Subject: [PATCH 1/2] fix: prevent login dialog from leaving dangling promises that freeze the UI (#280) The LoginDialogComponent's showPrompt/showManualInput methods returned Promises that could hang forever if: (a) a new prompt superseded a pending one without rejecting it, (b) the abort signal fired without cleaning up promises, or (c) the dialog was removed without disposing pending state. - Add rejectPending() to safely reject outstanding promises before creating new ones - Wire AbortSignal listener to auto-reject on external cancellation - Add dispose() method called by restoreEditor() as a safety net - Clean up manualCodePromise on error path - Filter internal error messages (Superseded/disposed) from user display --- .../interactive/components/login-dialog.ts | 53 +++++++++++++++++-- .../src/modes/interactive/interactive-mode.ts | 12 ++++- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts index 8e26afe7d..7f388cc48 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts @@ -1,3 +1,5 @@ +// GSD Login Dialog Component — OAuth login flow UI +// Copyright (c) 2026 Jeremy McSpadden import { getOAuthProviders } from "@gsd/pi-ai/oauth"; import { Container, type Focusable, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@gsd/pi-tui"; import { exec } from "child_process"; @@ -6,7 +8,12 @@ import { DynamicBorder } from "./dynamic-border.js"; import { keyHint } from "./keybinding-hints.js"; /** - * Login dialog component - replaces editor during OAuth login flow + * Login dialog component - replaces editor during OAuth login flow. + * + * Guards against stuck UI by: + * - Rejecting any outstanding promise before creating a new one + * - Listening on the internal AbortSignal so external cancellation cleans up + * - Exposing a public dispose() method so the caller can force-cleanup */ export class LoginDialogComponent extends Container implements Focusable { private contentContainer: Container; @@ -15,6 +22,7 @@ export class LoginDialogComponent extends Container implements Focusable { private abortController = new AbortController(); private inputResolver?: (value: string) => void; private inputRejecter?: (error: Error) => void; + private disposed = false; // Focusable implementation - propagate to input for IME cursor positioning private _focused = false; @@ -62,22 +70,51 @@ export class LoginDialogComponent extends Container implements Focusable { // Bottom border this.addChild(new DynamicBorder()); + + // Wire abort signal so external cancellation rejects pending promises + this.abortController.signal.addEventListener("abort", () => { + this.rejectPending("Login cancelled"); + }); } get signal(): AbortSignal { return this.abortController.signal; } - private cancel(): void { - this.abortController.abort(); + /** + * Reject any outstanding input promise without triggering a full cancel. + * Safe to call multiple times. + */ + private rejectPending(reason: string): void { if (this.inputRejecter) { - this.inputRejecter(new Error("Login cancelled")); + const rejecter = this.inputRejecter; this.inputResolver = undefined; this.inputRejecter = undefined; + rejecter(new Error(reason)); } + } + + private cancel(): void { + if (this.disposed) return; + this.abortController.abort(); + // rejectPending is also called by the abort listener, but guard with + // disposed flag and nulling to avoid double-reject + this.rejectPending("Login cancelled"); this.onComplete(false, "Login cancelled"); } + /** + * Force-dispose the dialog, rejecting any pending promises. + * Called by the parent when restoring the editor, as a safety net + * to ensure no promises are left dangling. + */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.abortController.abort(); + this.rejectPending("Login dialog disposed"); + } + /** * Called by onAuth callback - show URL and optional instructions */ @@ -106,6 +143,9 @@ export class LoginDialogComponent extends Container implements Focusable { * Show input for manual code/URL entry (for callback server providers) */ showManualInput(prompt: string): Promise { + // Reject any previous pending promise before creating a new one + this.rejectPending("Superseded by new input prompt"); + this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); this.contentContainer.addChild(this.input); @@ -123,6 +163,9 @@ export class LoginDialogComponent extends Container implements Focusable { * Note: Does NOT clear content, appends to existing (preserves URL from showAuth) */ showPrompt(message: string, placeholder?: string): Promise { + // Reject any previous pending promise before creating a new one + this.rejectPending("Superseded by new input prompt"); + this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("text", message), 1, 0)); if (placeholder) { @@ -161,6 +204,8 @@ export class LoginDialogComponent extends Container implements Focusable { } handleInput(data: string): void { + if (this.disposed) return; + const kb = getEditorKeybindings(); if (kb.matches(data, "selectCancel")) { diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index bbbe910d9..9a8770ad7 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -3805,8 +3805,10 @@ export class InteractiveMode { manualCodeReject = reject; }); - // Restore editor helper + // Restore editor helper — also disposes the dialog to reject any + // dangling promises and prevent the UI from getting stuck. const restoreEditor = () => { + dialog.dispose(); this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.ui.setFocus(this.editor); @@ -3881,8 +3883,14 @@ export class InteractiveMode { this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`); } catch (error: unknown) { restoreEditor(); + // Also reject the manual code promise if it's still pending + if (manualCodeReject) { + manualCodeReject(new Error("Login cancelled")); + manualCodeReject = undefined; + manualCodeResolve = undefined; + } const errorMsg = error instanceof Error ? error.message : String(error); - if (errorMsg !== "Login cancelled") { + if (errorMsg !== "Login cancelled" && !errorMsg.includes("Superseded") && !errorMsg.includes("disposed")) { this.showError(`Failed to login to ${providerName}: ${errorMsg}`); } } From 9eb5a00f4f944532d61624d1a18277eb712349e8 Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Sat, 14 Mar 2026 14:59:02 -0500 Subject: [PATCH 2/2] fix: suppress git-svn noise that causes confusing errors on affected systems (#404) Systems with a buggy git-svn Perl module (notably Arch Linux) emit "Duplicate specification" warnings on every git invocation. Filter these from error messages and suppress git-svn loading via GIT_SVN_ID. Also update repository URLs from stale glittercowboy/gsd-pi to gsd-build/gsd-2. --- package.json | 6 +++--- src/resources/extensions/gsd/git-service.ts | 18 ++++++++++++++++-- .../extensions/gsd/native-git-bridge.ts | 3 ++- .../extensions/gsd/worktree-manager.ts | 18 ++++++++++++++++-- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 7fb168bbd..c79d19163 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,11 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/glittercowboy/gsd-pi.git" + "url": "https://github.com/gsd-build/gsd-2.git" }, - "homepage": "https://github.com/glittercowboy/gsd-pi#readme", + "homepage": "https://github.com/gsd-build/gsd-2#readme", "bugs": { - "url": "https://github.com/glittercowboy/gsd-pi/issues" + "url": "https://github.com/gsd-build/gsd-2/issues" }, "type": "module", "workspaces": [ diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index c984e6606..89c20dd7d 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -205,13 +205,27 @@ export function writeIntegrationBranch(basePath: string, milestoneId: string, br // ─── Git Helper ──────────────────────────────────────────────────────────── -/** Env overlay that suppresses all interactive git credential prompts. */ +/** Env overlay that suppresses interactive git credential prompts and git-svn noise. */ const GIT_NO_PROMPT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", + GIT_SVN_ID: "", }; +/** + * Strip git-svn noise from error messages. + * Some systems (notably Arch Linux) have a buggy git-svn Perl module that + * emits warnings on every git invocation, confusing users. See #404. + */ +function filterGitSvnNoise(message: string): string { + return message + .replace(/Duplicate specification "[^"]*" for option "[^"]*"\n?/g, "") + .replace(/Unable to determine upstream SVN information from .*\n?/g, "") + .replace(/Perhaps the repository is empty\. at .*git-svn.*\n?/g, "") + .trim(); +} + /** * Run a git command in the given directory. * Returns trimmed stdout. Throws on non-zero exit unless allowFailure is set. @@ -229,7 +243,7 @@ export function runGit(basePath: string, args: string[], options: { allowFailure } catch (error) { if (options.allowFailure) return ""; const message = error instanceof Error ? error.message : String(error); - throw new Error(`git ${args.join(" ")} failed in ${basePath}: ${message}`); + throw new Error(`git ${args.join(" ")} failed in ${basePath}: ${filterGitSvnNoise(message)}`); } } diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index 851547b41..e613409a5 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -7,11 +7,12 @@ import { execSync } from "node:child_process"; -/** Env overlay that suppresses all interactive git credential prompts. */ +/** Env overlay that suppresses interactive git credential prompts and git-svn noise. */ const GIT_NO_PROMPT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", + GIT_SVN_ID: "", }; let nativeModule: { diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index edbec38eb..12955c9ee 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -46,13 +46,27 @@ export interface WorktreeDiffSummary { // ─── Git Helpers ─────────────────────────────────────────────────────────── -/** Env overlay that suppresses all interactive git credential prompts. */ +/** Env overlay that suppresses interactive git credential prompts and git-svn noise. */ const GIT_NO_PROMPT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", + GIT_SVN_ID: "", }; +/** + * Strip git-svn noise from error messages. + * Some systems have a buggy git-svn Perl module that emits warnings + * on every git invocation. See #404. + */ +function filterGitSvnNoise(message: string): string { + return message + .replace(/Duplicate specification "[^"]*" for option "[^"]*"\n?/g, "") + .replace(/Unable to determine upstream SVN information from .*\n?/g, "") + .replace(/Perhaps the repository is empty\. at .*git-svn.*\n?/g, "") + .trim(); +} + function runGit(cwd: string, args: string[], opts: { allowFailure?: boolean } = {}): string { try { return execSync(`git ${args.join(" ")}`, { @@ -64,7 +78,7 @@ function runGit(cwd: string, args: string[], opts: { allowFailure?: boolean } = } catch (error) { if (opts.allowFailure) return ""; const message = error instanceof Error ? error.message : String(error); - throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${message}`); + throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${filterGitSvnNoise(message)}`); } }