watch the claude process: drain stderr, surface exit (T-361)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,7 @@ class ClaudePane extends StatefulWidget {
|
||||
|
||||
class _ClaudePaneState extends State<ClaudePane> {
|
||||
StreamSubscription<SessionStatus>? _statusSub;
|
||||
StreamSubscription<SessionEnd>? _endSub;
|
||||
StreamSubscription<ProjectOpened>? _projectSub;
|
||||
ConversationController? _conversation;
|
||||
StreamJsonSession? _session;
|
||||
@@ -177,6 +178,8 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_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<ClaudePane> {
|
||||
Future<void> _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<ClaudePane> {
|
||||
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<ClaudePane> {
|
||||
Future<void> _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;
|
||||
|
||||
@@ -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<String> get lines;
|
||||
@@ -30,13 +32,43 @@ abstract class StreamJsonProcess {
|
||||
|
||||
/// Terminate the process.
|
||||
Future<void> 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<String> 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<int>? 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<String> _lines = [];
|
||||
|
||||
void add(String line) {
|
||||
_lines.add(line);
|
||||
if (_lines.length > cap) _lines.removeAt(0);
|
||||
}
|
||||
|
||||
List<String> 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<void> kill() async {
|
||||
_proc.kill();
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get stderrTail => _stderr.lines;
|
||||
|
||||
@override
|
||||
Future<int> 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<String> stderrTail;
|
||||
}
|
||||
|
||||
class StreamJsonSession {
|
||||
StreamJsonSession(this._proc, {List<McpServer> 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<SessionEnd>.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<SessionEnd> 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<void> 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user