Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Sources/Reeve/Models/PM2Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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
Expand Down
10 changes: 10 additions & 0 deletions Sources/Reeve/Services/PM2Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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([])
Expand Down
16 changes: 14 additions & 2 deletions Sources/Reeve/Views/EnvironmentSectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
)
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

let lower = message.lowercased()

// Socket connection errors — daemon is broken/zombie
Expand Down
50 changes: 50 additions & 0 deletions Tests/reeveTests/ReeveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading