diff --git a/Sources/Reeve/Models/PM2Environment.swift b/Sources/Reeve/Models/PM2Environment.swift index 76cfcf2..caee740 100644 --- a/Sources/Reeve/Models/PM2Environment.swift +++ b/Sources/Reeve/Models/PM2Environment.swift @@ -43,6 +43,27 @@ public struct PM2Environment: Identifiable, Hashable, Sendable { public var id: String { path } + /// macOS caps unix-domain socket paths (`sun_path`) at 104 characters. PM2 + /// binds its control sockets at `PM2_HOME/.sock`; the longest name it + /// uses is `interactor.sock`. If that full path exceeds the limit the daemon + /// can never bind or connect (`EINVAL`) — no amount of restarting fixes it. + static let maxSocketPathLength = 104 + static let longestSocketPathComponent = "/interactor.sock" + + /// Whether this workspace's PM2 socket path is too long to ever work. When + /// true, reeve must not run any `pm2` command against it: every command + /// auto-spawns a fresh daemon that immediately breaks, piling up zombies. + public var socketPathTooLong: Bool { + PM2Environment.socketPathTooLong(forHome: path) + } + + /// Pure length check, extracted for testing. Measures UTF-8 bytes because + /// `sun_path` is a fixed 104-byte C buffer — a multi-byte character in the + /// path costs more than one byte, so grapheme count would under-report. + static func socketPathTooLong(forHome home: String) -> Bool { + (home + longestSocketPathComponent).utf8.count > maxSocketPathLength + } + public init(path: String, isActive: Bool = false) { self.path = path self.isActive = isActive diff --git a/Sources/Reeve/Services/PM2Service.swift b/Sources/Reeve/Services/PM2Service.swift index 7a3e095..883973b 100644 --- a/Sources/Reeve/Services/PM2Service.swift +++ b/Sources/Reeve/Services/PM2Service.swift @@ -440,6 +440,11 @@ public class PM2Service: ObservableObject { // MARK: - Private + /// Sentinel error stored for a workspace whose socket path exceeds the macOS + /// limit. The UI recognises this case from `PM2Environment.socketPathTooLong` + /// and renders a tailored banner rather than the generic daemon error. + nonisolated static let socketPathTooLongError = "PM2_HOME socket path exceeds the macOS 104-character limit" + enum FetchResult: Sendable { case success([PM2Process]) case failure(String) @@ -463,6 +468,11 @@ public class PM2Service: ObservableObject { using resolution: PM2BinaryResolver.Resolution, sockets: SocketScanner.Snapshot ) -> FetchResult { + // Socket path too long for macOS: any pm2 command auto-spawns a daemon + // that instantly breaks (EINVAL), so never run one — just report it. + guard !environment.socketPathTooLong else { + return .failure(PM2Service.socketPathTooLongError) + } // If no daemon is running, return empty — don't call pm2 which would spawn one guard isDaemonRunning(for: environment) else { return .success([]) diff --git a/Sources/Reeve/Views/EnvironmentSectionView.swift b/Sources/Reeve/Views/EnvironmentSectionView.swift index e92f54a..8974e68 100644 --- a/Sources/Reeve/Views/EnvironmentSectionView.swift +++ b/Sources/Reeve/Views/EnvironmentSectionView.swift @@ -54,7 +54,7 @@ struct EnvironmentSectionView: View { var body: some View { DisclosureGroup(isExpanded: isExpanded) { if let errorMessage { - let parsed = Self.parseErrorMessage(errorMessage) + let parsed = Self.parseErrorMessage(errorMessage, pathTooLong: environment.socketPathTooLong) VStack(alignment: .leading, spacing: 6) { HStack(spacing: 4) { Image(systemName: "exclamationmark.triangle.fill") @@ -318,7 +318,19 @@ struct EnvironmentSectionView: View { let suggestKill: Bool } - private static func parseErrorMessage(_ message: String) -> ParsedError { + private static func parseErrorMessage(_ message: String, pathTooLong: Bool = false) -> ParsedError { + // Path over the macOS socket limit: the daemon can never run, so reeve + // won't poll it with pm2 (that would auto-spawn a broken daemon). + // Explain the real cause and durable fix. "Kill daemon" is still + // offered — it SIGKILLs any lingering zombie directly (no pm2 command). + if pathTooLong { + return ParsedError( + title: "Workspace path too long", + detail: "This workspace's path exceeds macOS's 104-character limit for PM2 sockets, so its daemon can't run. Recreate the workspace with a shorter name.", + suggestKill: true + ) + } + let lower = message.lowercased() // Socket connection errors — daemon is broken/zombie diff --git a/Tests/reeveTests/ReeveTests.swift b/Tests/reeveTests/ReeveTests.swift index cc2931d..17ed240 100644 --- a/Tests/reeveTests/ReeveTests.swift +++ b/Tests/reeveTests/ReeveTests.swift @@ -1365,6 +1365,56 @@ struct ParsePortTests { } } +// MARK: - Socket path length guard + +@Suite("PM2Environment.socketPathTooLong") +struct SocketPathTooLongTests { + + // The effective socket path is home + "/interactor.sock" (16 chars); macOS + // caps it at 104, so a home longer than 88 chars is too long. + private func home(length: Int) -> String { + "/" + String(repeating: "a", count: length - 1) + } + + @Test("Short path is fine") + func shortPath() { + #expect(PM2Environment.socketPathTooLong(forHome: "/Users/x/.pm2") == false) + } + + @Test("Home at the 88-char boundary is fine (path == 104)") + func atBoundary() { + let h = home(length: 88) + #expect(h.count == 88) + #expect(PM2Environment.socketPathTooLong(forHome: h) == false) + } + + @Test("One char over the boundary is too long") + func overBoundary() { + #expect(PM2Environment.socketPathTooLong(forHome: home(length: 89)) == true) + } + + @Test("The real over-long workspace path is flagged") + func realWorkspace() { + let h = "/Users/fredgptzero/.pm2-fredrivett-eng-4058-fix-re-scanning-to-be-page-by-page-and-show-scan-button-in" + #expect(PM2Environment.socketPathTooLong(forHome: h) == true) + } + + @Test("Exposed instance property matches the static check") + func instanceProperty() { + let env = PM2Environment(path: home(length: 200)) + #expect(env.socketPathTooLong == true) + } + + @Test("Measures UTF-8 bytes, not grapheme count") + func countsBytesNotGraphemes() { + // 1 + 60 + 20 = 81 graphemes -> +16 = 97, under 104 by grapheme count, + // but 1 + 60 + 20*4 = 141 UTF-8 bytes -> well over the 104-byte limit. + let h = "/" + String(repeating: "a", count: 60) + String(repeating: "🚀", count: 20) + #expect((h + "/interactor.sock").count <= 104) // grapheme count would pass + #expect(PM2Environment.socketPathTooLong(forHome: h) == true) // byte count correctly fails + } +} + // MARK: - Address-in-use log detection @Suite("PM2Service.logReportsAddressInUse")