From d9d07460dda5e1c1d68222ad3c402b387d76ee13 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 22 Apr 2026 06:22:55 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(desktop):=20Codex=E9=87=8D=E3=81=95?= =?UTF-8?q?=E8=AA=BF=E6=9F=BB=E3=83=AD=E3=82=B0=E3=81=AE=E6=94=B9=E5=96=84?= =?UTF-8?q?=E3=81=A8=E3=83=91=E3=83=95=E3=82=A9=E3=83=BC=E3=83=9E=E3=83=B3?= =?UTF-8?q?=E3=82=B9=E4=BF=AE=E6=AD=A3=20(#293)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sentry ログ調査(PR #341/#344)で得られた知見をもとに修正。 - debug-channel: 集計間隔を 1 秒 → 30 秒に延長 毎秒 Sentry にイベントが飛び続けていた問題を解消。 これ自体が CPU/メモリの追加負荷になっていた可能性がある。 - v1-terminal-cache: rAF ベースの write バッファを追加 xterm.write を 1 フレーム(16ms)単位でまとめて呼ぶようにし、 パーサ/レンダリングサイクルの回数を削減。 hidden 経路・マウント時経路の両方に適用。 - terminal-cleanup: killTerminalForPane に v1TerminalCache.dispose を追加 React unmount が遅延した場合でも xterm.js / WebGL リソースを ペイン削除時点で確実に解放する。 - pty-turn-runner: JSONL 発見タイムアウトを 15 秒 → 30 秒に延長 Claude Code の JSONL 生成が SessionStart hook 発火より遅れる場合に 15 秒以内で失敗していた問題を修正。 タイムアウト時のエラーメッセージに実在ファイル一覧を追記。 --- .../src/main/todo-daemon/pty-turn-runner.ts | 11 ++++-- .../Terminal/hooks/useTerminalStream.ts | 3 +- .../TabsContent/Terminal/v1-terminal-cache.ts | 34 ++++++++++++++++++- .../stores/tabs/utils/terminal-cleanup.ts | 4 +++ apps/desktop/src/shared/debug-channel.ts | 2 +- 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/todo-daemon/pty-turn-runner.ts b/apps/desktop/src/main/todo-daemon/pty-turn-runner.ts index de19f8191b1..8eeeaadbe57 100644 --- a/apps/desktop/src/main/todo-daemon/pty-turn-runner.ts +++ b/apps/desktop/src/main/todo-daemon/pty-turn-runner.ts @@ -78,7 +78,7 @@ const CLAUDE_BIN = const CLAUDE_PROJECTS_ROOT = path.join(os.homedir(), ".claude", "projects"); /** How long we wait after spawn for the JSONL file to appear. */ -const JSONL_DISCOVERY_TIMEOUT_MS = 15_000; +const JSONL_DISCOVERY_TIMEOUT_MS = 30_000; /** How often we poll the JSONL file for appended lines. */ const JSONL_POLL_INTERVAL_MS = 250; @@ -330,14 +330,19 @@ export async function runClaudeTurnPty( if (!state.jsonlPath) { const runtimeSid = hookSink.readRuntimeSessionId(); + const foundJsonls = listJsonl(projectDir); + const foundStr = + foundJsonls.length > 0 + ? `projectDir に存在するファイル: ${foundJsonls.slice(0, 5).join(", ")}${foundJsonls.length > 5 ? ` 他${foundJsonls.length - 5}件` : ""}` + : "projectDir に .jsonl ファイルが見つかりません"; return { result: null, sessionId: state.claudeSessionId, costUsd: null, numTurns: null, error: runtimeSid - ? `Claude Code のセッション JSONL (${runtimeSid}.jsonl) が発見できませんでした` - : "SessionStart hook が発火しなかったため JSONL を同定できませんでした (PTY 起動は成功)", + ? `Claude Code のセッション JSONL (${runtimeSid}.jsonl) が発見できませんでした — ${foundStr}` + : `SessionStart hook が発火しなかったため JSONL を同定できませんでした (PTY 起動は成功) — ${foundStr}`, interrupted: false, scheduledWakeup: null, }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts index 8a0ea18934e..1f4126d74d6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts @@ -5,6 +5,7 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run"; import { DEBUG_TERMINAL } from "../config"; import { logTerminalWrite, terminalRendererDebug } from "../debug"; +import { scheduleWrite } from "../v1-terminal-cache"; import type { TerminalExitReason, TerminalStreamEvent } from "../types"; export interface UseTerminalStreamOptions { @@ -192,7 +193,7 @@ export function useTerminalStream({ updateModesRef.current(event.data); logTerminalWrite("stream-data", event.data.length, { paneId }); - xterm.write(event.data); + scheduleWrite(paneId, event.data); updateCwdRef.current(event.data); } else if (event.type === "exit") { handleTerminalExit(event.exitCode, xterm, event.reason); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts index e4156e47281..48e7da20681 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts @@ -54,6 +54,9 @@ export interface CachedTerminal { subscriptionErrorHandler: ((error: unknown) => void) | null; /** ResizeObserver for the attached container. Managed by attach/detach. */ resizeObserver: ResizeObserver | null; + /** rAF-batched write buffer: data accumulates here until the next frame. */ + rafWriteBuffer: string; + rafWriteId: ReturnType | null; } const cache = new Map(); @@ -95,6 +98,8 @@ export function getOrCreate( resizeObserver: null, lastCols: xterm.cols, lastRows: xterm.rows, + rafWriteBuffer: "", + rafWriteId: null, }; cache.set(paneId, entry); @@ -212,6 +217,30 @@ export function updateAppearance( }; } +// --- rAF write buffer --- + +/** + * Batch xterm.write calls into one per animation frame to reduce the number + * of parser/render cycles. Callers accumulate data here; the actual write + * fires in the next rAF, coalescing all chunks that arrived within ~16 ms. + */ +export function scheduleWrite(paneId: string, data: string): void { + const entry = cache.get(paneId); + if (!entry) return; + entry.rafWriteBuffer += data; + if (entry.rafWriteId === null) { + entry.rafWriteId = requestAnimationFrame(() => { + const e = cache.get(paneId); + if (!e) return; + if (e.rafWriteBuffer) { + e.xterm.write(e.rafWriteBuffer); + e.rafWriteBuffer = ""; + } + e.rafWriteId = null; + }); + } +} + // --- Stream subscription --- function routeEvent( @@ -243,7 +272,7 @@ function routeEvent( data: { paneId }, }); logTerminalWrite("hidden-stream-data", event.data.length, { paneId }); - entry.xterm.write(event.data); + scheduleWrite(paneId, event.data); } else { entry.pendingLifecycleEvents.push(event); } @@ -402,6 +431,9 @@ export function dispose(paneId: string): void { entry.resizeObserver?.disconnect(); entry.subscription?.unsubscribe(); + if (entry.rafWriteId !== null) { + cancelAnimationFrame(entry.rafWriteId); + } entry.cleanupCreation(); entry.xterm.dispose(); cache.delete(paneId); diff --git a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts index a6b151dd39d..44e481b4f91 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts @@ -1,5 +1,6 @@ import { rejectTerminalSessionReady } from "../../../lib/terminal/session-readiness"; import { electronTrpcClient } from "../../../lib/trpc-client"; +import * as v1TerminalCache from "../../../screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache"; /** * Uses standalone tRPC client to avoid React hook dependencies @@ -9,6 +10,9 @@ export const killTerminalForPane = (paneId: string): void => { paneId, new Error("Terminal pane was closed before the session became ready"), ); + // Eagerly release xterm/WebGL resources in case the React unmount is delayed. + // v1TerminalCache.dispose is idempotent (no-op if already disposed). + v1TerminalCache.dispose(paneId); electronTrpcClient.terminal.kill.mutate({ paneId }).catch((error) => { console.warn(`Failed to kill terminal for pane ${paneId}:`, error); }); diff --git a/apps/desktop/src/shared/debug-channel.ts b/apps/desktop/src/shared/debug-channel.ts index 430261928e4..0cb61e8354c 100644 --- a/apps/desktop/src/shared/debug-channel.ts +++ b/apps/desktop/src/shared/debug-channel.ts @@ -55,7 +55,7 @@ interface AggregateState { timer: ReturnType | null; } -const DEFAULT_AGGREGATE_INTERVAL_MS = 1_000; +const DEFAULT_AGGREGATE_INTERVAL_MS = 30_000; const DEFAULT_MAX_STRING_LENGTH = 500; function truncateString(value: string, maxLength: number): string { From 81a4fbfe1bd4c56e5b77e119227f68ce724996df Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 22 Apr 2026 06:38:01 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(desktop):=20rAF=20flush=20before=20exit?= =?UTF-8?q?/error=20+=20dispose=20=E3=82=92store=E5=89=8A=E9=99=A4?= =?UTF-8?q?=E5=BE=8C=E3=81=AB=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useTerminalStream: exit/disconnect/error イベント処理前に flushWrite を呼び、 trailing output が exitバナーより先に描画されるバグを修正 (Codex レビュー指摘) - v1-terminal-cache: flushWrite をエクスポート - terminal-cleanup: killTerminalForPane から dispose を除去し、 releaseTerminalCache を別関数として追加 - store: paneをstateから削除した直後に releaseTerminalCache を呼ぶよう変更 (React unmount と同バッチになるため、dispose前にコンポーネントが動くリスクを排除) --- .../Terminal/hooks/useTerminalStream.ts | 5 ++++- .../TabsContent/Terminal/v1-terminal-cache.ts | 18 ++++++++++++++++++ apps/desktop/src/renderer/stores/tabs/store.ts | 5 ++++- .../stores/tabs/utils/terminal-cleanup.ts | 14 +++++++++++--- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts index 1f4126d74d6..857a1fc831e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts @@ -5,7 +5,7 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run"; import { DEBUG_TERMINAL } from "../config"; import { logTerminalWrite, terminalRendererDebug } from "../debug"; -import { scheduleWrite } from "../v1-terminal-cache"; +import { flushWrite, scheduleWrite } from "../v1-terminal-cache"; import type { TerminalExitReason, TerminalStreamEvent } from "../types"; export interface UseTerminalStreamOptions { @@ -196,12 +196,15 @@ export function useTerminalStream({ scheduleWrite(paneId, event.data); updateCwdRef.current(event.data); } else if (event.type === "exit") { + flushWrite(paneId); handleTerminalExit(event.exitCode, xterm, event.reason); } else if (event.type === "disconnect") { + flushWrite(paneId); setConnectionError( event.reason || "Connection to terminal daemon lost", ); } else if (event.type === "error") { + flushWrite(paneId); handleStreamError(event, xterm); } }, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts index 48e7da20681..e82722a7738 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts @@ -241,6 +241,24 @@ export function scheduleWrite(paneId: string, data: string): void { } } +/** + * Immediately flush any buffered data to xterm, cancelling the pending rAF. + * Must be called before processing exit/error/disconnect events so that + * trailing output is rendered before the exit banner or pane disposal. + */ +export function flushWrite(paneId: string): void { + const entry = cache.get(paneId); + if (!entry) return; + if (entry.rafWriteId !== null) { + cancelAnimationFrame(entry.rafWriteId); + entry.rafWriteId = null; + } + if (entry.rafWriteBuffer) { + entry.xterm.write(entry.rafWriteBuffer); + entry.rafWriteBuffer = ""; + } +} + // --- Stream subscription --- function routeEvent( diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 2fbb43e5539..0318de0ebdc 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -61,7 +61,7 @@ import { resolveActiveTabIdForWorkspace, resolveFileViewerMode, } from "./utils"; -import { killTerminalForPane } from "./utils/terminal-cleanup"; +import { killTerminalForPane, releaseTerminalCache } from "./utils/terminal-cleanup"; const DEFAULT_FILE_VIEWER_SPLIT_PERCENTAGE = 50; @@ -492,6 +492,7 @@ export const useTabsStore = create()( const newPanes = { ...state.panes }; for (const paneId of paneIds) { delete newPanes[paneId]; + releaseTerminalCache(paneId); } const newTabs = state.tabs.filter((t) => t.id !== tabId); @@ -670,6 +671,7 @@ export const useTabsStore = create()( killTerminalForPane(paneId); } delete newPanes[paneId]; + releaseTerminalCache(paneId); } } @@ -1256,6 +1258,7 @@ export const useTabsStore = create()( const newPanes = { ...state.panes }; for (const id of paneIdsToRemove) { delete newPanes[id]; + releaseTerminalCache(id); } let newFocusedPaneIds = state.focusedPaneIds; diff --git a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts index 44e481b4f91..f26d73330a3 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts @@ -10,10 +10,18 @@ export const killTerminalForPane = (paneId: string): void => { paneId, new Error("Terminal pane was closed before the session became ready"), ); - // Eagerly release xterm/WebGL resources in case the React unmount is delayed. - // v1TerminalCache.dispose is idempotent (no-op if already disposed). - v1TerminalCache.dispose(paneId); electronTrpcClient.terminal.kill.mutate({ paneId }).catch((error) => { console.warn(`Failed to kill terminal for pane ${paneId}:`, error); }); }; + +/** + * Release xterm/WebGL resources for a terminal pane. + * Call this AFTER the pane has been removed from the store so that the React + * component is already unmounted (or will unmount in the same batch) before + * xterm is disposed. dispose() is idempotent — safe to call even if the + * useTerminalLifecycle cleanup already ran. + */ +export const releaseTerminalCache = (paneId: string): void => { + v1TerminalCache.dispose(paneId); +}; From d94fd56205a7e39d4481a5e802045b177a4951f0 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 22 Apr 2026 06:43:10 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(desktop):=20HMR=20backfill=20rAF=20fiel?= =?UTF-8?q?ds=20+=20hidden=E7=B5=8C=E8=B7=AF=E3=81=A7exit=E5=89=8D?= =?UTF-8?q?=E3=81=ABflush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HMR復元時に rafWriteBuffer/rafWriteId を ??= で初期化し、 旧モジュールから引き継いだエントリで undefined になるバグを修正 - routeEvent の hidden 経路で非dataイベントをキューに入れる前に flushWrite を呼び、data→exit の順序が保たれるよう修正 --- .../ContentView/TabsContent/Terminal/v1-terminal-cache.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts index e82722a7738..24ac540ba83 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts @@ -292,6 +292,7 @@ function routeEvent( logTerminalWrite("hidden-stream-data", event.data.length, { paneId }); scheduleWrite(paneId, event.data); } else { + flushWrite(paneId); entry.pendingLifecycleEvents.push(event); } } @@ -465,6 +466,8 @@ if (hot) { | undefined; if (existing) { for (const [k, v] of existing) { + v.rafWriteBuffer ??= ""; + v.rafWriteId ??= null; cache.set(k, v); } } From df0a5fa19838da4892c49acc37a47917c5a851e8 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Wed, 22 Apr 2026 06:45:57 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(desktop):=20import=E9=A0=86=E3=83=BB?= =?UTF-8?q?=E3=83=95=E3=82=A9=E3=83=BC=E3=83=9E=E3=83=83=E3=83=88=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20(lint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TabsContent/Terminal/hooks/useTerminalStream.ts | 2 +- apps/desktop/src/renderer/stores/tabs/store.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts index 857a1fc831e..4dd7dce52ca 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts @@ -5,8 +5,8 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run"; import { DEBUG_TERMINAL } from "../config"; import { logTerminalWrite, terminalRendererDebug } from "../debug"; -import { flushWrite, scheduleWrite } from "../v1-terminal-cache"; import type { TerminalExitReason, TerminalStreamEvent } from "../types"; +import { flushWrite, scheduleWrite } from "../v1-terminal-cache"; export interface UseTerminalStreamOptions { paneId: string; diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 0318de0ebdc..9b740e1c426 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -61,7 +61,10 @@ import { resolveActiveTabIdForWorkspace, resolveFileViewerMode, } from "./utils"; -import { killTerminalForPane, releaseTerminalCache } from "./utils/terminal-cleanup"; +import { + killTerminalForPane, + releaseTerminalCache, +} from "./utils/terminal-cleanup"; const DEFAULT_FILE_VIEWER_SPLIT_PERCENTAGE = 50;