From ea2efe804f74aaac12f63f4e9491c35b9ddcd211 Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Sun, 15 Mar 2026 09:56:36 -0500 Subject: [PATCH] fix(pi-tui): patch 5 resource leak bugs in TUI components - Loader: clear existing interval in start() to prevent orphaned timers on double-call; add dispose() to stop and null TUI ref - CancellableLoader: abort the AbortController and clear onAbort in dispose() so external signal holders release the controller - Editor: add public dispose() that clears the autocomplete debounce timer to prevent post-removal callbacks - Input: convert focused to getter/setter that resets isInPaste and pasteBuffer on focus loss, preventing paste state corruption - TUI.stop(): iterate overlayStack calling dispose() on each component before clearing, stopping overlay timers (e.g. dashboard refresh) --- packages/pi-tui/src/components/cancellable-loader.ts | 2 ++ packages/pi-tui/src/components/editor.ts | 4 ++++ packages/pi-tui/src/components/input.ts | 12 +++++++++++- packages/pi-tui/src/components/loader.ts | 8 ++++++++ packages/pi-tui/src/tui.ts | 9 +++++++++ 5 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/pi-tui/src/components/cancellable-loader.ts b/packages/pi-tui/src/components/cancellable-loader.ts index 506b763de..e790659e1 100644 --- a/packages/pi-tui/src/components/cancellable-loader.ts +++ b/packages/pi-tui/src/components/cancellable-loader.ts @@ -35,6 +35,8 @@ export class CancellableLoader extends Loader { } dispose(): void { + this.abortController.abort(); + this.onAbort = undefined; this.stop(); } } diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 768439289..fdcf402ea 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -2055,6 +2055,10 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ this.lastAutocompleteLookupPrefix = null; } + public dispose(): void { + this.clearAutocompleteDebounce(); + } + public isShowingAutocomplete(): boolean { return this.autocompleteState !== null; } diff --git a/packages/pi-tui/src/components/input.ts b/packages/pi-tui/src/components/input.ts index e5c3b4f7f..4feb81912 100644 --- a/packages/pi-tui/src/components/input.ts +++ b/packages/pi-tui/src/components/input.ts @@ -22,7 +22,17 @@ export class Input implements Component, Focusable { public onEscape?: () => void; /** Focusable interface - set by TUI when focus changes */ - focused: boolean = false; + private _focused: boolean = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + if (!value) { + this.isInPaste = false; + this.pasteBuffer = ""; + } + } // Bracketed paste mode buffering private pasteBuffer: string = ""; diff --git a/packages/pi-tui/src/components/loader.ts b/packages/pi-tui/src/components/loader.ts index b071e8ee2..a55a2570c 100644 --- a/packages/pi-tui/src/components/loader.ts +++ b/packages/pi-tui/src/components/loader.ts @@ -26,6 +26,9 @@ export class Loader extends Text { } start() { + if (this.intervalId) { + clearInterval(this.intervalId); + } this.updateDisplay(); this.intervalId = setInterval(() => { this.currentFrame = (this.currentFrame + 1) % this.frames.length; @@ -40,6 +43,11 @@ export class Loader extends Text { } } + dispose() { + this.stop(); + this.ui = null; + } + setMessage(message: string) { this.message = message; this.updateDisplay(); diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index 89537f1b3..c3e39acc5 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -441,6 +441,15 @@ export class TUI extends Container { stop(): void { this.stopped = true; + + // Dispose all overlays to stop any running timers + for (const entry of this.overlayStack) { + if ("dispose" in entry.component && typeof (entry.component as any).dispose === "function") { + (entry.component as any).dispose(); + } + } + this.overlayStack = []; + // Move cursor to the end of the content to prevent overwriting/artifacts on exit if (this.previousLines.length > 0) { const targetRow = this.previousLines.length; // Line after the last content