mirror of
https://github.com/MioMioOS/MioIsland
synced 2026-04-21 13:37:26 +00:00
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:
parent
f6a8754237
commit
d49a86c76b
10 changed files with 378 additions and 21 deletions
|
|
@ -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", "声音设置") }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
21
ClaudeIsland/Services/State/ProcessLivenessChecker.swift
Normal file
21
ClaudeIsland/Services/State/ProcessLivenessChecker.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
24
ClaudeIsland/UI/Helpers/SessionFilter.swift
Normal file
24
ClaudeIsland/UI/Helpers/SessionFilter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
64
ClaudeIslandTests/SessionFilterTests.swift
Normal file
64
ClaudeIslandTests/SessionFilterTests.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
143
ClaudeIslandTests/ZombieScanTests.swift
Normal file
143
ClaudeIslandTests/ZombieScanTests.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue