Skip to content

Commit e8f03ab

Browse files
beriberikixclaude
andcommitted
Let bind ask the daemon instead of leaving it a note
`bind` wrote a file and hoped. The daemon noticed, since a previous change made it re-read the file when it changed, but the command could still only report that it had written something — not whether the daemon had accepted it, or whether a daemon was even running. It now asks. A Unix socket at ~/.usbipd/control.sock carries bind and unbind requests to the running daemon, which applies them and answers. When nothing is listening the command writes the state itself and says so, because binding a device before the daemon has ever started is ordinary and refusing it would be worse than doing it quietly. The channel is a Unix socket rather than a command on the USB/IP port, and that is the load-bearing decision. Port 3240 is exposed to the network by design; a bind command reachable there would let any host able to open a TCP connection to this machine share any USB device attached to it. A socket under the user's own directory, mode 0600, is reachable only by processes here, with the filesystem deciding who may open it. A daemon killed without a chance to tidy up leaves the socket file behind. That degrades correctly rather than breaking: connecting to it fails, the CLI treats that as "no daemon" and falls back, and a new daemon reclaims the name — a live daemon holds the descriptor, not the file. The timestamp check on the state file stays. It is no longer polling for something a running daemon does not know about; it is how a change made over the socket, or by a CLI that ran while no daemon was up, reaches the request path. Keeping the file as the single source of truth is what makes both routes agree. Verified against a CP2102N behind a hub: bound with no daemon and reported as deferred; bound through a running daemon and attachable immediately with no restart and no note; unbound through the daemon and refused immediately; and bound again with a stale socket left by a killed daemon, falling back correctly and then going through the socket once a new daemon took the name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rmyikdjWveP99ZUCDLY89
1 parent 1069f3b commit e8f03ab

4 files changed

Lines changed: 520 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,20 @@ exchanges and stopped only at chip detection — the same error it gives with th
2121
plugged straight into the Mac, because no target is wired to its SWD pins. No System
2222
Extension and no entitlement are involved.
2323

24-
`bind` and `unbind` take effect on a running daemon. They are separate processes that
25-
write `~/.usbipd/bound-devices.json`; the daemon notices the file has changed and
26-
re-reads it. It used to read that state only at startup, so a device bound while the
27-
daemon was running stayed unimportable — `usbip attach` answered "Request Failed" — and
28-
nothing said a restart was needed.
24+
`bind` and `unbind` take effect on a running daemon. They ask it directly, over a Unix
25+
socket at `~/.usbipd/control.sock`, so the command reports what the daemon actually did
26+
rather than what it hoped would happen. When no daemon is listening they write
27+
`~/.usbipd/bound-devices.json` themselves and say so; the daemon reads it at startup.
28+
29+
The control channel is deliberately not a command on port 3240. That port is exposed to
30+
the network, so a bind reachable there would let any host that can open a TCP connection
31+
share any USB device on this machine. The socket is mode 0600 in the user's own
32+
directory.
33+
34+
The daemon still checks the file's timestamp when it consults the list. That is not
35+
redundant: it is how a change made over the control socket, or by a CLI that ran while
36+
no daemon was up, reaches the request path — the file stays the single source of
37+
truth.
2938

3039
State and configuration are separate files, and only state is ever written by the tool.
3140
`~/.usbipd/usbipd-config.json` holds the port, log level and tuning; it is read at

Sources/USBIPDCLI/Commands.swift

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,46 @@ public class ListCommand: Command {
158158
}
159159
}
160160

161+
/// How a bind or unbind was carried out.
162+
private enum BindOutcome {
163+
/// A running daemon applied it and confirmed. `changed` is false when the device
164+
/// was already in that state.
165+
case daemon(changed: Bool)
166+
/// No daemon was listening, so the state file was written directly. It will be read
167+
/// when a daemon next starts.
168+
case directly(changed: Bool)
169+
}
170+
171+
/// Apply a change to the bound-device list, preferring a running daemon.
172+
///
173+
/// Asking the daemon is what lets these commands report what actually happened. Writing
174+
/// the file and leaving the daemon to notice meant `bind` could only report that it had
175+
/// written a file — and for a long time it did not even mean that much, because the
176+
/// daemon read the file once at startup and never again.
177+
///
178+
/// Falling back to a direct write keeps the first run working: binding a device before
179+
/// the daemon has ever been started is ordinary, and refusing it would be worse than
180+
/// doing it quietly.
181+
private func applyBinding(_ command: ControlRequest.Command,
182+
busid: String,
183+
store: BoundDeviceStore) throws -> BindOutcome {
184+
if let response = ControlSocketClient.send(ControlRequest(command: command, busid: busid)) {
185+
guard response.ok else {
186+
throw CommandHandlerError.deviceBindingFailed(response.error ?? "the daemon refused the request")
187+
}
188+
return .daemon(changed: response.changed)
189+
}
190+
191+
let changed: Bool
192+
switch command {
193+
case .bind:
194+
changed = try store.bind(busid)
195+
case .unbind:
196+
changed = try store.unbind(busid)
197+
}
198+
return .directly(changed: changed)
199+
}
200+
161201
/// Bind command implementation
162202
public class BindCommand: Command {
163203
public let name = "bind"
@@ -313,7 +353,7 @@ public class BindCommand: Command {
313353
print("Device \(busid) is already claimed by System Extension")
314354

315355
// Record it even if already claimed, so the two agree.
316-
try boundDevices.bind(deviceIdentifier)
356+
_ = try applyBinding(.bind, busid: deviceIdentifier, store: boundDevices)
317357

318358
print("Device \(busid) successfully bound: \(String(format: "%04x", device.vendorID)):\(String(format: "%04x", device.productID)) (\(device.productString ?? "Unknown"))")
319359
return
@@ -362,7 +402,10 @@ public class BindCommand: Command {
362402
// rewrote the port, the log level and every tuning value along with it —
363403
// and reset them outright whenever the configuration had failed to parse.
364404
logger.debug("Recording the device as bound", context: ["deviceIdentifier": deviceIdentifier])
365-
try boundDevices.bind(deviceIdentifier)
405+
let outcome = try applyBinding(.bind, busid: deviceIdentifier, store: boundDevices)
406+
if case .directly = outcome {
407+
print("Note: no daemon is running, so this was recorded for the next start.")
408+
}
366409

367410
logger.info("Successfully bound device", context: ["busid": busid])
368411
print("✓ Device \(busid) added to server configuration")
@@ -557,7 +600,17 @@ public class UnbindCommand: Command {
557600

558601
// Step 3: Forget the device.
559602
logger.debug("Removing the device from the bound list", context: ["busid": busid])
560-
let removed = try boundDevices.unbind(busid)
603+
let unbindOutcome = try applyBinding(.unbind, busid: busid, store: boundDevices)
604+
let removed: Bool
605+
switch unbindOutcome {
606+
case .daemon(let changed):
607+
removed = changed
608+
case .directly(let changed):
609+
removed = changed
610+
if changed {
611+
print("Note: no daemon is running; the change takes effect at the next start.")
612+
}
613+
}
561614

562615
if removed {
563616
logger.info("Successfully unbound device", context: ["busid": busid])
@@ -644,10 +697,47 @@ public class DaemonCommand: Command {
644697

645698
private let server: USBIPServer
646699
private let serverConfig: ServerConfig
647-
648-
public init(server: USBIPServer, serverConfig: ServerConfig) {
700+
private let boundDevices: BoundDeviceStore
701+
702+
/// Held for the daemon's lifetime; dropping it would close the socket.
703+
private var controlSocket: ControlSocketServer?
704+
705+
public init(server: USBIPServer,
706+
serverConfig: ServerConfig,
707+
boundDevices: BoundDeviceStore = BoundDeviceStore()) {
649708
self.server = server
650709
self.serverConfig = serverConfig
710+
self.boundDevices = boundDevices
711+
}
712+
713+
/// Serve bind and unbind requests from the CLI.
714+
///
715+
/// Failing to open the socket is reported and then ignored. The daemon's job is
716+
/// serving USB devices, and it can still do that: `bind` falls back to writing the
717+
/// state file directly, which is how it worked before this existed.
718+
private func startControlSocket() {
719+
let socket = ControlSocketServer { [boundDevices] request in
720+
do {
721+
let changed: Bool
722+
switch request.command {
723+
case .bind:
724+
changed = try boundDevices.bind(request.busid)
725+
case .unbind:
726+
changed = try boundDevices.unbind(request.busid)
727+
}
728+
return ControlResponse(ok: true, changed: changed)
729+
} catch {
730+
return ControlResponse(ok: false, changed: false, error: error.localizedDescription)
731+
}
732+
}
733+
734+
do {
735+
try socket.start()
736+
controlSocket = socket
737+
} catch {
738+
logger.warning("Control socket unavailable; bind will write the state file directly",
739+
context: ["error": error.localizedDescription])
740+
}
651741
}
652742

653743
public func execute(with arguments: [String]) throws {
@@ -716,6 +806,7 @@ public class DaemonCommand: Command {
716806
logger.info("Starting USB/IP server", context: ["port": serverConfig.port])
717807
try server.start()
718808
DaemonRuntime.markServerStarted()
809+
startControlSocket()
719810

720811
logger.info("USB/IP server started successfully", context: ["port": serverConfig.port])
721812
print("USB/IP daemon started on port \(serverConfig.port)")
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
// ControlSocket.swift
2+
// The channel `bind` and `unbind` use to talk to a running daemon.
3+
4+
import Foundation
5+
import Common
6+
7+
/// A request from the CLI to the daemon.
8+
public struct ControlRequest: Codable {
9+
public enum Command: String, Codable {
10+
case bind
11+
case unbind
12+
}
13+
14+
public let command: Command
15+
public let busid: String
16+
17+
public init(command: Command, busid: String) {
18+
self.command = command
19+
self.busid = busid
20+
}
21+
}
22+
23+
/// The daemon's answer.
24+
public struct ControlResponse: Codable {
25+
/// Whether the daemon carried the request out.
26+
public let ok: Bool
27+
28+
/// Whether anything actually changed — false when a device was already bound, or
29+
/// already absent. The distinction belongs to the caller's wording, not to whether
30+
/// the request succeeded.
31+
public let changed: Bool
32+
33+
/// Present when `ok` is false.
34+
public let error: String?
35+
36+
public init(ok: Bool, changed: Bool, error: String? = nil) {
37+
self.ok = ok
38+
self.changed = changed
39+
self.error = error
40+
}
41+
}
42+
43+
/// Accepts control requests from the CLI on a Unix domain socket.
44+
///
45+
/// A Unix socket rather than the USB/IP port, deliberately. Port 3240 is exposed to the
46+
/// network — that is its purpose — and a bind command reachable there would let any host
47+
/// that can open a TCP connection to this machine share any USB device attached to it.
48+
/// A socket in the user's own directory is reachable only by processes on this machine,
49+
/// with the filesystem enforcing who may open it.
50+
///
51+
/// The daemon owning this state is what lets `bind` say what actually happened. Writing
52+
/// the file and hoping the daemon noticed meant the command could only report that it
53+
/// had written a file.
54+
public final class ControlSocketServer {
55+
56+
/// Handles one request and returns what to say back.
57+
public typealias Handler = (ControlRequest) -> ControlResponse
58+
59+
private let path: String
60+
private let handler: Handler
61+
private let logger: Logger
62+
private var listener: FileHandle?
63+
private var descriptor: Int32 = -1
64+
private let queue = DispatchQueue(label: "com.usbipd.control-socket")
65+
private var source: DispatchSourceRead?
66+
67+
public init(path: String = ControlSocketServer.defaultPath(), handler: @escaping Handler) {
68+
self.path = path
69+
self.handler = handler
70+
self.logger = Logger(subsystem: "com.usbipd.core", category: "control-socket")
71+
}
72+
73+
/// `~/.usbipd/control.sock`, beside the state it manipulates.
74+
public static func defaultPath() -> String {
75+
let directory = FileManager.default.homeDirectoryForCurrentUser
76+
.appendingPathComponent(ServerConfig.defaultConfigDirName)
77+
return directory.appendingPathComponent("control.sock").path
78+
}
79+
80+
/// Begin listening. Throws if the socket cannot be created.
81+
public func start() throws {
82+
let directory = URL(fileURLWithPath: path).deletingLastPathComponent()
83+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
84+
85+
// A socket file left by a daemon that did not shut down cleanly would make bind
86+
// fail to connect and, worse, make this one fail to listen. Removing it is safe:
87+
// a live daemon holds the descriptor, not the name.
88+
try? FileManager.default.removeItem(atPath: path)
89+
90+
descriptor = socket(AF_UNIX, SOCK_STREAM, 0)
91+
guard descriptor >= 0 else {
92+
throw ServerError.initializationFailed("control socket: \(String(cString: strerror(errno)))")
93+
}
94+
95+
var address = sockaddr_un()
96+
address.sun_family = sa_family_t(AF_UNIX)
97+
let maxLength = MemoryLayout.size(ofValue: address.sun_path)
98+
guard path.utf8.count < maxLength else {
99+
close(descriptor)
100+
throw ServerError.initializationFailed("control socket path is too long: \(path)")
101+
}
102+
_ = withUnsafeMutablePointer(to: &address.sun_path) { pointer in
103+
path.withCString { source in
104+
strncpy(UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: CChar.self),
105+
source, maxLength - 1)
106+
}
107+
}
108+
109+
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
110+
let bound = withUnsafePointer(to: &address) { pointer in
111+
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(descriptor, $0, size) }
112+
}
113+
guard bound == 0 else {
114+
close(descriptor)
115+
throw ServerError.initializationFailed("control socket bind: \(String(cString: strerror(errno)))")
116+
}
117+
118+
// Only this user may issue commands. The socket lives under the user's home
119+
// directory, but the mode says so explicitly rather than relying on that.
120+
chmod(path, 0o600)
121+
122+
guard listen(descriptor, 8) == 0 else {
123+
close(descriptor)
124+
throw ServerError.initializationFailed("control socket listen: \(String(cString: strerror(errno)))")
125+
}
126+
127+
let source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: queue)
128+
source.setEventHandler { [weak self] in self?.acceptOne() }
129+
source.resume()
130+
self.source = source
131+
132+
logger.info("Control socket listening", context: ["path": path])
133+
}
134+
135+
public func stop() {
136+
source?.cancel()
137+
source = nil
138+
if descriptor >= 0 {
139+
close(descriptor)
140+
descriptor = -1
141+
}
142+
try? FileManager.default.removeItem(atPath: path)
143+
}
144+
145+
deinit {
146+
stop()
147+
}
148+
149+
private func acceptOne() {
150+
let client = accept(descriptor, nil, nil)
151+
guard client >= 0 else { return }
152+
defer { close(client) }
153+
154+
// Requests are one short line each, so a single read is enough. A caller that
155+
// sends nothing simply gets dropped.
156+
var buffer = [UInt8](repeating: 0, count: 4096)
157+
let count = read(client, &buffer, buffer.count)
158+
guard count > 0 else { return }
159+
160+
let data = Data(buffer[0..<count])
161+
let response: ControlResponse
162+
if let request = try? JSONDecoder().decode(ControlRequest.self, from: data) {
163+
response = handler(request)
164+
} else {
165+
response = ControlResponse(ok: false, changed: false, error: "malformed control request")
166+
}
167+
168+
if var encoded = try? JSONEncoder().encode(response) {
169+
encoded.append(0x0A)
170+
encoded.withUnsafeBytes { bytes in
171+
_ = write(client, bytes.baseAddress, bytes.count)
172+
}
173+
}
174+
}
175+
}
176+
177+
/// Sends a control request to a running daemon.
178+
public enum ControlSocketClient {
179+
180+
/// Ask the daemon to carry out a command.
181+
///
182+
/// Returns nil when no daemon is listening, which is not an error: `bind` before the
183+
/// daemon has ever been started is ordinary, and the caller falls back to writing
184+
/// the state itself.
185+
public static func send(_ request: ControlRequest,
186+
to path: String = ControlSocketServer.defaultPath()) -> ControlResponse? {
187+
let descriptor = socket(AF_UNIX, SOCK_STREAM, 0)
188+
guard descriptor >= 0 else { return nil }
189+
defer { close(descriptor) }
190+
191+
var address = sockaddr_un()
192+
address.sun_family = sa_family_t(AF_UNIX)
193+
let maxLength = MemoryLayout.size(ofValue: address.sun_path)
194+
guard path.utf8.count < maxLength else { return nil }
195+
_ = withUnsafeMutablePointer(to: &address.sun_path) { pointer in
196+
path.withCString { source in
197+
strncpy(UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: CChar.self),
198+
source, maxLength - 1)
199+
}
200+
}
201+
202+
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
203+
let connected = withUnsafePointer(to: &address) { pointer in
204+
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(descriptor, $0, size) }
205+
}
206+
guard connected == 0 else { return nil }
207+
208+
// Don't hang the CLI if the daemon is wedged. A control request is a dictionary
209+
// update; anything slower than this is a daemon that cannot answer.
210+
var timeout = timeval(tv_sec: 5, tv_usec: 0)
211+
setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
212+
setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
213+
214+
guard let encoded = try? JSONEncoder().encode(request) else { return nil }
215+
let written = encoded.withUnsafeBytes { bytes in
216+
write(descriptor, bytes.baseAddress, bytes.count)
217+
}
218+
guard written == encoded.count else { return nil }
219+
220+
var buffer = [UInt8](repeating: 0, count: 4096)
221+
let count = read(descriptor, &buffer, buffer.count)
222+
guard count > 0 else { return nil }
223+
224+
return try? JSONDecoder().decode(ControlResponse.self, from: Data(buffer[0..<count]))
225+
}
226+
}

0 commit comments

Comments
 (0)