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
26 changes: 20 additions & 6 deletions native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,19 @@ actor CoreProcessManager {
// else owns this" rather than as an invitation to spawn.
return
case .unusable(let reason):
// A socket that has refused every probe beside a database nobody
// holds is a dead core's leftovers, and the deadline below would
// reach exactly that verdict, only 60 seconds later. bbolt takes
// the lock before a core creates its listener, so a live core, even
// a saturated one (GH #933), always holds it. `.unknown` proves
// nothing and keeps the wait.
if maySpawn, socketEvidence.onlyEverRefused,
DataDirectoryLock.probe(path: dataDirectoryLockPath) == .free {
NSLog("[MCPProxy] %@ refuses every probe and nobody holds %@ — "
+ "a dead core's socket file; launching without waiting (%@)",
socketPath, dataDirectoryLockPath, reason)
break
}
// Something is, or may be, on the other end of that socket. Do NOT
// unlink it and do NOT start a competing core against the same data
// directory. Wait instead — the attach watch re-probes and attaches
Expand Down Expand Up @@ -326,11 +339,10 @@ actor CoreProcessManager {
guard !shutdownRequested else { return }

// Confirmed empty — and the tray does NOT unlink the socket file here.
// A socket file left over from a dead core never reaches this line: it
// probes `.refused`, which `attachIfCoreIsRunningLocked()` reports as
// `.unusable` above, sending us down the wait path. So the only file
// that could be here is one that appeared AFTER the probes — i.e. one
// something just bound — and unlinking that strands a live core.
// A socket file left over from a dead core reaches this line only when
// nobody holds its data directory (the `.unusable` case above); any
// other file could be one that something bound AFTER the probes, and
// unlinking that strands a live core.
//
// Cleaning up a genuinely stale file is the core's job anyway: it
// removes one it can prove nobody is listening on (see
Expand Down Expand Up @@ -889,7 +901,9 @@ actor CoreProcessManager {
/// is gone. Launch a core rather than telling the user to quit a process that
/// does not exist — the error we would otherwise show is unactionable, and
/// Retry would repeat this same wait forever, because nothing else in the
/// system removes that file.
/// system removes that file. A refused socket beside a free data-directory
/// lock never gets here: `start()` launches at once. This covers the rest —
/// a lock held and then freed, or one that could not be read.
///
/// Why this cannot resurrect the double-spawn hazard the tray-side unlink
/// had. It never unlinks anything: the dangerous step is done by the core we
Expand Down
21 changes: 19 additions & 2 deletions native/macos/MCPProxy/MCPProxyTests/CoreLivenessTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ final class CoreLivenessTests: XCTestCase {

private var stub: UnixSocketHTTPStub?
private var manager: CoreProcessManager?
private var privateDirectories: [URL] = []

/// A scratch directory of its own for a socket that is left REFUSING at
/// `start()`. The data-directory lock is derived from the socket's
/// directory (`DataDirectoryLock.path(forSocket:)`), so a bare `/tmp` socket
/// would consult a stray `/tmp/config.db`, and a free one would make the
/// startup shortcut launch. /tmp, not NSTemporaryDirectory(): sun_path is
/// 104 bytes.
private func refusingSocketPath() throws -> String {
let directory = URL(fileURLWithPath: "/tmp/mcpproxy-live-\(UUID().uuidString.prefix(8))",
isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
privateDirectories.append(directory)
return directory.appendingPathComponent("mcpproxy.sock").path
}

/// Async teardown so shutdown actually completes before the next test runs:
/// a fire-and-forget `Task { await shutdown() }` would race the next test's
Expand All @@ -32,6 +47,8 @@ final class CoreLivenessTests: XCTestCase {
manager = nil
stub?.stop()
stub = nil
for directory in privateDirectories { try? FileManager.default.removeItem(at: directory) }
privateDirectories = []
try await super.tearDown()
}

Expand Down Expand Up @@ -545,7 +562,7 @@ final class CoreLivenessTests: XCTestCase {

// A socket file that refuses connections. From out here this is
// indistinguishable from a live core with a full backlog.
let stub = UnixSocketHTTPStub.healthyCore()
let stub = UnixSocketHTTPStub.healthyCore(at: try refusingSocketPath())
try stub.start()
stub.stop(unlinkSocket: false)
defer { unlink(stub.path) }
Expand Down Expand Up @@ -755,7 +772,7 @@ final class CoreLivenessTests: XCTestCase {
defer { restoreEnv() }

// Exactly what a killed core leaves behind: the file, and nothing on it.
let stub = UnixSocketHTTPStub.healthyCore()
let stub = UnixSocketHTTPStub.healthyCore(at: try refusingSocketPath())
try stub.start()
stub.stop(unlinkSocket: false)
defer { unlink(stub.path) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ final class SaturatedCoreEscalationTests: XCTestCase {
}

/// The other half of the same rule: a socket file with NO live holder is
/// still escalated over, exactly as before. Without this the fix would have
/// traded a rare bad launch for a permanent deadlock on every stale socket —
/// nothing else in the system removes that file.
/// still launched over (via the up-front lock check in `start()`, no longer
/// the deadline). Without this the fix would have traded a rare bad launch
/// for a permanent deadlock on every stale socket — nothing else in the
/// system removes that file.
func testAGenuinelyStaleSocketIsStillLaunchedOver() async throws {
let fakeCore = try FakeCoreBinary(behaviour: "sleep 30")
let restoreEnv = fakeCore.install()
Expand Down Expand Up @@ -151,9 +152,61 @@ final class SaturatedCoreEscalationTests: XCTestCase {
+ "dead core's leftovers and the tray has to get past it on its own")
}

/// A dead core's leftovers are recognisable at startup: the socket refuses
/// every probe and nobody holds the database beside it. The tray must
/// launch straight away instead of sitting out the whole unresponsive-core
/// deadline (60 s in production) before reaching the same verdict.
func testAStaleSocketBesideAFreeDataDirectoryIsLaunchedOverWithoutWaitingForTheDeadline() async throws {
let fakeCore = try FakeCoreBinary(behaviour: "sleep 30")
let restoreEnv = fakeCore.install()
defer { restoreEnv() }

let socketPath = try refusingSocket()
FileManager.default.createFile(
atPath: directory.appendingPathComponent("config.db").path, contents: Data()
)

let appState = await MainActor.run { AppState() }
let manager = makeManager(appState: appState, socketPath: socketPath, unresponsiveCoreTimeout: 30)
self.manager = manager

await manager.start(maySpawn: true)
try await Task.sleep(nanoseconds: 2_000_000_000)

let launched = await manager.coresLaunched
XCTAssertGreaterThan(launched, 0,
"a refusing socket beside an unheld database is a dead core's "
+ "leftovers; waiting out the deadline only delays the launch")
}

/// The shortcut needs positive proof that nobody owns the data directory.
/// With no database beside the socket the lock says nothing either way, so
/// the tray keeps the old behaviour and waits for the deadline.
func testAStaleSocketWithNoDatabaseBesideItStillWaitsForTheDeadline() async throws {
let fakeCore = try FakeCoreBinary(behaviour: "sleep 30")
let restoreEnv = fakeCore.install()
defer { restoreEnv() }

let socketPath = try refusingSocket()

let appState = await MainActor.run { AppState() }
let manager = makeManager(appState: appState, socketPath: socketPath, unresponsiveCoreTimeout: 30)
self.manager = manager

await manager.start(maySpawn: true)
try await Task.sleep(nanoseconds: 2_000_000_000)

let launched = await manager.coresLaunched
XCTAssertEqual(launched, 0, "an unknown lock state is not proof the socket is stale")
let state = await MainActor.run { appState.coreState }
XCTAssertEqual(state, .waitingForCore)
}

// MARK: - Fixtures

private func makeManager(appState: AppState, socketPath: String) -> CoreProcessManager {
private func makeManager(
appState: AppState, socketPath: String, unresponsiveCoreTimeout: TimeInterval = 0.5
) -> CoreProcessManager {
CoreProcessManager(
appState: appState,
notificationService: NotificationService(deliveryEnabled: false),
Expand All @@ -163,11 +216,39 @@ final class SaturatedCoreEscalationTests: XCTestCase {
socketPath: socketPath,
refreshInterval: 60,
probeTimeout: 0.3,
unresponsiveCoreTimeout: 0.5,
unresponsiveCoreTimeout: unresponsiveCoreTimeout,
socketWaitTimeout: 1.0
)
}

/// The shortcut is a start-time decision about a socket we may not launch
/// over. With spawning disallowed (a tray that only attaches) an unheld
/// database must not launch anything: the tray waits for a core to appear.
func testAStaleSocketBesideAFreeDataDirectoryStillWaitsWhenSpawningIsNotAllowed() async throws {
let fakeCore = try FakeCoreBinary(behaviour: "sleep 30")
let restoreEnv = fakeCore.install()
defer { restoreEnv() }

let socketPath = try refusingSocket()
FileManager.default.createFile(
atPath: directory.appendingPathComponent("config.db").path, contents: Data()
)

let appState = await MainActor.run { AppState() }
let manager = makeManager(appState: appState, socketPath: socketPath, unresponsiveCoreTimeout: 30)
self.manager = manager

await manager.start(maySpawn: false)
try await Task.sleep(nanoseconds: 2_000_000_000)

let launched = await manager.coresLaunched
XCTAssertEqual(launched, 0, "maySpawn is false: nothing may be launched, free lock or not")
XCTAssertEqual(fakeCore.launchCount(), 0)
XCTAssertNil(manager.managedProcess)
let state = await MainActor.run { appState.coreState }
XCTAssertEqual(state, .waitingForCore)
}

/// A socket file that refuses every connection — what a saturated backlog
/// and a dead core's leftovers look like from the outside, identically.
private func refusingSocket() throws -> String {
Expand Down
Loading