fix(claude): /clear no longer kills the session on CLI 2.1.177 (T-437)
/clear tore the session down and respawned on the same deterministic --session-id BEFORE the old claude process had actually exited. The orchestrator's close() ran conversation.dispose() unawaited and kill() only sent SIGTERM without awaiting exitCode, so the respawn raced a still-alive holder of the id — claude 2.1.177 rejects it as "Session ID … is already in use" and exits 1. Root cause confirmed from clide's own crash log + isolated probes against 2.1.177: the id frees the instant the holder dies (SIGTERM cleans the new ~/.claude/sessions/<pid>.json registry), so awaiting real death is the fix — and it preserves T-268's deterministic-id continuity (chosen over minting a fresh id, which would change the continuity model). - stream_json_session: kill() awaits exitCode (SIGTERM → 2s → SIGKILL → await); dispose() idempotent (shared cached future); new SessionEnd.reason getter (last non-empty stderr line, capped). - session_orchestrator: close() awaits session.dispose() so teardown returns only once the process is truly dead, before clear + respawn. - claude_pane: surface end.reason in the status line — no more opaque "code 1". - session_naming: correct the stale clearSessionTranscript doc (real sidecar is the shared memory/ dir) + the await-death precondition. - tests: close() blocks until process exit; SessionEnd.reason. CLI 2.1.177 re-probe (folded-in scope): sessions/ registry characterized (PID-keyed, cleaned on exit); init cache auto-refreshes; advertised slash_commands show no routing-table drift. No further code change needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,26 @@ class _FakeProc extends StreamJsonProcess {
|
||||
Future<void> kill() async => killed = true;
|
||||
}
|
||||
|
||||
/// A fake whose [kill] blocks until [gate] completes — models a real `claude`
|
||||
/// that hasn't actually exited yet, so a test can prove `close()` waits for the
|
||||
/// process's real death before returning (T-437).
|
||||
class _GatedProc extends StreamJsonProcess {
|
||||
_GatedProc(this._gate);
|
||||
final Completer<void> _gate;
|
||||
final _ctl = StreamController<String>.broadcast();
|
||||
int killCount = 0;
|
||||
|
||||
@override
|
||||
Stream<String> get lines => _ctl.stream;
|
||||
@override
|
||||
void writeLine(String line) {}
|
||||
@override
|
||||
Future<void> kill() async {
|
||||
killCount++;
|
||||
await _gate.future;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -207,6 +227,37 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- close awaits the process's real death (T-437) ----------------------
|
||||
|
||||
group('close — awaits real process death before returning (T-437)', () {
|
||||
test('close() does not complete until the process exit resolves', () async {
|
||||
final gate = Completer<void>();
|
||||
final created = <_GatedProc>[];
|
||||
final orch = ClaudeSessionOrchestrator(
|
||||
processFactory: ({required sessionArgs, required cwd, env}) async {
|
||||
final p = _GatedProc(gate);
|
||||
created.add(p);
|
||||
return p;
|
||||
},
|
||||
);
|
||||
await orch.spawn(_spec('primary'));
|
||||
|
||||
var closed = false;
|
||||
final closing = orch.close('primary').then((_) => closed = true);
|
||||
await pumpEventQueue();
|
||||
// The process hasn't exited yet, so /clear must not have proceeded to
|
||||
// delete the transcript + respawn — close() is still awaiting death.
|
||||
expect(closed, isFalse, reason: 'close must block until the old process is truly dead');
|
||||
|
||||
gate.complete(); // the claude process finally exits
|
||||
await closing;
|
||||
expect(closed, isTrue);
|
||||
expect(created.single.killCount, 1, reason: 'idempotent dispose kills exactly once');
|
||||
|
||||
orch.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- claude.kill-all-sessions via orchestrator --------------------------
|
||||
|
||||
group('claude.kill-all-sessions via orchestrator (T-167)', () {
|
||||
|
||||
@@ -937,6 +937,24 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('SessionEnd.reason (T-437)', () {
|
||||
test('is the last non-empty stderr line — the CLI error', () {
|
||||
const end = SessionEnd(exitCode: 1, stderrTail: ['warming up', '', 'Error: Session ID abc is already in use.', ' ']);
|
||||
expect(end.reason, 'Error: Session ID abc is already in use.');
|
||||
});
|
||||
|
||||
test('is empty when stderr was silent', () {
|
||||
expect(const SessionEnd(exitCode: 1, stderrTail: []).reason, isEmpty);
|
||||
expect(const SessionEnd(exitCode: 1, stderrTail: ['', ' ']).reason, isEmpty);
|
||||
});
|
||||
|
||||
test('caps a very long line so it cannot blow out the status line', () {
|
||||
final end = SessionEnd(exitCode: 1, stderrTail: ['x' * 500]);
|
||||
expect(end.reason.length, 201); // 200 chars + ellipsis
|
||||
expect(end.reason.endsWith('…'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('BoundedLineBuffer', () {
|
||||
test('keeps only the last cap lines', () {
|
||||
final b = BoundedLineBuffer(cap: 3);
|
||||
|
||||
Reference in New Issue
Block a user