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:
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user