From d19707422dd50c626d4baf67872627efc4b6e0fd Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 15 Mar 2026 23:00:57 +0800 Subject: [PATCH 1/2] feat: add tea (Gitea CLI) to claude-dev tooling --- claude-dev/Dockerfile | 7 +++++++ claude-dev/required-tools.txt | 1 + 2 files changed, 8 insertions(+) diff --git a/claude-dev/Dockerfile b/claude-dev/Dockerfile index d5e3d2b..936795f 100644 --- a/claude-dev/Dockerfile +++ b/claude-dev/Dockerfile @@ -28,6 +28,13 @@ ARG YQ_VERSION=v4.44.6 RUN wget -q -O /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \ && chmod +x /usr/local/bin/yq +ARG TEA_VERSION=0.12.0 +RUN wget -q -O /tmp/tea.tar.gz "https://dl.gitea.com/tea/${TEA_VERSION}/tea-${TEA_VERSION}-linux-amd64.tar.gz" \ + && tar -xzf /tmp/tea.tar.gz -C /tmp \ + && mv /tmp/tea-${TEA_VERSION}-linux-amd64/tea /usr/local/bin/tea \ + && chmod +x /usr/local/bin/tea \ + && rm -rf /tmp/tea.tar.gz /tmp/tea-${TEA_VERSION}-linux-amd64 + RUN npm install -g @anthropic-ai/claude-code RUN python3 - <<'PY' diff --git a/claude-dev/required-tools.txt b/claude-dev/required-tools.txt index 99731b6..84d3f82 100644 --- a/claude-dev/required-tools.txt +++ b/claude-dev/required-tools.txt @@ -14,4 +14,5 @@ zip unzip make tmux +tea claude From b742ab8fef406a28b0a42b7b0f41ba6fe0167a42 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 15 Mar 2026 23:18:20 +0800 Subject: [PATCH 2/2] fix: harden terminal WebSocket edge cases - Wrap tryRefreshSession in try-finally to ensure authRecoveryInFlight is always reset - Fix variable scope issue: use const tabState instead of reassigning d - Add error handling for process.stdin.write() to catch BrokenPipeError/OSError - Verify WebSocket instance before closing in ping timeout callback - Properly await conn.wait_closed() to prevent SSH connection leaks - Gracefully cancel and wait for read_task with 2s timeout These fixes prevent race conditions, resource leaks, and improve error recovery. --- dashboard/backend/routers/terminal.py | 17 +++++++- dashboard/frontend/src/routes/Terminal.svelte | 41 +++++++++++-------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index e61e08f..78358ac 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -190,18 +190,31 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): rows = struct.unpack("!H", data[3:5])[0] process.channel.change_terminal_size(cols, rows) else: - process.stdin.write(data) + try: + process.stdin.write(data) + except (BrokenPipeError, OSError) as e: + logger.warning("Failed to write to stdin for %s: %s", host, e) + break elif "text" in msg and msg["text"]: if msg["text"] == "__ping__": logger.debug("Received ping from %s, sending pong", host) await websocket.send_text("__pong__") continue - process.stdin.write(msg["text"].encode()) + try: + process.stdin.write(msg["text"].encode()) + except (BrokenPipeError, OSError) as e: + logger.warning("Failed to write to stdin for %s: %s", host, e) + break except Exception as e: logger.warning("WebSocket receive loop ended for %s: %s", host, e) finally: read_task.cancel() + try: + await asyncio.wait_for(read_task, timeout=2.0) + except (asyncio.TimeoutError, asyncio.CancelledError): + pass process.close() finally: conn.close() + await conn.wait_closed() await _release_session(session_id) diff --git a/dashboard/frontend/src/routes/Terminal.svelte b/dashboard/frontend/src/routes/Terminal.svelte index db3c6b8..c40a5a5 100644 --- a/dashboard/frontend/src/routes/Terminal.svelte +++ b/dashboard/frontend/src/routes/Terminal.svelte @@ -300,7 +300,9 @@ ws.send("__ping__"); // Set timeout to detect missing pong pongTimeout = setTimeout(() => { - if (ws.readyState === 1) { + // Check if still the same WebSocket instance and still open + const currentTab = tabData.get(tabId); + if (currentTab?.ws === ws && ws.readyState === 1) { ws.close(1000, "keepalive ping timeout"); } }, PONG_TIMEOUT); @@ -343,8 +345,8 @@ }; ws.onclose = async (e) => { const reason = e.reason || `Connection closed (code ${e.code})`; - d = tabData.get(tabId); - if (d) d.ws = null; + const tabState = tabData.get(tabId); + if (tabState) tabState.ws = null; // Handle auth failures (code 1008) if (e.code === 1008) { @@ -359,18 +361,23 @@ // Try to refresh session if (!closedManually && !authRecoveryInFlight) { authRecoveryInFlight = true; - const refreshed = await tryRefreshSession(); - authRecoveryInFlight = false; - if (refreshed && !closedManually) { - reconnecting = true; - updateTab(tabId, { - connected: false, - error: `Refreshing session (attempt ${consecutiveAuthFailures}/${MAX_AUTH_FAILURES})...`, - statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "", - }); - term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`); - void connect(false); - return; + try { + const refreshed = await tryRefreshSession(); + if (refreshed && !closedManually) { + reconnecting = true; + updateTab(tabId, { + connected: false, + error: `Refreshing session (attempt ${consecutiveAuthFailures}/${MAX_AUTH_FAILURES})...`, + statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "", + }); + term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`); + void connect(false); + return; + } + } catch (err) { + console.error("Session refresh failed:", err); + } finally { + authRecoveryInFlight = false; } } handleAuthExpired("Session expired — please log in again"); @@ -386,8 +393,8 @@ } }; ws.onerror = () => { - d = tabData.get(tabId); - if (d) d.ws = null; + const tabState = tabData.get(tabId); + if (tabState) tabState.ws = null; if (!closedManually) scheduleReconnect("Connection failed"); else updateTab(tabId, { connected: false, error: "Connection failed" }); };