Skip to content
Closed
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
78 changes: 78 additions & 0 deletions Sources/PreviewsCLI/DaemonLifecycle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Foundation

/// Writes the daemon PID on startup and removes it on graceful shutdown.
/// Installs SIGTERM/SIGINT handlers that trigger cleanup before exiting.
enum DaemonLifecycle {

/// Register this process as the running daemon. Writes `serve.pid` and
/// installs signal handlers. Call once during daemon startup, after the
/// socket is listening.
static func register() throws {
try DaemonPaths.ensureDirectory()

let pid = ProcessInfo.processInfo.processIdentifier
try "\(pid)\n".write(to: DaemonPaths.pidFile, atomically: true, encoding: .utf8)

installSignalHandlers()
}

/// Remove PID file and socket. Safe to call multiple times.
static func unregister() {
try? FileManager.default.removeItem(at: DaemonPaths.pidFile)
try? FileManager.default.removeItem(at: DaemonPaths.socket)
}

/// Read the PID from the PID file, if present. Returns nil if missing,
/// unreadable, or unparseable.
static func readPID() -> Int32? {
guard
let contents = try? String(contentsOf: DaemonPaths.pidFile, encoding: .utf8),
let pid = Int32(contents.trimmingCharacters(in: .whitespacesAndNewlines))
else { return nil }
return pid
}

/// Check whether a process with the given PID is alive.
/// Uses `kill(pid, 0)` which returns success if the process exists and we
/// have permission to signal it.
static func isProcessAlive(_ pid: Int32) -> Bool {
kill(pid, 0) == 0
}

/// Returns the PID of the running daemon, or nil if no daemon is running
/// *according to the PID file*. Note: the PID file is a management hint,
/// not a liveness check — a live daemon with a deleted PID file looks
/// "not running" to this function. Use `DaemonProbe.canConnect()` for
/// authoritative liveness.
static func daemonRunningPID() -> Int32? {
guard let pid = readPID(), isProcessAlive(pid) else { return nil }
return pid
}

private static func installSignalHandlers() {
// Ignore default Swift signal behavior; handle explicitly.
signal(SIGTERM, SIG_IGN)
signal(SIGINT, SIG_IGN)

let termSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
termSource.setEventHandler {
fputs("daemon: received SIGTERM, shutting down\n", stderr)
unregister()
Darwin.exit(0)
}
termSource.resume()

let intSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
intSource.setEventHandler {
fputs("daemon: received SIGINT, shutting down\n", stderr)
unregister()
Darwin.exit(0)
}
intSource.resume()

// Hold strong refs so the sources aren't deallocated.
retainedSources = [termSource, intSource]
}

private nonisolated(unsafe) static var retainedSources: [DispatchSourceSignal] = []
}
84 changes: 84 additions & 0 deletions Sources/PreviewsCLI/DaemonListener.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Foundation
import MCP
import Network
import PreviewsCore

/// Runs the MCP server daemon on a Unix domain socket.
///
/// Accepts multiple concurrent client connections. Each connection gets its own
/// `MCP.Server` instance, but all connections share the module-level actors in
/// `MCPServer.swift` (`IOSState`, `ConfigCache`) and a single `Compiler` built
/// at daemon startup — so preview sessions persist across CLI invocations and
/// simultaneous clients see consistent state.
enum DaemonListener {

/// Start the daemon listener. Returns once the listener is ready to accept
/// connections. Callers hold the process alive via the existing
/// `NSApplication` run loop (see `PreviewsMCPApp.main`).
static func start() async throws -> NWListener {
try DaemonPaths.ensureDirectory()

// Clean up any stale socket file from a previous crashed daemon.
// bind() would fail with EADDRINUSE otherwise. Callers must have
// already verified via DaemonProbe that no live daemon is listening.
try? FileManager.default.removeItem(at: DaemonPaths.socket)

// Build the shared compiler once. Each accepted connection creates its
// own MCP.Server but reuses this compiler (and the module-level
// IOSState / ConfigCache), avoiding the ~seconds of per-connection
// xcrun / SDK resolution cost.
let sharedCompiler = try await Compiler()

let params = NWParameters.tcp
params.requiredLocalEndpoint = NWEndpoint.unix(path: DaemonPaths.socket.path)
params.allowLocalEndpointReuse = true

let listener = try NWListener(using: params)

listener.newConnectionHandler = { connection in
Task {
await handleConnection(connection, compiler: sharedCompiler)
}
}

// Block until the listener reports ready (or fails).
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
listener.stateUpdateHandler = { state in
switch state {
case .ready:
fputs(
"previewsmcp daemon listening on \(DaemonPaths.socket.path)\n",
stderr
)
cont.resume()
// Clear after resuming so the closure isn't retained.
listener.stateUpdateHandler = nil
case .failed(let error):
cont.resume(throwing: error)
listener.stateUpdateHandler = nil
default:
break
}
}
listener.start(queue: .global(qos: .userInitiated))
}

return listener
}

/// Handle one client connection. Creates a per-connection MCP Server
/// sharing the given compiler and module-level state with other connections.
private static func handleConnection(
_ connection: NWConnection, compiler: Compiler
) async {
do {
let transport = NetworkTransport(connection: connection)
let (server, _) = try await configureMCPServer(sharedCompiler: compiler)
try await server.start(transport: transport)
// `start` returns when the transport closes (client disconnected).
} catch {
fputs("daemon connection error: \(error)\n", stderr)
connection.cancel()
}
}
}
41 changes: 41 additions & 0 deletions Sources/PreviewsCLI/DaemonPaths.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

/// Filesystem paths for the daemon.
///
/// All daemon state lives under `~/.previewsmcp/`. Sessions themselves are held
/// in the daemon's memory (not on disk) — this directory only holds IPC
/// primitives and lifecycle metadata.
enum DaemonPaths {

/// The `~/.previewsmcp/` directory. Created on first use.
static var directory: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".previewsmcp", isDirectory: true)
}

/// Unix domain socket the daemon listens on.
/// Clients connect here to issue MCP JSON-RPC calls.
static var socket: URL {
directory.appendingPathComponent("serve.sock")
}

/// PID file for the running daemon.
/// Written on startup, removed on graceful shutdown.
/// Used by `kill-daemon` and `status` for process targeting and display.
/// Not used for liveness — that's determined by trying to connect the socket.
static var pidFile: URL {
directory.appendingPathComponent("serve.pid")
}

/// Log file for daemon stdout/stderr when running detached.
static var logFile: URL {
directory.appendingPathComponent("serve.log")
}

/// Ensure the directory exists. Call before reading or writing any daemon file.
static func ensureDirectory() throws {
try FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: true
)
}
}
63 changes: 63 additions & 0 deletions Sources/PreviewsCLI/DaemonProbe.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation
import Network

/// Liveness check for the daemon: can we connect to its socket?
///
/// This is the canonical "is the daemon running?" test. The kernel atomically
/// tracks socket-to-fd associations, so if `connect()` succeeds, something is
/// listening. A lingering `serve.sock` file from a crashed daemon returns
/// ECONNREFUSED, so socket file presence alone is not enough.
///
/// Used by:
/// - `ServeCommand --daemon` before unlinking a stale socket, to avoid
/// clobbering a running daemon whose PID file was deleted.
/// - `StatusCommand` for its liveness report.
/// - `DaemonClient` (PR 2) as the auto-start trigger.
enum DaemonProbe {

/// Try to connect to the daemon socket with a short timeout.
/// Returns true on success, false on ENOENT / ECONNREFUSED / timeout.
static func canConnect(timeout: TimeInterval = 1.0) -> Bool {
// Fast path: if the socket file doesn't exist, no daemon is listening.
guard FileManager.default.fileExists(atPath: DaemonPaths.socket.path) else {
return false
}

let connection = NWConnection(
to: NWEndpoint.unix(path: DaemonPaths.socket.path),
using: .tcp
)
let semaphore = DispatchSemaphore(value: 0)
let result = ConnectResult()

connection.stateUpdateHandler = { state in
switch state {
case .ready:
result.set(true)
semaphore.signal()
case .failed, .cancelled:
semaphore.signal()
default:
break
}
}
connection.start(queue: .global())
_ = semaphore.wait(timeout: .now() + timeout)
connection.cancel()
return result.value
}
}

/// Thread-safe boolean set from the NWConnection state handler (runs on a
/// background queue) and read from the main thread.
private final class ConnectResult: @unchecked Sendable {
private let lock = NSLock()
private var _value = false
var value: Bool {
lock.lock(); defer { lock.unlock() }
return _value
}
func set(_ v: Bool) {
lock.lock(); _value = v; lock.unlock()
}
}
45 changes: 45 additions & 0 deletions Sources/PreviewsCLI/KillDaemonCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import ArgumentParser
import Foundation

struct KillDaemonCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "kill-daemon",
abstract: "Stop the running previewsmcp daemon"
)

@Option(name: .long, help: "Seconds to wait for graceful shutdown before giving up")
var timeout: Double = 5.0

func run() throws {
guard let pid = DaemonLifecycle.readPID() else {
print("daemon not running (no PID file)")
return
}

guard DaemonLifecycle.isProcessAlive(pid) else {
print("daemon not running (stale PID \(pid))")
DaemonLifecycle.unregister()
return
}

// Send SIGTERM for graceful shutdown.
guard kill(pid, SIGTERM) == 0 else {
let reason = String(cString: strerror(errno))
print("failed to signal daemon (pid \(pid)): \(reason)")
throw ExitCode(1)
}

// Poll until the process is gone or timeout elapses.
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if !DaemonLifecycle.isProcessAlive(pid) {
print("daemon stopped (pid \(pid))")
return
}
Thread.sleep(forTimeInterval: 0.1)
}

print("daemon did not exit within \(timeout)s; leaving it running")
throw ExitCode(1)
}
}
15 changes: 13 additions & 2 deletions Sources/PreviewsCLI/MCPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,22 @@ private func mcpReporter(
}

/// Configures and returns an MCP server with preview tools.
func configureMCPServer() async throws -> (Server, Compiler) {
///
/// - Parameter sharedCompiler: Pass a pre-built `Compiler` to reuse across
/// multiple server instances (e.g., daemon mode, where each accepted client
/// connection gets its own `Server` but they all share one compiler). When
/// nil, a fresh compiler is built — appropriate for single-connection modes
/// like stdio.
func configureMCPServer(sharedCompiler: Compiler? = nil) async throws -> (Server, Compiler) {
// Clean up stale temp directories from previous sessions (older than 24 hours)
cleanupStaleTempDirs()

let compiler = try await Compiler()
let compiler: Compiler
if let sharedCompiler {
compiler = sharedCompiler
} else {
compiler = try await Compiler()
}

let server = Server(
name: "previewsmcp",
Expand Down
4 changes: 2 additions & 2 deletions Sources/PreviewsCLI/PreviewsMCPApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct PreviewsMCPApp {
PreviewsMCPCommand.exit(withError: error)
}

// Commands that don't need NSApplication (list, help, etc.)
// Commands that don't need NSApplication (list, help, status, kill-daemon)
if command is ListCommand
|| !(command is RunCommand || command is ServeCommand || command is SnapshotCommand
|| command is VariantsCommand)
Expand Down Expand Up @@ -98,7 +98,7 @@ struct PreviewsMCPCommand: ParsableCommand {
version: version,
subcommands: [
RunCommand.self, ListCommand.self, SnapshotCommand.self, VariantsCommand.self,
ServeCommand.self,
ServeCommand.self, StatusCommand.self, KillDaemonCommand.self,
],
defaultSubcommand: RunCommand.self
)
Expand Down
Loading
Loading