From ea666487e2676cb325fac72de82af4c1c2f74709 Mon Sep 17 00:00:00 2001 From: Fred Rivett Date: Wed, 1 Jul 2026 15:15:29 +0100 Subject: [PATCH 1/2] Guard against PM2 socket paths over the macOS 104-char limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace whose PM2_HOME makes the socket path (PM2_HOME + /interactor.sock) exceed macOS's 104-char sun_path limit can never bind its daemon socket — every pm2 command auto-spawns a fresh daemon that instantly breaks with EINVAL, piling up zombies. reeve now detects this in fetchProcessesSync and returns early without running any pm2 command, so it stops contributing to the spawn cascade. The workspace shows a tailored "Workspace path too long" banner pointing at the real fix (shorter name) instead of the generic socket error. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Reeve/Models/PM2Environment.swift | 19 +++++++++ Sources/Reeve/Services/PM2Service.swift | 10 +++++ .../Reeve/Views/EnvironmentSectionView.swift | 15 ++++++- Tests/reeveTests/ReeveTests.swift | 41 +++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/Sources/Reeve/Models/PM2Environment.swift b/Sources/Reeve/Models/PM2Environment.swift index 76cfcf2..60d882f 100644 --- a/Sources/Reeve/Models/PM2Environment.swift +++ b/Sources/Reeve/Models/PM2Environment.swift @@ -43,6 +43,25 @@ 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. + static func socketPathTooLong(forHome home: String) -> Bool { + (home + longestSocketPathComponent).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..cdf4408 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,18 @@ 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, and no + // command should be issued against it. Explain the real cause rather + // than the generic socket error, and point at the durable fix. + 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..8c86d5d 100644 --- a/Tests/reeveTests/ReeveTests.swift +++ b/Tests/reeveTests/ReeveTests.swift @@ -1365,6 +1365,47 @@ 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) + } +} + // MARK: - Address-in-use log detection @Suite("PM2Service.logReportsAddressInUse") From 7f1a1806175db247eaf81bc5d86d9829229230d1 Mon Sep 17 00:00:00 2001 From: Fred Rivett Date: Wed, 1 Jul 2026 15:56:55 +0100 Subject: [PATCH 2/2] Address cubic review on socket path guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Measure the socket path in UTF-8 bytes, not grapheme clusters: sun_path is a fixed 104-byte C buffer, so a multi-byte character in the path would otherwise under-report and slip past the check. - Fix the misleading comment on the path-too-long banner: reeve avoids polling with pm2 (which auto-spawns), but "Kill daemon" is still offered and is safe — it SIGKILLs any zombie directly, no pm2 command. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Reeve/Models/PM2Environment.swift | 6 ++++-- Sources/Reeve/Views/EnvironmentSectionView.swift | 7 ++++--- Tests/reeveTests/ReeveTests.swift | 9 +++++++++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Sources/Reeve/Models/PM2Environment.swift b/Sources/Reeve/Models/PM2Environment.swift index 60d882f..caee740 100644 --- a/Sources/Reeve/Models/PM2Environment.swift +++ b/Sources/Reeve/Models/PM2Environment.swift @@ -57,9 +57,11 @@ public struct PM2Environment: Identifiable, Hashable, Sendable { PM2Environment.socketPathTooLong(forHome: path) } - /// Pure length check, extracted for testing. + /// 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).count > maxSocketPathLength + (home + longestSocketPathComponent).utf8.count > maxSocketPathLength } public init(path: String, isActive: Bool = false) { diff --git a/Sources/Reeve/Views/EnvironmentSectionView.swift b/Sources/Reeve/Views/EnvironmentSectionView.swift index cdf4408..8974e68 100644 --- a/Sources/Reeve/Views/EnvironmentSectionView.swift +++ b/Sources/Reeve/Views/EnvironmentSectionView.swift @@ -319,9 +319,10 @@ struct EnvironmentSectionView: View { } private static func parseErrorMessage(_ message: String, pathTooLong: Bool = false) -> ParsedError { - // Path over the macOS socket limit: the daemon can never run, and no - // command should be issued against it. Explain the real cause rather - // than the generic socket error, and point at the durable fix. + // 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", diff --git a/Tests/reeveTests/ReeveTests.swift b/Tests/reeveTests/ReeveTests.swift index 8c86d5d..17ed240 100644 --- a/Tests/reeveTests/ReeveTests.swift +++ b/Tests/reeveTests/ReeveTests.swift @@ -1404,6 +1404,15 @@ struct SocketPathTooLongTests { 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