server/src/usbip_input_session.cpp
Rama main · réplica de lectura del repositorio original.
/**
* @file src/usbip_input_session.cpp
* @brief The devices one stream is holding. Implementation.
*
* The one rule this file exists to keep: whatever it takes, it gives back. See the header for why
* that is a destructor rather than a call.
*/
#include "src/usbip_input_session.h"
#include <algorithm>
#include <chrono>
#include <sstream>
#include <thread>
#include <unordered_set>
#include <utility>
namespace input::usbip {
namespace {
/**
* Split the optional busid allowlist.
*
* Tolerant of the ways a person actually types a list into a config file - spaces after the
* commas, a trailing comma, both. A malformed entry is DROPPED rather than passed on: the
* policy validates busids again before anything is spawned, but a bad entry that reaches it
* throws, and throwing out of "which devices did the user ask for" would report a config typo
* as a failure to attach. Dropping it means the typo costs one device, not the feature.
*/
std::unordered_set<std::string> parse_allowlist(const std::string &raw) {
std::unordered_set<std::string> wanted;
std::stringstream stream {raw};
std::string piece;
while (std::getline(stream, piece, ',')) {
const auto first = piece.find_first_not_of(" \t\r\n");
if (first == std::string::npos) {
continue;
}
const auto last = piece.find_last_not_of(" \t\r\n");
const auto trimmed = piece.substr(first, last - first + 1);
if (is_valid_busid(trimmed)) {
wanted.insert(trimmed);
}
}
return wanted;
}
/// How hard we look for a port before believing there is not one.
///
/// `usbip attach` succeeding does NOT mean the port exists yet: the driver assigns it, and a
/// read taken in the same breath as the tool that moved the device comes back before the port
/// is recorded. Measured on the z13 on 2026-10-06: the attach printed NOTHING on either stream
/// (the `using port` format string is in the binary and is not emitted on success), and
/// `usbip port` reported an EMPTY table at t+0 and only named the device at t+3s.
///
/// The budget this replaced - six looks 250ms apart, 1.25s in total - sat entirely INSIDE that
/// window, so it expired before the table could answer, on every attach, and the device went
/// unported. A device nobody can name cannot be detached, which is the one failure this file
/// exists to prevent.
///
/// So wait the lag out instead of guessing at it: a ceiling, not a doorman. The loop returns the
/// moment the table names the device (about 3s on the machine that measured it), and only spends
/// the ceiling when the answer is genuinely not coming. This runs off the request path, after
/// the stream's answer has gone out, so the patience is not paid for by the client.
constexpr std::chrono::milliseconds kPortLookupCeiling {20000};
constexpr std::chrono::milliseconds kPortLookupPause {400};
/**
* What a port lookup found - and, when it found nothing, WHICH nothing.
*
* "This machine holds nothing" and "I could not read what this machine holds" are different
* answers and must never collapse into one. The first is a fact; the second is an admission
* with a device possibly sitting attached behind it. The policy layer keeps them apart on
* purpose - see the PortOutcome note about refusing to answer "your hands are empty" - and this
* carries that distinction out to the caller that has to act on it.
*/
struct port_lookup_t {
std::vector<held_device_t> held;
/// Non-empty when the read itself failed, in the tool's own words. Never composed by us.
std::string unreadable;
/// How many times the table was actually read. Carried out to the report so the sentence says
/// how hard we looked rather than quoting a constant that no longer bounds the search.
int looks = 0;
};
/**
* Learn which port each of our busids landed on.
*
* `usbip attach` does not hand back a port - the port is assigned by the driver and the only
* way to read it is to ask what this machine now holds. So after attaching we re-read the port
* table and match entries to the busids we asked for. A busid that does not appear is NOT
* recorded as held: we did not get a port for it, and inventing one would mean detaching
* whatever ends up on that number later. It IS recorded as unported, because a device we
* attached is ours to give back whether or not we can name its port yet.
*/
port_lookup_t match_ports(const std::vector<std::string> &attached_busids) {
port_lookup_t lookup;
if (attached_busids.empty()) {
return lookup;
}
const auto ceiling = std::chrono::steady_clock::now() + port_table_patience();
while (true) {
++lookup.looks;
const auto listed = run_list_attached();
if (listed.code != 0) {
// The read failed. That is an admission, not an empty table - and the tool's own words
// are the detail, not a bucket name.
lookup.unreadable = listed.err.empty()
? ("the port table could not be read (client exit " +
std::to_string(listed.code) + ")")
: listed.err;
// A tool that will not run is not a table that has not answered yet. Waiting cannot fix it,
// so a failing read ends the wait here instead of spending the whole period on it.
break;
} else {
const auto parsed = parse_attached(listed.out);
if (parsed.outcome == PortOutcome::Devices ||
parsed.outcome == PortOutcome::NothingAttached) {
lookup.unreadable.clear();
lookup.held.clear();
for (const auto &want : attached_busids) {
const auto found =
std::find_if(parsed.devices.begin(), parsed.devices.end(),
[&want](const Attached &device) { return device.busid == want; });
if (found != parsed.devices.end()) {
lookup.held.push_back(held_device_t {found->busid, found->port});
}
}
if (lookup.held.size() == attached_busids.size()) {
return lookup;
}
} else {
// The tool refused, or said something we could not place. Carry its sentence.
lookup.unreadable = describe(parsed.outcome);
if (!parsed.detail.empty()) {
lookup.unreadable += ": ";
lookup.unreadable += parsed.detail;
}
}
}
// The ceiling is checked AFTER the read, so the first look is always immediate and the last
// one always counts - a table that answers on the very edge of the window is still read.
if (std::chrono::steady_clock::now() >= ceiling) {
break;
}
std::this_thread::sleep_for(kPortLookupPause);
}
return lookup;
}
std::string join_busids(const std::vector<std::string> &busids) {
std::string joined;
for (const auto &busid : busids) {
if (!joined.empty()) {
joined += ", ";
}
joined += busid;
}
return joined;
}
} // namespace
namespace {
/// See port_table_patience(). One value for every holder, so a test can shorten the wait without
/// having to find every object that might look.
std::chrono::milliseconds g_port_table_patience {kPortLookupCeiling};
} // namespace
std::chrono::milliseconds port_table_patience() {
return g_port_table_patience;
}
void set_port_table_patience(const std::chrono::milliseconds patience) {
g_port_table_patience = patience;
}
std::shared_ptr<session_holder_t> session_holder_t::holding_nothing() {
// A shared_ptr to a private constructor: this is the only way to make one, so nobody can
// construct a holder by a path that skipped what this file exists to do.
return std::shared_ptr<session_holder_t>(new session_holder_t {});
}
std::shared_ptr<session_holder_t> session_holder_t::attach(const session_request_t &request) {
// The one-shot form: make the holder and fill it before handing it back.
//
// A caller that needs the stream to ANSWER before the devices move - which is the streaming
// path, because a client that is kept waiting on a USB probe gives up on the whole handshake -
// uses holding_nothing() and take_from() separately, so the session owns its hold across the
// whole of the slow part. That is why the two are no longer one function: there is nothing
// wrong with this form, it just cannot publish a holder in the middle of itself.
auto holder = holding_nothing();
holder->take_from(request);
return holder;
}
void session_holder_t::take_from(const session_request_t &request) {
if (m_Released) {
// The session let go of this holder before anything was asked of it. There is nothing to
// give back - and nothing may be taken either: release_all() will not run a second time, so
// a device put on this holder now would never be given back at all.
m_Report = "the stream ended before any device was asked for";
return;
}
if (!request.enabled) {
// The default. Said out loud rather than returning silently, because "sharing is off" and
// "sharing is on and found nothing" look identical from the outside otherwise.
m_Report = "USB device sharing is switched off for this stream";
return;
}
if (request.exporter.empty()) {
m_Report =
"no exporter address to attach from - the stream's source address was not known and no "
"override is configured";
return;
}
// The exporter address is the most dangerous input in this feature - it decides which machine
// this one connects to, and it arrives from a stream's source address rather than from a
// person. Validate it FIRST, before the machine readiness check and before anything else, so
// that a bad address is refused on its own merits rather than incidentally by whatever check
// happens to come first. The policy validates it again when it builds the argv; this is the
// gate that keeps it out of the feature entirely.
if (!is_usable_exporter(request.exporter)) {
m_Report = "refusing an unusable exporter address: '" + request.exporter + "'";
return;
}
// Ask whether this machine could take a device before asking for one. A refusal here is the
// whole point of the probe: it is the sentence that tells a person what to install, and the
// alternative is an attach that fails with a tool error they cannot act on.
const auto availability = client_available();
if (!availability.ok) {
m_Report = std::string {"this PC cannot take USB devices yet: "} + describe(availability.state);
return;
}
// What is the exporter offering?
const auto offered_run = run_list_remote(request.exporter);
if (offered_run.code != 0) {
// Both streams are reported, because on this path the reason is on stderr and the exit code
// only says "no". A reader with only the code cannot tell a wrong address from a busy port.
m_Report = "could not ask " + request.exporter + " what it is offering (client exit " +
std::to_string(offered_run.code) + "): " +
(offered_run.err.empty() ? std::string {"no detail"} : offered_run.err);
return;
}
const auto offered = parse_device_list(offered_run.out);
if (offered.outcome != ListOutcome::Devices) {
m_Report = "the exporter has nothing to offer: " + describe(offered.outcome);
return;
}
// Decide. plan_reconcile is the policy's, not this file's, so the decision is the same one the
// tests exercise and the same one the exporter's half makes.
const auto wanted = parse_allowlist(request.busids);
std::vector<std::string> wanted_list {wanted.begin(), wanted.end()};
const auto held_now = parse_attached(run_list_attached().out).devices;
const auto plan = plan_reconcile(offered.devices, held_now, wanted_list);
std::vector<std::string> took;
std::vector<std::string> refused;
// Devices the attach itself named a port for. Authoritative and immediate - see the comment
// on the capture below - so these never have to wait on the port table at all.
std::vector<held_device_t> named;
for (const auto &action : plan.actions) {
if (action.action != Action::Attach) {
// This holder does not release what it did not take. A device found already attached was
// taken by someone else - often a previous run that died - and giving it back from here
// would be detaching another session's device. Leftovers are the startup sweep's job,
// where there is provably no session at all.
continue;
}
// Qualified: the free function that runs the client and classifies the outcome, NOT the
// static member of the same name that we are inside. Unqualified, this would call itself.
const auto result = input::usbip::attach(request.exporter, action.busid);
if (result.outcome == AttachOutcome::Attached) {
took.push_back(action.busid);
// The tool named its port. Take it, rather than making the port table prove it - the table
// is exactly what failed on 2026-10-06: a successful attach printed
// `usbip: info: using port 8 (vhci_hcd.0)` while `usbip port` showed NOTHING at every look
// for the whole session, and only listed the device minutes after the session had ended.
// So the session ended holding a device it could not name - and a device that cannot be
// named cannot be detached.
if (result.port >= 0) {
named.push_back(held_device_t {action.busid, result.port});
}
} else {
// "NOTHING MOVED" cases are collected as well as failures, because a device left behind is
// the thing the user will ring about, and the sentence that says why is the difference
// between a two-minute answer and an afternoon.
//
// So keep the sentence. The classification alone says which bucket it landed in and
// throws away the reason the tool actually gave - and a bucket name is not an answer.
// The failure that cost a night proves it: a polkit refusal classified as Failed printed
// as "the attach failed" while the real reason, already captured in detail, was dropped
// here. detail is a line written by the tool, never composed by us.
std::string why = describe(result.outcome);
if (!result.detail.empty()) {
why += ": ";
why += result.detail;
}
refused.push_back(action.busid + " (" + why + ")");
}
}
// What the tool named is authoritative and immediate, so the table is asked ONLY about the
// devices the tool did NOT name. On a machine whose attach reports its port this costs no
// lookups and no waiting at all - and a device the tool named can never end up unnamed.
std::vector<std::string> unnamed;
for (const auto &busid : took) {
const auto known = std::find_if(named.begin(), named.end(),
[&busid](const held_device_t &device) { return device.busid == busid; });
if (known == named.end()) {
unnamed.push_back(busid);
}
}
const auto lookup = match_ports(unnamed);
m_Held = named;
m_Held.insert(m_Held.end(), lookup.held.begin(), lookup.held.end());
// Anything we attached and could not find a port for is STILL OURS TO GIVE BACK. Recorded here
// so the release path looks again, when the table has settled - a device we moved must not
// become a device nobody owns just because one read came back empty.
m_Unported.clear();
for (const auto &busid : took) {
const auto held = std::find_if(m_Held.begin(), m_Held.end(),
[&busid](const held_device_t &device) { return device.busid == busid; });
if (held == m_Held.end()) {
m_Unported.push_back(busid);
}
}
// Built once, from what actually happened, so the log line cannot claim more than the holder
// can give back. This is the audit trail for the promise.
std::ostringstream report;
if (m_Held.empty()) {
report << "no devices were taken";
if (!took.empty()) {
// We were told it attached but could not find a port for it. That is the one genuinely
// alarming state in this file: a device may be held that we have no way to release - and
// saying WHICH nothing we found is the difference between reading the tool's own sentence
// and reading our shrug.
report << ", but " << took.size() << " device(s) reported attached and ";
if (lookup.unreadable.empty()) {
report << "neither the attach nor the port table named one after " << lookup.looks
<< " look(s), so no port could be found for ";
} else {
report << "the port table could not be read (" << lookup.unreadable
<< "), so no port could be found for ";
}
report << join_busids(took)
<< " - this machine may be holding them, and they will be looked for again when the "
"stream ends";
}
} else {
report << "holding " << m_Held.size() << " device(s) from " << request.exporter << ": ";
bool first = true;
for (const auto &device : m_Held) {
if (!first) {
report << ", ";
}
first = false;
report << device.busid << " on port " << device.port;
}
}
if (!refused.empty()) {
report << "; not taken: " << join_busids(refused);
}
m_Report = report.str();
if (m_Released) {
// The session ended WHILE the devices were being moved - they are slow to move, and a stream
// can end in the middle of it. That release has already been recorded, and it released a
// holder that was still empty, because these were not on it yet. The guard in release_all()
// would make the destructor's call a no-op, so give them back HERE, while there is still
// something that can. Without this the devices stay on a machine whose stream is gone, which
// is the exact state this whole file exists to prevent.
m_Report += "; the stream ended while they were being attached, so they were given straight back";
give_back_everything();
m_Held.clear(); // so holding() tells the truth: nothing is held any more
}
}
bool session_holder_t::release(const held_device_t &device) {
const auto result = run_detach(device.port);
if (result.code == 0) {
return true;
}
// A failed release is RECORDED, not retried and not thrown. Retrying would block a teardown
// path on a device that is not coming back; throwing would end the process instead of
// releasing the rest of them. The port table is the authority, and if the device is still held
// the next startup sweep finds it - which is exactly what the sweep is for.
//
// This module deliberately has no logging dependency, so the failure is carried out in words
// for the CALLER to log. That is not tidiness: it is why this file compiles and is tested
// without the full server toolchain, which is the only reason it can be tested at all.
if (!m_ReleaseFailure.empty()) {
m_ReleaseFailure += "; ";
}
m_ReleaseFailure += "port " + std::to_string(device.port) +
(device.busid.empty() ? std::string {} : (" (" + device.busid + ")")) +
" was not given back (client exit " + std::to_string(result.code) + "): " +
(result.err.empty() ? std::string {"no detail"} : result.err);
return false;
}
void session_holder_t::give_back_everything() {
for (const auto &device : m_Held) {
release(device);
}
// The last look, and it is a PATIENT one.
//
// A device we attached but could not find a port for at the time is read again here, on the way
// out. This used to be a single read with no waiting, on the reasoning that a teardown must never
// be slow and that "by now the thing that was too early has long since happened".
//
// That reasoning is wrong for the case it was written for. Measured on 2026-10-06: attach at
// 20:18:22, stream gone at 20:18:30 - eight seconds - and the port table did not name the device
// inside that window. The single read came back empty, and an empty read is indistinguishable
// from "this machine holds nothing", so the device was judged not to be ours after all and a
// drive sat attached until a person detached it by hand. Eight seconds is not a long stream; it
// is one that was ended the moment somebody saw something was wrong.
//
// So wait the table out the same way the take path does, and NEVER leave without a sentence. The
// cost is bounded, and it is only paid when a device is genuinely unported - which the take
// path's own ceiling now makes rare. The alternative is somebody's drive going missing with
// nothing in the log that says so.
if (m_Unported.empty()) {
return;
}
std::vector<std::string> still_held = m_Unported;
m_Unported.clear();
std::string last_reason;
int looks = 0;
const auto ceiling = std::chrono::steady_clock::now() + port_table_patience();
while (true) {
++looks;
const auto listed = run_list_attached();
if (listed.code != 0) {
last_reason = listed.err.empty() ? std::string {"no detail"} : listed.err;
// Same reasoning as the take path: a tool that will not run is not a table that is slow.
break;
} else {
const auto parsed = parse_attached(listed.out);
if (parsed.outcome == PortOutcome::Devices ||
parsed.outcome == PortOutcome::NothingAttached) {
last_reason.clear();
std::vector<std::string> not_found;
for (const auto &busid : still_held) {
const auto found = std::find_if(parsed.devices.begin(), parsed.devices.end(),
[&busid](const Attached &device) { return device.busid == busid; });
if (found == parsed.devices.end()) {
not_found.push_back(busid);
continue;
}
release(held_device_t {found->busid, found->port});
}
still_held = not_found;
if (still_held.empty()) {
return;
}
} else {
last_reason = describe(parsed.outcome);
if (!parsed.detail.empty()) {
last_reason += ": ";
last_reason += parsed.detail;
}
}
}
if (std::chrono::steady_clock::now() >= ceiling) {
break;
}
std::this_thread::sleep_for(kPortLookupPause);
}
// Still ours, and the table would not name it. Say so, with WHICH nothing we found: a device away
// from the machine it belongs to is the one state a person has to be told about, and it must not
// be lost to a read that came back empty.
for (const auto &busid : still_held) {
record_give_back_failure(
busid,
(last_reason.empty() ? std::string {"the port table never named it"} : last_reason) +
" (after " + std::to_string(looks) + " look(s) over " +
std::to_string(port_table_patience().count() / 1000) + "s)",
0);
}
}
void session_holder_t::record_give_back_failure(const std::string &busid, const std::string &reason,
const int code) {
if (!m_ReleaseFailure.empty()) {
m_ReleaseFailure += "; ";
}
m_ReleaseFailure += "the device on " + busid + " was not given back" +
(code == 0 ? std::string {}
: (" (client exit " + std::to_string(code) + ")")) +
": " + reason;
}
void session_holder_t::release_all() {
if (m_Released) {
return;
}
m_Released = true;
give_back_everything();
}
session_holder_t::~session_holder_t() {
// The promise. Every end path reaches here, which is the entire reason the devices are owned
// by an object instead of released by a call.
release_all();
}
std::string session_holder_t::report() const {
return m_Report.empty() ? "no devices were taken" : m_Report;
}
std::string sweep_leftovers() {
const auto availability = client_available();
if (!availability.ok) {
// Not a failure. A machine that cannot attach also cannot be holding anything.
return std::string {"not swept: "} + describe(availability.state);
}
const auto listed = run_list_attached();
const auto parsed = parse_attached(listed.out);
if (parsed.outcome == PortOutcome::NothingAttached) {
return "nothing was left behind by a previous run";
}
if (parsed.outcome != PortOutcome::Devices) {
return std::string {"could not read what this machine holds: "} + describe(parsed.outcome);
}
// Everything the kernel says is attached is a leftover here, because this runs at startup
// before any session exists. There is no session that could legitimately own one yet.
std::vector<std::string> released;
std::vector<std::string> stuck;
for (const auto &device : parsed.devices) {
const auto result = run_detach(device.port);
const auto name = device.busid.empty() ? ("port " + std::to_string(device.port)) : device.busid;
if (result.code == 0) {
released.push_back(name);
} else {
stuck.push_back(name);
}
}
std::ostringstream report;
if (released.empty() && stuck.empty()) {
return "nothing was left behind by a previous run";
}
if (!released.empty()) {
report << "gave back " << released.size()
<< " device(s) a previous run had left attached ("
<< "a stream that died before it could release them"
<< "): " << join_busids(released);
}
if (!stuck.empty()) {
if (!released.empty()) {
report << "; ";
}
report << "could NOT give back " << join_busids(stuck)
<< " - the machine they are plugged into is still missing them";
}
return report.str();
}
} // namespace input::usbip