fix: detect zombie sessions and show ended state in Dynamic Island

已结束或进程已死的 session 在灵动岛中显示为灰色 "Ended" 状态,
隐藏跳转按钮,设置页新增一键清除按钮。

- 新增 ProcessLivenessChecker 协议 + PosixLivenessChecker(kill(pid,0))
- SessionStore 每 30s 扫描非 ended session,进程已死自动标记 ended
- ClaudeInstancesView 使用提取的 SessionFilter 过滤逻辑
- InstanceRow 对 ended session 整行降透明度 + Ended 标签 + 隐藏终端按钮
- NotchMenuView 设置页新增 "清除已结束" 按钮
- 附带测试代码(需手动在 Xcode 中添加测试 target)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jacinta Gu 2026-04-06 14:40:00 +08:00
parent f6a8754237
commit d49a86c76b
10 changed files with 378 additions and 21 deletions

View file

@ -85,6 +85,11 @@ enum L10n {
static var goToTerminal: String { tr("Go to Terminal", "前往终端") }
static var terminal: String { tr("Terminal", "终端") }
// MARK: - Session state
static var ended: String { tr("Ended", "已结束") }
static var clearEnded: String { tr("Clear Ended", "清除已结束") }
// MARK: - Sound settings
static var soundSettings: String { tr("Sound Settings", "声音设置") }

View file

@ -70,6 +70,9 @@ enum SessionEvent: Sendable {
/// Session has ended
case sessionEnded(sessionId: String)
/// Remove all ended sessions from state
case clearEndedSessions
/// Request to load initial history from file
case loadHistory(sessionId: String, cwd: String)
@ -215,6 +218,8 @@ extension SessionEvent: CustomStringConvertible {
return "subagentStopped(session: \(sessionId.prefix(8)), task: \(taskToolId.prefix(12)))"
case .agentFileUpdated(let sessionId, let taskToolId, let tools):
return "agentFileUpdated(session: \(sessionId.prefix(8)), task: \(taskToolId.prefix(12)), tools: \(tools.count))"
case .clearEndedSessions:
return "clearEndedSessions"
}
}
}

View file

@ -68,10 +68,17 @@ class ClaudeSessionMonitor: ObservableObject {
}
}
)
Task { await SessionStore.shared.startZombieScan() }
}
func stopMonitoring() {
HookSocketServer.shared.stop()
Task { await SessionStore.shared.stopZombieScan() }
}
/// Remove all ended sessions from the store
func clearEndedSessions() {
Task { await SessionStore.shared.process(.clearEndedSessions) }
}
// MARK: - Permission Handling

View file

@ -0,0 +1,21 @@
//
// ProcessLivenessChecker.swift
// ClaudeIsland
//
// Checks whether a process is still alive via POSIX signals.
// Protocol-based for test injection.
//
import Darwin
protocol ProcessLivenessChecker: Sendable {
nonisolated func isAlive(pid: Int) -> Bool
}
struct PosixLivenessChecker: ProcessLivenessChecker {
nonisolated func isAlive(pid: Int) -> Bool {
// signal 0 checks existence without sending a signal
// EPERM means the process exists but we lack permission still alive
kill(pid_t(pid), 0) == 0 || errno == EPERM
}
}

View file

@ -29,6 +29,12 @@ actor SessionStore {
/// Sync debounce interval (100ms)
private let syncDebounceNs: UInt64 = 100_000_000
/// Process liveness checker (injectable for testing)
private let livenessChecker: ProcessLivenessChecker
/// Background task for periodic zombie session scanning
private var zombieScanTask: Task<Void, Never>?
// MARK: - Published State (for UI)
/// Publisher for session state changes (nonisolated for Combine subscription from any context)
@ -41,7 +47,9 @@ actor SessionStore {
// MARK: - Initialization
private init() {}
init(livenessChecker: ProcessLivenessChecker = PosixLivenessChecker()) {
self.livenessChecker = livenessChecker
}
// MARK: - Event Processing
@ -107,6 +115,9 @@ actor SessionStore {
case .agentFileUpdated:
// No longer used - subagent tools are populated from JSONL completion
break
case .clearEndedSessions:
clearEndedSessions()
}
publishState()
@ -874,6 +885,53 @@ actor SessionStore {
Self.logger.info("/clear processed for session \(sessionId.prefix(8), privacy: .public) - marked for reconciliation")
}
// MARK: - Zombie Session Detection
/// Start periodic scanning for zombie sessions (process died without sending SessionEnd)
func startZombieScan(interval: TimeInterval = 30) {
zombieScanTask?.cancel()
zombieScanTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
guard !Task.isCancelled else { break }
await self?.scanForZombies()
}
}
}
/// Stop the zombie scanner
func stopZombieScan() {
zombieScanTask?.cancel()
zombieScanTask = nil
}
/// Check all non-ended sessions for dead processes
func scanForZombies() {
var changed = false
for (sessionId, session) in sessions {
guard session.phase != .ended else { continue }
guard let pid = session.pid else { continue }
if !livenessChecker.isAlive(pid: pid) {
Self.logger.info("Zombie detected: session \(sessionId.prefix(8), privacy: .public) PID \(pid) is dead")
sessions[sessionId]?.phase = .ended
cancelPendingSync(sessionId: sessionId)
changed = true
}
}
if changed {
publishState()
}
}
/// Remove all ended sessions from state
private func clearEndedSessions() {
let endedIds = sessions.filter { $0.value.phase == .ended }.map(\.key)
for id in endedIds {
sessions.removeValue(forKey: id)
cancelPendingSync(sessionId: id)
}
}
// MARK: - Session End Processing
private func processSessionEnd(sessionId: String) async {

View file

@ -0,0 +1,24 @@
//
// SessionFilter.swift
// ClaudeIsland
//
// Extracted filtering logic for session display list.
// Separated for testability.
//
import Foundation
enum SessionFilter {
/// Filter sessions for display: hide rate-limit noise (ended sessions that ran < 30s).
/// Ended sessions that ran >= 30s are kept and shown with "Ended" visual state.
static func filterForDisplay(_ sessions: [SessionState]) -> [SessionState] {
sessions.filter { session in
if session.phase == .ended {
// Rate-limit noise: short-lived sessions that ended quickly
let duration = Date().timeIntervalSince(session.createdAt)
return duration >= 30
}
return true
}
}
}

View file

@ -281,15 +281,7 @@ struct ClaudeInstancesView: View {
/// Secondary sort: by last user message date (stable - doesn't change when agent responds)
/// Note: approval requests stay in their date-based position to avoid layout shift
private var sortedInstances: [SessionState] {
sessionMonitor.instances
.filter { session in
// Filter out short-lived ended sessions (< 30s, likely from rate limit checks)
if session.phase == .ended {
let duration = Date().timeIntervalSince(session.createdAt)
return duration > 30
}
return true
}
SessionFilter.filterForDisplay(sessionMonitor.instances)
.sorted { a, b in
let priorityA = phasePriority(a.phase)
let priorityB = phasePriority(b.phase)
@ -594,6 +586,9 @@ struct InstanceRow: View {
}
}
/// Whether this session has ended
private var isEnded: Bool { session.phase == .ended }
private var iconScale: CGFloat { isActive ? 0.45 : 0.35 }
private var iconSize: CGFloat { isActive ? 28 : 22 }
private var titleFontSize: CGFloat { isActive ? 13 : 11 }
@ -657,22 +652,34 @@ struct InstanceRow: View {
Capsule().fill(terminalTagColor.opacity(0.12))
)
// Ended tag
if isEnded {
Text(L10n.ended)
.font(.system(size: 8, weight: .semibold))
.foregroundColor(.white.opacity(0.4))
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(Capsule().fill(Color.white.opacity(0.08)))
}
// Duration colored when active
Text(durationText)
.font(.system(size: 10, weight: isActive ? .medium : .regular))
.foregroundColor(isActive ? accentColor.opacity(0.7) : .white.opacity(0.3))
// Terminal jump button green tinted
Image(systemName: "terminal")
.font(.system(size: 10))
.foregroundColor(Color(red: 0.29, green: 0.87, blue: 0.5).opacity(0.7))
.frame(width: 20, height: 20)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color(red: 0.29, green: 0.87, blue: 0.5).opacity(0.1))
)
.contentShape(Rectangle())
.onTapGesture { onFocus() }
// Terminal jump button hidden for ended sessions
if !isEnded {
Image(systemName: "terminal")
.font(.system(size: 10))
.foregroundColor(Color(red: 0.29, green: 0.87, blue: 0.5).opacity(0.7))
.frame(width: 20, height: 20)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color(red: 0.29, green: 0.87, blue: 0.5).opacity(0.1))
)
.contentShape(Rectangle())
.onTapGesture { onFocus() }
}
// Delete button (ended/idle sessions)
if !isActive {
@ -796,6 +803,7 @@ struct InstanceRow: View {
}
}
.onHover { isHovered = $0 }
.opacity(isEnded ? 0.4 : 1.0)
}
// MARK: - AskUserQuestion Response

View file

@ -139,6 +139,28 @@ struct NotchMenuView: View {
}
.padding(.horizontal, 4)
// Clear ended sessions
Button {
Task { await SessionStore.shared.process(.clearEndedSessions) }
} label: {
HStack(spacing: 4) {
Image(systemName: "trash")
.font(.system(size: 9))
.foregroundColor(.white.opacity(0.5))
.frame(width: 12)
Text(L10n.clearEnded)
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.7))
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.04)))
}
.buttonStyle(.plain)
.padding(.horizontal, 4)
AccessibilityRow(isEnabled: AXIsProcessTrusted())
// Star & Feedback

View file

@ -0,0 +1,64 @@
//
// SessionFilterTests.swift
// ClaudeIslandTests
//
// Tests for session display filtering logic.
//
import XCTest
@testable import ClaudeIsland
final class SessionFilterTests: XCTestCase {
/// Ended session that ran >= 30s should be kept (shown as "Ended" state)
func test_endedLongSession_kept() {
let session = makeSession(phase: .ended, createdSecondsAgo: 60)
let result = SessionFilter.filterForDisplay([session])
XCTAssertEqual(result.count, 1)
}
/// Ended session that ran < 30s should be hidden (rate-limit noise)
func test_endedShortSession_hidden() {
let session = makeSession(phase: .ended, createdSecondsAgo: 5)
let result = SessionFilter.filterForDisplay([session])
XCTAssertEqual(result.count, 0)
}
/// Active session should always be kept
func test_activeSession_kept() {
let session = makeSession(phase: .processing, createdSecondsAgo: 10)
let result = SessionFilter.filterForDisplay([session])
XCTAssertEqual(result.count, 1)
}
/// Idle session should always be kept
func test_idleSession_kept() {
let session = makeSession(phase: .idle, createdSecondsAgo: 120)
let result = SessionFilter.filterForDisplay([session])
XCTAssertEqual(result.count, 1)
}
/// Mixed sessions: filters only short-lived ended ones
func test_mixedSessions_correctFiltering() {
let sessions = [
makeSession(phase: .processing, createdSecondsAgo: 10),
makeSession(phase: .ended, createdSecondsAgo: 5), // noise filtered
makeSession(phase: .ended, createdSecondsAgo: 120), // kept
makeSession(phase: .idle, createdSecondsAgo: 300),
]
let result = SessionFilter.filterForDisplay(sessions)
XCTAssertEqual(result.count, 3)
}
// MARK: - Helpers
private func makeSession(phase: SessionPhase, createdSecondsAgo: TimeInterval) -> SessionState {
SessionState(
sessionId: UUID().uuidString,
cwd: "/tmp/test",
projectName: "test",
phase: phase,
createdAt: Date().addingTimeInterval(-createdSecondsAgo)
)
}
}

View file

@ -0,0 +1,143 @@
//
// ZombieScanTests.swift
// ClaudeIslandTests
//
// Tests for zombie session detection via process liveness checking.
//
import XCTest
@testable import ClaudeIsland
// MARK: - Mock Liveness Checker
struct MockLivenessChecker: ProcessLivenessChecker {
let aliveSet: Set<Int>
nonisolated func isAlive(pid: Int) -> Bool {
aliveSet.contains(pid)
}
}
// MARK: - Tests
final class ZombieScanTests: XCTestCase {
/// Dead process should be marked as ended
func test_deadProcess_markedEnded() async {
let checker = MockLivenessChecker(aliveSet: [])
let store = SessionStore(livenessChecker: checker)
// Create a session with a "dead" PID
let event = makeHookEvent(sessionId: "test-1", cwd: "/tmp", pid: 99999, status: nil)
await store.process(.hookReceived(event))
// Verify session exists and is not ended
let before = await store.session(for: "test-1")
XCTAssertNotNil(before)
XCTAssertNotEqual(before?.phase, .ended)
// Run zombie scan
await store.scanForZombies()
// Session should now be ended
let after = await store.session(for: "test-1")
XCTAssertEqual(after?.phase, .ended)
}
/// Live process should not be affected
func test_liveProcess_notAffected() async {
let checker = MockLivenessChecker(aliveSet: [42])
let store = SessionStore(livenessChecker: checker)
let event = makeHookEvent(sessionId: "test-2", cwd: "/tmp", pid: 42, status: nil)
await store.process(.hookReceived(event))
await store.scanForZombies()
let session = await store.session(for: "test-2")
XCTAssertNotNil(session)
XCTAssertNotEqual(session?.phase, .ended)
}
/// Session with no PID should be ignored by zombie scan
func test_noPid_ignored() async {
let checker = MockLivenessChecker(aliveSet: [])
let store = SessionStore(livenessChecker: checker)
let event = makeHookEvent(sessionId: "test-3", cwd: "/tmp", pid: nil, status: nil)
await store.process(.hookReceived(event))
await store.scanForZombies()
let session = await store.session(for: "test-3")
XCTAssertNotNil(session)
XCTAssertNotEqual(session?.phase, .ended)
}
/// Already ended session should not be reprocessed
func test_alreadyEnded_skipped() async {
let checker = MockLivenessChecker(aliveSet: [])
let store = SessionStore(livenessChecker: checker)
let event = makeHookEvent(sessionId: "test-4", cwd: "/tmp", pid: 100, status: "ended")
await store.process(.hookReceived(event))
let before = await store.session(for: "test-4")
XCTAssertEqual(before?.phase, .ended)
// Should not crash or cause issues
await store.scanForZombies()
let after = await store.session(for: "test-4")
XCTAssertEqual(after?.phase, .ended)
}
/// clearEndedSessions should remove only ended sessions
func test_clearEndedSessions() async {
let checker = MockLivenessChecker(aliveSet: [10, 20])
let store = SessionStore(livenessChecker: checker)
// Create 3 sessions: one will be ended, two active
let e1 = makeHookEvent(sessionId: "ended-1", cwd: "/tmp", pid: 1, status: "ended")
let e2 = makeHookEvent(sessionId: "active-1", cwd: "/tmp", pid: 10, status: nil)
let e3 = makeHookEvent(sessionId: "active-2", cwd: "/tmp", pid: 20, status: nil)
await store.process(.hookReceived(e1))
await store.process(.hookReceived(e2))
await store.process(.hookReceived(e3))
// Verify ended session exists
let endedBefore = await store.session(for: "ended-1")
XCTAssertEqual(endedBefore?.phase, .ended)
// Clear ended sessions
await store.process(.clearEndedSessions)
// Ended session should be gone
let endedAfter = await store.session(for: "ended-1")
XCTAssertNil(endedAfter)
// Active sessions should remain
let active1 = await store.session(for: "active-1")
XCTAssertNotNil(active1)
let active2 = await store.session(for: "active-2")
XCTAssertNotNil(active2)
}
// MARK: - Helpers
private func makeHookEvent(sessionId: String, cwd: String, pid: Int?, status: String?) -> HookEvent {
HookEvent(
sessionId: sessionId,
cwd: cwd,
event: status == "ended" ? "Stop" : "UserPromptSubmit",
status: status ?? "active",
pid: pid,
tty: nil,
tool: nil,
toolInput: nil,
toolUseId: nil,
notificationType: nil,
message: nil
)
}
}