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:
2026-06-12 00:54:12 +02:00
co-authored by Claude Fable 5
parent 77c4341318
commit a919d79ce1
11 changed files with 243 additions and 8 deletions
@@ -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<String>.broadcast();
final List<String> writes = [];
bool killed = false;
+1 -1
View File
@@ -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<String>.broadcast();
final List<String> writes = [];
bool killed = false;
@@ -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<String>.broadcast();
final List<String> writes = [];
bool killed = false;
@@ -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<String>.broadcast();
final List<String> writes = [];
bool killed = false;
@@ -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<String>();
final List<String> writes = [];
bool killed = false;
/// Drives the T-361 exit watch; never completes unless a test exits it.
final exit = Completer<int>();
final List<String> stderr = [];
@override
Stream<String> get lines => _ctl.stream;
@override
void writeLine(String line) => writes.add(line);
@override
Future<void> kill() async => killed = true;
@override
Future<int> get exitCode => exit.future;
@override
List<String> 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 = <SessionEnd>[];
session.endedStream.listen(ends.add);
session.send('do something');
await Future<void>.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<void>.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 = <ToolPrompt?>[];
session.pendingPromptStream.listen(pendings.add);
proc.emit(canUseTool('p1'));
await Future<void>.delayed(Duration.zero);
expect(session.pendingPrompt, isNotNull);
proc.exit.complete(1);
await Future<void>.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<void>.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']);
});
});
}
+1 -1
View File
@@ -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<String>.broadcast();
final List<String> writes = [];
@override