From a919d79ce12b4562414eca241804711e32386b2a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 12 Jun 2026 00:54:12 +0200 Subject: [PATCH] watch the claude process: drain stderr, surface exit (T-361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session observed its child only via stdout. Two failure modes: with --verbose the CLI chats on stderr, and an undrained 64KB pipe blocks the child mid-turn with zero diagnostics; and nothing watched the exit code, so a crashed process just looked thoughtful forever. ClaudeStreamJsonProcess now drains stderr from construction into a bounded tail buffer, and StreamJsonSession watches exitCode: on death it flips busy off, clears any unanswerable pending prompt, and emits a SessionEnd (exit code + stderr tail) — replayed via session.end for late binders. The pane reports the exit in its status line and logs the stderr tail; a deliberate dispose suppresses the watch so /clear and teardown don't read as crashes. Test fakes extend the process base instead of implementing it, so its defaults carry. Co-Authored-By: Claude Fable 5 --- .pql/changelog/ticket_history/2026-06.sql | 37 ++++++++ .pql/changelog/tickets/2026-06.sql | 25 ++++++ CHANGELOG.md | 6 ++ lib/builtin/claude/src/claude_pane.dart | 25 ++++++ .../claude/src/stream_json_session.dart | 84 ++++++++++++++++++- .../claude/claude_meta_sidebar_test.dart | 2 +- test/builtin/claude/claude_pane_test.dart | 2 +- .../claude/session_lifecycle_test.dart | 2 +- .../claude/session_orchestrator_test.dart | 2 +- .../claude/stream_json_session_test.dart | 64 +++++++++++++- test/builtin/claude/ticket_pick_up_test.dart | 2 +- 11 files changed, 243 insertions(+), 8 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 32e5fe51..6ad8d0d0 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -3955,3 +3955,40 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBWE2W1226T58CX37E50HC', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:44:05', '2026-06-11 22:44:05', '2026-06-11 22:44:05', NULL, '7846c244fccd1cd444f6715ed7472549', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:44:30', '2026-06-11 22:44:30', '2026-06-11 22:44:30', NULL, 'f20e68df446500477d6a3c7761e354c3', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:46:46', '2026-06-11 22:46:46', '2026-06-11 22:46:46', NULL, '434ffa25b18e16c5b158c61df6f40bf8', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBKK2TZQK683J8FS0ZH5A4', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:47:12', '2026-06-11 22:47:12', '2026-06-11 22:47:12', NULL, 'e816d6787437a31d3ec34332d069776e', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBKK2TZQK683J8FS0ZH5A4', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:53:45', '2026-06-11 22:53:45', '2026-06-11 22:53:45', NULL, 'efa0615f1968f8cfebd7b0e26acbdf31', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6K1QRC911JMQ9C960', 'description', 'Surfaced 2026-06-08 while chasing a --resume hang in the Claude pane (T-274 diagnostic line). The specific corrupted-transcript repro may turn out to be a one-off, but the code trace found two real latent gaps that make a resume hang unrecoverable regardless of root cause. + +(a) No timeout / no fallback on the init-event wait. +On spawn the orchestrator listens for the session_id from claude''s init event (StreamJsonSession sessionIdResolved -> session_orchestrator.dart ~257) with NO timeout. If ''claude --resume '' never emits an init event (hangs), the listener never fires: the pane shows ''resumed · '' + running indicator forever, the process is never killed, and there is no fallback to a fresh --session-id spawn. Unrecoverable without killing the process / restarting clide. + +(b) Resume is decided by file existence, not resumable content. +claude_pane.dart ~279: ''final resume = await File(transcriptFile).exists();'' — resume=true purely because the .jsonl exists. A metadata-only transcript (only system / permission-mode / attachment records; parseTranscriptChunk().items returns []) yields resume=true with zero seeded items. So clide passes --resume against a functionally empty session, the diagnostic logs ''fresh session (no history)'' (it keys off seeded count, not the resume flag — itself misleading), and if that resume hangs there is no fallback per (a). + +Acceptance: +1. If a --resume spawn yields no init event within a short timeout (e.g. 10-30s), fall back to a fresh --session-id spawn (or surface a recoverable error with a retry affordance) — the pane must never spin forever with no recovery. +2. Don''t pass --resume for a transcript that has no resumable conversation items: validate parsed item count (not just file existence) before choosing --resume vs --session-id, and/or detect-and-repair a metadata-only transcript. +3. The T-274 diagnostic log reflects the actual spawn mode (resume vs fresh), not just seeded-item count. +4. Tests: (i) fake process that never emits init -> pane falls back / surfaces error within the timeout; (ii) metadata-only transcript -> spawn chooses fresh, not --resume. + +Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork session id capture), D-77, claude_pane.dart:279/300-308, session_orchestrator.dart:240/257. + +UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority.', 'Surfaced 2026-06-08 while chasing a --resume hang in the Claude pane (T-274 diagnostic line). The specific corrupted-transcript repro may turn out to be a one-off, but the code trace found two real latent gaps that make a resume hang unrecoverable regardless of root cause. + +(a) No timeout / no fallback on the init-event wait. +On spawn the orchestrator listens for the session_id from claude''s init event (StreamJsonSession sessionIdResolved -> session_orchestrator.dart ~257) with NO timeout. If ''claude --resume '' never emits an init event (hangs), the listener never fires: the pane shows ''resumed · '' + running indicator forever, the process is never killed, and there is no fallback to a fresh --session-id spawn. Unrecoverable without killing the process / restarting clide. + +(b) Resume is decided by file existence, not resumable content. +claude_pane.dart ~279: ''final resume = await File(transcriptFile).exists();'' — resume=true purely because the .jsonl exists. A metadata-only transcript (only system / permission-mode / attachment records; parseTranscriptChunk().items returns []) yields resume=true with zero seeded items. So clide passes --resume against a functionally empty session, the diagnostic logs ''fresh session (no history)'' (it keys off seeded count, not the resume flag — itself misleading), and if that resume hangs there is no fallback per (a). + +Acceptance: +1. If a --resume spawn yields no init event within a short timeout (e.g. 10-30s), fall back to a fresh --session-id spawn (or surface a recoverable error with a retry affordance) — the pane must never spin forever with no recovery. +2. Don''t pass --resume for a transcript that has no resumable conversation items: validate parsed item count (not just file existence) before choosing --resume vs --session-id, and/or detect-and-repair a metadata-only transcript. +3. The T-274 diagnostic log reflects the actual spawn mode (resume vs fresh), not just seeded-item count. +4. Tests: (i) fake process that never emits init -> pane falls back / surfaces error within the timeout; (ii) metadata-only transcript -> spawn chooses fresh, not --resume. + +Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork session id capture), D-77, claude_pane.dart:279/300-308, session_orchestrator.dart:240/257. + +UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority. + +T-361 (done, 2026-06-12) added the session-level building blocks this ticket can reuse: StreamJsonSession now watches the process exit code (SessionEnd with stderr tail, replay-latest via session.end) and the pane surfaces ''claude exited (code N) — /clear to restart''. A resume that dies at spawn now surfaces instead of hanging silently; what remains here is the timeout/fallback for a resume that starts but never produces the init event, and resume-decided-by-content.', NULL, '2026-06-11 22:53:52', '2026-06-11 22:53:52', '2026-06-11 22:53:52', NULL, '5a99913696126412ccc7b18f75d3ec15', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 57ba0825..ce923f73 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -4035,3 +4035,28 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat Fix: exclude semantics only while collapsed (or scope the exclusion to the chrome, not the body); keep the header announcing expanded/collapsed state. Acceptance: semantics test asserting expanded-card children are present in the semantics tree and absent (or summarized) when collapsed; make test-a11y green.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:57:04', '2026-06-11 22:46:46', NULL, '1a642a72ded99e1710d9cd5e4b73a591', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBKK2TZQK683J8FS0ZH5A4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Claude child process observed only via stdout: stderr never drained, exit never watched', 'lib/builtin/claude/src/stream_json_session.dart:43-78 — the claude child stderr is never drained: >=64KB of --verbose spew fills the pipe, the child blocks mid-turn, and the flagship pane wedges with zero diagnostics. Nothing watches exitCode or stdout onDone (line ~303), so a crashed/dead session just looks busy. + +Fix: drain stderr into a bounded ring buffer (surface it on failure), watch exitCode/onDone, and emit a terminal SessionEnded state to the pane. Intersects T-283 (resume hang has no timeout/fallback). Tests: dead-child surfaces SessionEnded; stderr flood does not wedge the session.', 'in_progress', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:01', '2026-06-11 22:47:12', NULL, '050a42aae5477de3d6013adfc6231814', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBKK2TZQK683J8FS0ZH5A4', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Claude child process observed only via stdout: stderr never drained, exit never watched', 'lib/builtin/claude/src/stream_json_session.dart:43-78 — the claude child stderr is never drained: >=64KB of --verbose spew fills the pipe, the child blocks mid-turn, and the flagship pane wedges with zero diagnostics. Nothing watches exitCode or stdout onDone (line ~303), so a crashed/dead session just looks busy. + +Fix: drain stderr into a bounded ring buffer (surface it on failure), watch exitCode/onDone, and emit a terminal SessionEnded state to the pane. Intersects T-283 (resume hang has no timeout/fallback). Tests: dead-child surfaces SessionEnded; stderr flood does not wedge the session.', 'done', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:01', '2026-06-11 22:53:45', NULL, '225dafdd282850fa3fc420acb0084355', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6K1QRC911JMQ9C960', 'bug', '06FB0TNQM5HM3N07VB3RPFJCFM', 'Resume hang has no timeout/fallback; resume decided by file-exists not content', 'Surfaced 2026-06-08 while chasing a --resume hang in the Claude pane (T-274 diagnostic line). The specific corrupted-transcript repro may turn out to be a one-off, but the code trace found two real latent gaps that make a resume hang unrecoverable regardless of root cause. + +(a) No timeout / no fallback on the init-event wait. +On spawn the orchestrator listens for the session_id from claude''s init event (StreamJsonSession sessionIdResolved -> session_orchestrator.dart ~257) with NO timeout. If ''claude --resume '' never emits an init event (hangs), the listener never fires: the pane shows ''resumed · '' + running indicator forever, the process is never killed, and there is no fallback to a fresh --session-id spawn. Unrecoverable without killing the process / restarting clide. + +(b) Resume is decided by file existence, not resumable content. +claude_pane.dart ~279: ''final resume = await File(transcriptFile).exists();'' — resume=true purely because the .jsonl exists. A metadata-only transcript (only system / permission-mode / attachment records; parseTranscriptChunk().items returns []) yields resume=true with zero seeded items. So clide passes --resume against a functionally empty session, the diagnostic logs ''fresh session (no history)'' (it keys off seeded count, not the resume flag — itself misleading), and if that resume hangs there is no fallback per (a). + +Acceptance: +1. If a --resume spawn yields no init event within a short timeout (e.g. 10-30s), fall back to a fresh --session-id spawn (or surface a recoverable error with a retry affordance) — the pane must never spin forever with no recovery. +2. Don''t pass --resume for a transcript that has no resumable conversation items: validate parsed item count (not just file existence) before choosing --resume vs --session-id, and/or detect-and-repair a metadata-only transcript. +3. The T-274 diagnostic log reflects the actual spawn mode (resume vs fresh), not just seeded-item count. +4. Tests: (i) fake process that never emits init -> pane falls back / surfaces error within the timeout; (ii) metadata-only transcript -> spawn chooses fresh, not --resume. + +Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork session id capture), D-77, claude_pane.dart:279/300-308, session_orchestrator.dart:240/257. + +UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority. + +T-361 (done, 2026-06-12) added the session-level building blocks this ticket can reuse: StreamJsonSession now watches the process exit code (SessionEnd with stderr tail, replay-latest via session.end) and the pane surfaces ''claude exited (code N) — /clear to restart''. A resume that dies at spawn now surfaces instead of hanging silently; what remains here is the timeout/fallback for a resume that starts but never produces the init event, and resume-decided-by-content.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-06-08 13:39:51', '2026-06-11 22:53:52', NULL, '06cc0a2e629ee7a192d24286f58af27f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1c5a71..a26c8c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. expanding a run announced nothing inside it; the exclusion is now scoped to the header and the inner cards stay in the a11y tree. (T-370) +- **A crashed Claude process no longer looks like it's still thinking.** + stderr is now drained continuously (an undrained pipe could block the + child mid-turn) and the exit code is watched: when the process dies the + pane stops spinning, clears any unanswerable permission prompt, reports + the exit in the status line, and logs the stderr tail. (T-361) + - **Accepting ExitPlanMode now leaves plan mode in the conversation panel.** Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying session out of plan mode, but clide's tracked permission mode didn't follow, diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 61ec271f..9cef3bed 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -71,6 +71,7 @@ class ClaudePane extends StatefulWidget { class _ClaudePaneState extends State { StreamSubscription? _statusSub; + StreamSubscription? _endSub; StreamSubscription? _projectSub; ConversationController? _conversation; StreamJsonSession? _session; @@ -177,6 +178,8 @@ class _ClaudePaneState extends State { _projectSub = null; _statusSub?.cancel(); _statusSub = null; + _endSub?.cancel(); + _endSub = null; // The orchestrator owns the session, so disposing this pane does NOT kill // it — that's what lets a hidden/kept-alive pane keep its session (T-169). // A secondary tab being *closed* is a real teardown, so close its session; @@ -228,6 +231,8 @@ class _ClaudePaneState extends State { Future _rebindToActiveProject() async { _statusSub?.cancel(); _statusSub = null; + _endSub?.cancel(); + _endSub = null; await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session _conversation = null; _session = null; @@ -327,6 +332,24 @@ class _ClaudePaneState extends State { if (!mounted) return; setState(() => _status = s); }); + // Surface a dead process instead of letting it look thoughtful (T-361): + // late binders read the replayed end; live sessions stream it. + final alreadyEnded = managed.session.end; + if (alreadyEnded != null) { + _onSessionEnd(alreadyEnded); + } else { + _endSub = managed.session.endedStream.listen(_onSessionEnd); + } + } + + /// The claude process exited under this pane's live session. Stop looking + /// busy, say so in the status line, and log the drained stderr tail — + /// the diagnostics that used to vanish (T-361). + void _onSessionEnd(SessionEnd end) { + if (!mounted) return; + final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}'; + _kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail'); + setState(() => _statusLine = 'claude exited (code ${end.exitCode}) — /clear to restart'); } // Send composed text to Claude over the stream-json channel. Commands clide @@ -444,6 +467,8 @@ class _ClaudePaneState extends State { Future _respawnWithSession(String sessionId, {bool clearTranscript = false}) async { _statusSub?.cancel(); _statusSub = null; + _endSub?.cancel(); + _endSub = null; await activeSessionOrchestrator?.close(_orchId); // kills the old session // Erase only after the process is dead, so claude isn't mid-write. final root = _repoRoot; diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index ce2d032e..2c82f4b2 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -21,6 +21,8 @@ import 'dart:io'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; /// The claude subprocess, abstracted so tests drive it without spawning. +/// Fakes `extend` this and override what they drive; the defaults below +/// describe a process with no real child behind it. abstract class StreamJsonProcess { /// stdout, one JSON event per line. Stream get lines; @@ -30,13 +32,43 @@ abstract class StreamJsonProcess { /// Terminate the process. Future kill(); + + /// The last lines of the child's stderr, drained continuously so the pipe + /// can never fill and block the child mid-turn (T-361). Default: none. + List get stderrTail => const []; + + /// Completes with the child's exit code, or null when there is no real + /// process to watch (fakes that never "exit"). + Future? get exitCode => null; +} + +/// A bounded FIFO of the most recent lines — the stderr tail kept for +/// post-mortem diagnostics while the stream itself is drained and dropped. +class BoundedLineBuffer { + BoundedLineBuffer({this.cap = 100}); + + final int cap; + final List _lines = []; + + void add(String line) { + _lines.add(line); + if (_lines.length > cap) _lines.removeAt(0); + } + + List get lines => List.unmodifiable(_lines); } /// Production [StreamJsonProcess] backed by a real `claude` process. -class ClaudeStreamJsonProcess implements StreamJsonProcess { - ClaudeStreamJsonProcess._(this._proc); +class ClaudeStreamJsonProcess extends StreamJsonProcess { + ClaudeStreamJsonProcess._(this._proc) { + // Drain stderr from the moment the process exists — with --verbose the + // CLI chats on stderr, and an undrained 64KB pipe blocks the child + // mid-turn with zero diagnostics (T-361). Keep a tail for post-mortems. + _proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(_stderr.add, onError: (Object _) {}); + } final Process _proc; + final BoundedLineBuffer _stderr = BoundedLineBuffer(); /// Spawn `claude` in stream-json mode. [sessionArgs] is `['--session-id', id]` /// for a new session or `['--resume', id]` to resume an existing one (T-161). @@ -75,6 +107,12 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess { Future kill() async { _proc.kill(); } + + @override + List get stderrTail => _stderr.lines; + + @override + Future get exitCode => _proc.exitCode; } /// An in-process MCP server clide hosts for a session, entirely over the @@ -194,6 +232,15 @@ final class DenyTool extends ToolDecision { /// Parses a [StreamJsonProcess]'s events into conversation items + status, /// answers control-channel prompts, and sends user messages. +/// Terminal session end: the claude process exited (crash or otherwise). +/// Carries the exit code and the drained stderr tail for diagnostics. +class SessionEnd { + const SessionEnd({required this.exitCode, required this.stderrTail}); + + final int exitCode; + final List stderrTail; +} + class StreamJsonSession { StreamJsonSession(this._proc, {List mcpServers = const []}) : _mcpServers = mcpServers; @@ -299,9 +346,25 @@ class StreamJsonSession { /// The latest known status — the current value [statusStream] last emitted. SessionStatus get status => _status; + /// Non-null once the claude process has exited (T-361). Late binders read + /// this; live listeners get [endedStream]. Never set by a deliberate + /// [dispose] — only by the process dying underneath a live session. + SessionEnd? get end => _end; + SessionEnd? _end; + final _endCtl = StreamController.broadcast(); + bool _disposed = false; + + /// Fires once when the process exits while the session is still live — + /// a crashed/dead session must not just look thoughtful (T-361). + Stream get endedStream => _endCtl.stream; + /// Begin consuming the process's event stream. void start() { _sub = _proc.lines.listen(_onLine, onError: (Object _) {}); + // Watch the process itself: stdout EOF alone is ambiguous, the exit + // code is not (T-361). + final exit = _proc.exitCode; + if (exit != null) unawaited(exit.then(_onExit)); // Declaring our in-process MCP servers in the `initialize` handshake is what // makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent // when we actually host a server, so a plain session is unchanged. @@ -712,7 +775,23 @@ class StreamJsonSession { _mergeStatus(SessionStatus(permissionMode: mode)); } + /// The process exited under a live session. Flip every "in flight" + /// surface off so the pane reflects reality instead of spinning forever. + void _onExit(int code) { + if (_disposed || _end != null) return; + _end = SessionEnd(exitCode: code, stderrTail: _proc.stderrTail); + _setBusy(false); + // A prompt pending against a dead process can never be answered — + // clear it so the composer comes back. + if (_queue.isNotEmpty) { + _queue.clear(); + _pendingCtl.add(null); + } + _endCtl.add(_end!); + } + Future dispose() async { + _disposed = true; // deliberate teardown — suppress the exit-watch path await _sub?.cancel(); await _proc.kill(); await _items.close(); @@ -720,5 +799,6 @@ class StreamJsonSession { await _sessionIdCtl.close(); await _pendingCtl.close(); await _busyCtl.close(); + await _endCtl.close(); } } diff --git a/test/builtin/claude/claude_meta_sidebar_test.dart b/test/builtin/claude/claude_meta_sidebar_test.dart index 98415ac0..c4f2ee90 100644 --- a/test/builtin/claude/claude_meta_sidebar_test.dart +++ b/test/builtin/claude/claude_meta_sidebar_test.dart @@ -20,7 +20,7 @@ import '../../helpers/widget_harness.dart'; // --------------------------------------------------------------------------- // Minimal fake process so orchestrator tests don't need a real `claude` binary. // --------------------------------------------------------------------------- -class _FakeProc implements StreamJsonProcess { +class _FakeProc extends StreamJsonProcess { final _ctl = StreamController.broadcast(); final List writes = []; bool killed = false; diff --git a/test/builtin/claude/claude_pane_test.dart b/test/builtin/claude/claude_pane_test.dart index 7a6aede0..dc52f28d 100644 --- a/test/builtin/claude/claude_pane_test.dart +++ b/test/builtin/claude/claude_pane_test.dart @@ -25,7 +25,7 @@ import 'package:flutter_test/flutter_test.dart'; import '../../helpers/kernel_fixture.dart'; -class _FakeProc implements StreamJsonProcess { +class _FakeProc extends StreamJsonProcess { final _ctl = StreamController.broadcast(); final List writes = []; bool killed = false; diff --git a/test/builtin/claude/session_lifecycle_test.dart b/test/builtin/claude/session_lifecycle_test.dart index 93926474..bda90daf 100644 --- a/test/builtin/claude/session_lifecycle_test.dart +++ b/test/builtin/claude/session_lifecycle_test.dart @@ -18,7 +18,7 @@ import 'package:test/test.dart'; // Minimal fake process — same as session_orchestrator_test.dart. // --------------------------------------------------------------------------- -class _FakeProc implements StreamJsonProcess { +class _FakeProc extends StreamJsonProcess { final _ctl = StreamController.broadcast(); final List writes = []; bool killed = false; diff --git a/test/builtin/claude/session_orchestrator_test.dart b/test/builtin/claude/session_orchestrator_test.dart index fc752fb6..a7a14bb5 100644 --- a/test/builtin/claude/session_orchestrator_test.dart +++ b/test/builtin/claude/session_orchestrator_test.dart @@ -7,7 +7,7 @@ import 'package:clide/builtin/claude/src/stream_json_session.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:flutter_test/flutter_test.dart'; -class _FakeProc implements StreamJsonProcess { +class _FakeProc extends StreamJsonProcess { final _ctl = StreamController.broadcast(); final List writes = []; bool killed = false; diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index 09708d71..0195aad2 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -5,17 +5,25 @@ import 'package:clide/builtin/claude/src/stream_json_session.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:test/test.dart'; -class _FakeProc implements StreamJsonProcess { +class _FakeProc extends StreamJsonProcess { final _ctl = StreamController(); final List writes = []; bool killed = false; + /// Drives the T-361 exit watch; never completes unless a test exits it. + final exit = Completer(); + final List stderr = []; + @override Stream get lines => _ctl.stream; @override void writeLine(String line) => writes.add(line); @override Future kill() async => killed = true; + @override + Future get exitCode => exit.future; + @override + List get stderrTail => stderr; void emit(String line) => _ctl.add(line); } @@ -745,4 +753,58 @@ void main() { expect((r['error'] as Map)['message'], contains('resources/list')); }); }); + + // T-361: nothing watched the process itself — a crashed claude just + // looked thoughtful forever. + group('process exit (T-361)', () { + test('exit emits SessionEnd with code + stderr tail and clears busy', () async { + final ends = []; + session.endedStream.listen(ends.add); + session.send('do something'); + await Future.delayed(Duration.zero); + expect(session.busy, isTrue, reason: 'a send marks the turn in flight'); + + proc.stderr.addAll(['boom: stack', 'fatal: died']); + proc.exit.complete(70); + await Future.delayed(Duration.zero); + + expect(session.busy, isFalse, reason: 'a dead process is not thinking'); + expect(ends, hasLength(1)); + expect(ends.single.exitCode, 70); + expect(ends.single.stderrTail, ['boom: stack', 'fatal: died']); + expect(session.end, same(ends.single), reason: 'late binders replay via the getter'); + }); + + test('exit clears a pending prompt — it can never be answered', () async { + final pendings = []; + session.pendingPromptStream.listen(pendings.add); + proc.emit(canUseTool('p1')); + await Future.delayed(Duration.zero); + expect(session.pendingPrompt, isNotNull); + + proc.exit.complete(1); + await Future.delayed(Duration.zero); + expect(session.pendingPrompt, isNull); + expect(pendings.last, isNull, reason: 'the composer swaps back from the prompt UI'); + }); + + test('a deliberate dispose suppresses the exit watch', () async { + final p = _FakeProc(); + final s = StreamJsonSession(p)..start(); + await s.dispose(); + p.exit.complete(9); // the kill's exit must not surface as a crash + await Future.delayed(Duration.zero); + expect(s.end, isNull); + }); + }); + + group('BoundedLineBuffer', () { + test('keeps only the last cap lines', () { + final b = BoundedLineBuffer(cap: 3); + for (var i = 0; i < 5; i++) { + b.add('line $i'); + } + expect(b.lines, ['line 2', 'line 3', 'line 4']); + }); + }); } diff --git a/test/builtin/claude/ticket_pick_up_test.dart b/test/builtin/claude/ticket_pick_up_test.dart index 29027115..250f949f 100644 --- a/test/builtin/claude/ticket_pick_up_test.dart +++ b/test/builtin/claude/ticket_pick_up_test.dart @@ -14,7 +14,7 @@ import 'package:flutter_test/flutter_test.dart'; import '../../helpers/fake_ipc.dart'; -class _FakeProc implements StreamJsonProcess { +class _FakeProc extends StreamJsonProcess { final _ctl = StreamController.broadcast(); final List writes = []; @override