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
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
public import Foundation

/// One subprocess invocation the coordinator hands to its process runner:
/// the executable, argv, optional environment/working directory/stdin, and
/// the timeout after which the process is terminated.
/// the executable, argv, optional environment/working directory/standard
/// input, and the timeout after which the process is terminated.
public struct RemoteProcessRequest: Sendable {
/// Absolute path of the executable to launch.
public let executable: String
Expand All @@ -15,6 +15,8 @@ public struct RemoteProcessRequest: Sendable {
/// Data written to stdin (the write end is closed afterwards), or `nil`
/// to attach the null device.
public let stdin: Data?
/// Local file streamed to stdin, or `nil` for data/null-device input.
public let stdinFile: URL?
/// Seconds after which a still-running process is terminated and the run
/// fails with the legacy timeout error.
public let timeout: TimeInterval
Expand All @@ -33,6 +35,33 @@ public struct RemoteProcessRequest: Sendable {
self.environment = environment
self.currentDirectory = currentDirectory
self.stdin = stdin
self.stdinFile = nil
self.timeout = timeout
}

/// Creates a request whose standard input is streamed from a local file.
///
/// - Parameters:
/// - executable: Absolute path of the executable to launch.
/// - arguments: Argument vector excluding the executable.
/// - environment: Process environment, or `nil` to inherit.
/// - currentDirectory: Working directory, or `nil` to inherit.
/// - stdinFile: Local file whose bytes become process standard input.
/// - timeout: Seconds after which a still-running process is terminated.
public init(
executable: String,
arguments: [String],
environment: [String: String]? = nil,
currentDirectory: URL? = nil,
stdinFile: URL,
timeout: TimeInterval
) {
self.executable = executable
self.arguments = arguments
self.environment = environment
self.currentDirectory = currentDirectory
self.stdin = nil
self.stdinFile = stdinFile
self.timeout = timeout
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ public struct RemoteSessionProcessRunner: RemoteSessionProcessRunning {
let arguments = request.arguments
let timeout = request.timeout
let stdin = request.stdin
let stdinFileHandle: FileHandle?
if let stdinFile = request.stdinFile {
stdinFileHandle = try FileHandle(forReadingFrom: stdinFile)
} else {
stdinFileHandle = nil
}
defer { try? stdinFileHandle?.close() }

debugLog(
"remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
Expand All @@ -80,7 +87,9 @@ public struct RemoteSessionProcessRunner: RemoteSessionProcessRunning {
process.standardOutput = stdoutPipe
process.standardError = stderrPipe

if stdin != nil {
if let stdinFileHandle {
process.standardInput = stdinFileHandle
} else if stdin != nil {
process.standardInput = Pipe()
} else {
process.standardInput = FileHandle.nullDevice
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,63 +302,6 @@ extension RemoteSessionCoordinator {
return output
}

func uploadRemoteDaemonBinaryLocked(localBinary: URL, location: RemoteDaemonInstallLocation) throws {
let remotePath = location.absolutePath
let remoteDirectory = location.directory
let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))"
debugLog(
"remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)"
)

let mkdirScript = "mkdir -p \(remoteDirectory.shellSingleQuoted)"
let mkdirCommand = "sh -c \(mkdirScript.shellSingleQuoted)"
let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12)
guard mkdirResult.status == 0 else {
let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)"
throw NSError(domain: "cmux.remote.daemon", code: 30, userInfo: [
NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)",
])
}

let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions)
var scpArgs: [String] = ["-q"]
if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") {
scpArgs += ["-o", "StrictHostKeyChecking=accept-new"]
}
scpArgs += ["-o", "ControlMaster=no"]
if let port = configuration.port {
scpArgs += ["-P", String(port)]
}
if let identityFile = configuration.identityFile,
!identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
scpArgs += ["-i", identityFile]
}
for option in scpSSHOptions {
scpArgs += ["-o", option]
}
scpArgs += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"]
let scpResult = try scpExec(arguments: scpArgs, timeout: 45)
guard scpResult.status == 0 else {
let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)"
throw NSError(domain: "cmux.remote.daemon", code: 31, userInfo: [
NSLocalizedDescriptionKey: "failed to upload cmuxd-remote: \(detail)",
])
}

let finalizeScript = """
chmod 755 \(remoteTempPath.shellSingleQuoted) && \
mv \(remoteTempPath.shellSingleQuoted) \(remotePath.shellSingleQuoted)
"""
let finalizeCommand = "sh -c \(finalizeScript.shellSingleQuoted)"
let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12)
guard finalizeResult.status == 0 else {
let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)"
throw NSError(domain: "cmux.remote.daemon", code: 32, userInfo: [
NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)",
])
}
}

func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello {
let request = #"{"id":1,"method":"hello","params":{}}"#
let script = "printf '%s\\n' \(request.shellSingleQuoted) | \(remotePath.shellSingleQuoted) serve --stdio"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
internal import CmuxFoundation
internal import Foundation

// Installs cmuxd-remote through the same SSH exec channel used by bootstrap.
// No SFTP subsystem or remote scp executable is required: the local binary is
// streamed to `cat`, then the existing chmod-and-rename step publishes it
// atomically at the versioned destination.
extension RemoteSessionCoordinator {
func uploadRemoteDaemonBinaryLocked(localBinary: URL, location: RemoteDaemonInstallLocation) throws {
let remotePath = location.absolutePath
let remoteDirectory = location.directory
let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))"
debugLog(
"remote.upload.begin transport=ssh-stdin local=\(localBinary.path) " +
"remoteTemp=\(remoteTempPath) remote=\(remotePath)"
)

let mkdirScript = "mkdir -p \(remoteDirectory.shellSingleQuoted)"
let mkdirCommand = "sh -c \(mkdirScript.shellSingleQuoted)"
let mkdirResult: RemoteCommandResult
do {
mkdirResult = try sshExec(
arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand],
timeout: 12
)
} catch {
throw NSError(domain: "cmux.remote.daemon", code: 30, userInfo: [
NSLocalizedDescriptionKey: String(
localized: "remoteDaemon.upload.createDirectoryFailed",
defaultValue: "failed to create remote daemon directory"
),
])
}
guard mkdirResult.status == 0 else {
let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ??
"ssh exited \(mkdirResult.status)"
throw NSError(domain: "cmux.remote.daemon", code: 30, userInfo: [
NSLocalizedDescriptionKey: String(
localized: "remoteDaemon.upload.createDirectoryFailedWithDetail",
defaultValue: "failed to create remote daemon directory: \(detail)"
),
])
}

let uploadScript = "cat > \(remoteTempPath.shellSingleQuoted)"
let uploadCommand = "sh -c \(uploadScript.shellSingleQuoted)"
let uploadResult: RemoteCommandResult
do {
uploadResult = try sshExec(
arguments: sshCommonArguments(batchMode: true) + [configuration.destination, uploadCommand],
stdinFile: localBinary,
timeout: 45
)
} catch {
cleanupUploadedRemotePaths([remoteTempPath])
throw NSError(domain: "cmux.remote.daemon", code: 31, userInfo: [
NSLocalizedDescriptionKey: String(
localized: "remoteDaemon.upload.transferFailed",
defaultValue: "failed to upload cmuxd-remote"
),
])
}
guard uploadResult.status == 0 else {
cleanupUploadedRemotePaths([remoteTempPath])
let detail = Self.bestErrorLine(stderr: uploadResult.stderr, stdout: uploadResult.stdout) ??
"ssh exited \(uploadResult.status)"
throw NSError(domain: "cmux.remote.daemon", code: 31, userInfo: [
NSLocalizedDescriptionKey: String(
localized: "remoteDaemon.upload.transferFailedWithDetail",
defaultValue: "failed to upload cmuxd-remote: \(detail)"
),
])
}

let finalizeScript = """
chmod 755 \(remoteTempPath.shellSingleQuoted) && \
mv \(remoteTempPath.shellSingleQuoted) \(remotePath.shellSingleQuoted)
"""
let finalizeCommand = "sh -c \(finalizeScript.shellSingleQuoted)"
let finalizeResult: RemoteCommandResult
do {
finalizeResult = try sshExec(
arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand],
timeout: 12
)
} catch {
cleanupUploadedRemotePaths([remoteTempPath])
throw NSError(domain: "cmux.remote.daemon", code: 32, userInfo: [
NSLocalizedDescriptionKey: String(
localized: "remoteDaemon.upload.installFailed",
defaultValue: "failed to install remote daemon binary"
),
])
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
guard finalizeResult.status == 0 else {
cleanupUploadedRemotePaths([remoteTempPath])
let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ??
"ssh exited \(finalizeResult.status)"
throw NSError(domain: "cmux.remote.daemon", code: 32, userInfo: [
NSLocalizedDescriptionKey: String(
localized: "remoteDaemon.upload.installFailedWithDetail",
defaultValue: "failed to install remote daemon binary: \(detail)"
),
])
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
internal import Foundation

// Blocking SSH/SCP/dev-build execution through the injected process runner.
// Calls stay on the coordinator's serial utility queue; file-backed stdin lets
// large helper binaries flow through SSH without buffering them in memory.
extension RemoteSessionCoordinator {
func sshExec(
arguments: [String],
stdin: Data? = nil,
timeout: TimeInterval = 15
) throws -> RemoteCommandResult {
try runProcess(
executable: "/usr/bin/ssh",
arguments: arguments,
environment: configuration.sshProcessEnvironment,
stdin: stdin,
timeout: timeout
)
}

func sshExec(
arguments: [String],
stdinFile: URL,
timeout: TimeInterval = 15
) throws -> RemoteCommandResult {
// A host or caller can configure StdinNull=yes; OpenSSH would then
// discard this file while `cat` still exits successfully. Its first
// option value wins, so pin file-backed execs before caller options.
let fileInputArguments = ["-o", "StdinNull=no"] + arguments
return try processRunner.run(
RemoteProcessRequest(
executable: "/usr/bin/ssh",
arguments: fileInputArguments,
environment: configuration.sshProcessEnvironment,
stdinFile: stdinFile,
timeout: timeout
),
operation: nil
)
}

func scpExec(
arguments: [String],
timeout: TimeInterval = 30,
operation: (any RemoteTransferCancelling)? = nil
) throws -> RemoteCommandResult {
try runProcess(
executable: "/usr/bin/scp",
arguments: arguments,
environment: configuration.sshProcessEnvironment,
stdin: nil,
timeout: timeout,
operation: operation
)
}

func runProcess(
executable: String,
arguments: [String],
environment: [String: String]? = nil,
currentDirectory: URL? = nil,
stdin: Data?,
timeout: TimeInterval,
operation: (any RemoteTransferCancelling)? = nil
) throws -> RemoteCommandResult {
try processRunner.run(
RemoteProcessRequest(
executable: executable,
arguments: arguments,
environment: environment,
currentDirectory: currentDirectory,
stdin: stdin,
timeout: timeout
),
operation: operation
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -542,55 +542,6 @@ public final class RemoteSessionCoordinator: @unchecked Sendable {
return message.isEmpty ? "remote daemon bootstrap failed" : message
}

// MARK: - Subprocess execution (through the runner seam)

func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> RemoteCommandResult {
try runProcess(
executable: "/usr/bin/ssh",
arguments: arguments,
environment: configuration.sshProcessEnvironment,
stdin: stdin,
timeout: timeout
)
}

func scpExec(
arguments: [String],
timeout: TimeInterval = 30,
operation: (any RemoteTransferCancelling)? = nil
) throws -> RemoteCommandResult {
try runProcess(
executable: "/usr/bin/scp",
arguments: arguments,
environment: configuration.sshProcessEnvironment,
stdin: nil,
timeout: timeout,
operation: operation
)
}

func runProcess(
executable: String,
arguments: [String],
environment: [String: String]? = nil,
currentDirectory: URL? = nil,
stdin: Data?,
timeout: TimeInterval,
operation: (any RemoteTransferCancelling)? = nil
) throws -> RemoteCommandResult {
try processRunner.run(
RemoteProcessRequest(
executable: executable,
arguments: arguments,
environment: environment,
currentDirectory: currentDirectory,
stdin: stdin,
timeout: timeout
),
operation: operation
)
}

// MARK: - Debug logging

func debugLog(_ message: @autoclosure () -> String) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
enum RemoteDaemonUploadStep: Equatable {
case createDirectory
case upload
case finalize
case cleanup
case unknown
}
Loading
Loading