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
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,51 @@ working. The HIDMaestro backend, the composite audio personas and the
unified build story (one script contract shared by CI and local builds)
land here too.

The diagnostics page tells both halves of the story. `GET /api/debug` was
eight counters about inbound UDP plus one backend name, and the page built on
it showed one direction, named only the preferred backend (so a Windows box
with both drivers only ever said "ViGEm"), and painted an idle-but-healthy host
red because it read `backendAvailable` -- which means "the bus is open right
now", not "the driver works". The payload gains three blocks. `rx` counts
accepted inbound messages by type (input, heartbeat, motion, battery, pointer,
mic audio) and the four ways a datagram is refused before any decoder sees it
(`malformed` for a known opcode that failed its length guard, `unknownType`,
`runt`, `unknownToken`). `tx` is the direction that had no telemetry at all:
datagrams and bytes sent, split across heartbeat acks, rumble, lightbar,
trigger effects, player LEDs, speaker audio, the mic lamp and session closes,
plus `unroutable` / `encryptFailed` / `oversize` / `sendFailed` -- the last of
which required checking `sendto`'s return value, which the client adapter had
been discarding. `audio` reports the health of the streams this release turns
on: mic frames accepted, arrived-too-late and dropped (a partition of every
inbound frame), how many reached the pad by decode, by Opus in-band FEC and by
concealment, and on the way out how many speaker frames were sent, suppressed
as digital silence, failed to encode, or lost to lock contention. Alongside
them: client-API 401s split `notPaired` / `badProof`, reaped sessions, and the
host gauges the page previously had to infer (`webPort` -- which the old page
read but the server never sent -- `mdnsResponderActive`, `clientApiListening`,
live connection and controller counts).

None of it touches the gamepad hot path, and the split is deliberate rather
than incidental: the inbound counters live only in the receiver's rejection
branches and its cold non-gamepad dispatch branch (`DispatchResult` grew a
`handled` flag so the receiver can tell a malformed frame from an unrecognised
one without re-parsing, keeping `inner_dispatch.cpp` free of globals as its
portable test build requires), the outbound ones sit in
`ClientAdapter::sendEncryptedPacket`, which no gamepad packet reaches, and the
audio ones in `SessionService` under locks those paths already hold. Inbound
byte counting is deliberately absent: it would cost an atomic add per packet on
the accepted path. `maxLoopUs` keeps its read-and-zero window semantics for
benchmark tooling, and a new `peakLoopUs` reports the peak that field could not:
the receiver's thread-local high-water mark never resets, so a zeroed
`maxLoopUs` climbs again only on a new all-time record, which is why the page's
"peak" read 0 nearly always. The page itself is rebuilt around a bidirectional
flow diagram, six grouped sections, a mirrored in/out traffic chart and a list
of every backend the host reports with its vendor, mode, audio capability,
lifecycle and installed-versus-bundled driver version; it degrades to em dashes
against an older satellite rather than rendering `undefined`, and it stops
polling when you navigate away instead of fetching `/api/debug` twice a second
for the life of the tab.

No elevation prompt when a controller connects. Creating a HIDMaestro
virtual device needs an administrator token, and until now Satellite got
one by spawning `satellite-hm-helper.exe` with `runas` on the first
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,7 @@ satellite_add_pure_test(test_config_json tests/test_config_json.cpp)
satellite_add_pure_test(test_network_info tests/test_network_info.cpp src/core/network_info.cpp)
satellite_add_pure_test(test_origin_guard tests/test_origin_guard.cpp)
satellite_add_pure_test(test_status_json tests/test_status_json.cpp)
satellite_add_pure_test(test_wire_stats tests/test_wire_stats.cpp)
satellite_add_pure_test(test_backend_registry tests/test_backend_registry.cpp
src/core/backend_registry.cpp)
satellite_add_pure_test(test_driver_inf tests/test_driver_inf.cpp)
Expand Down
21 changes: 16 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ arrays. The remaining globals are:

- `g_config` / `g_configMtx`: application configuration
- Atomic telemetry counters (`g_packetCount`, `g_submitOk`, etc.)
- `satellite::g_wire` (`app/wire_stats.h`): per-message wire counters, all
incremented off the gamepad hot path (see "Hot-path discipline")
- Log ring buffer (`g_logRing`, `g_logMtx`)
- Win32 plumbing (`g_hwnd`, `g_httpServer`, `g_appRunning`)

Expand All @@ -257,14 +259,14 @@ and runs a `recvfrom` loop. It delegates all business logic to
```
recvfrom()
├─ n < 8 → drop (too small for header)
├─ n < 28 → drop (too small for header+inner+tag) [g_wire.rxRunt]
├─ Extract token (bytes 0-3), counter (bytes 4-7)
│ └─ svc.getDecryptInfo(token) → not found → drop
│ └─ counter <= lastCounter → drop (replay)
│ └─ svc.getDecryptInfo(token) → not found → drop [g_wire.rxUnknownToken]
│ └─ counter <= lastCounter → drop (replay) [g_replayDrop]
├─ Decrypt (ChaCha20-Poly1305)
│ └─ Fail → drop
│ └─ Fail → drop [g_decryptFail]
├─ svc.updatePostDecrypt(token, counter, ip, port)
Expand Down Expand Up @@ -301,7 +303,16 @@ loop is kept allocation-free and minimally locked. If you touch
`inet_ntop` / `std::string` allocation per packet).
- Lock-free loop telemetry. `g_maxLoopUs` uses a thread-local
high-water-mark so the atomic CAS loop is skipped on the ~99% of
packets below the running per-second peak.
packets below the running per-second peak. Because that mark never
resets, a reader that zeroes `g_maxLoopUs` only sees it rise again on a
new all-time record; `/api/debug` folds each windowed read into
`g_wire.peakLoopUs` and reports that as the true peak.
- No new counters on the accepted-packet path. The per-message counters in
`app/wire_stats.h` are incremented only in the receiver's rejection
branches and in its cold (non-gamepad) dispatch branch, in
`ClientAdapter::sendEncryptedPacket` (no outbound message is reachable
from the gamepad path), and in `SessionService`'s audio paths, which run
at 50 Hz per controller under a lock they already hold.

The conceptual pipeline above lists `getDecryptInfo` /
`updatePostDecrypt` / `handleGamepadData` as distinct steps; on the
Expand Down
20 changes: 20 additions & 0 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,26 @@ hostFeatures; the server drops streams for ungranted features.
| `DELETE /api/devices/{deviceId}` | Unpair; closes any live session (notify `unpaired`) |
| `GET /api/connections` | Live sessions + per-controller truth (`pluggedIn` reflects the adapter, not `serialNo > 0`) |
| `DELETE /api/connections/{connectionId}` | Kick (notify `kicked`); transient, client may reconnect |
| `GET /api/backend/status` | Preferred backend plus the per-host `backends` array (identity, availability, driver/bundled version, lifecycle) |
| `GET /api/debug` | Wire, audio and host telemetry for the diagnostics page |

### `GET /api/debug`

Counters are cumulative since the receiver bound its socket (once per process in
practice) and are **not** part of the client contract: this is an operator surface and
fields may be added at any time. Alongside the long-standing scalars (`listening`,
`packets`, `submitOk`, `submitFail`, `lastLoopUs`, `senderIP`, `udpPort`, `decryptFail`,
`replayDrop`, `backendAvailable`, `backend`) it carries:

| Field | Meaning |
|-------|---------|
| `rx` | Accepted inbound messages by type (`input` = `submitOk + submitFail`, `heartbeat`, `motion`, `battery`, `pointer`, `micAudio`) and the rejections that never reached a decoder (`malformed` = known opcode whose length guard failed, `unknownType`, `runt` = datagram under 28 bytes, `unknownToken`) |
| `tx` | Outbound datagrams (`packets`, `bytes`) and their split by message (`heartbeatAck`, `rumble`, `lightbar`, `triggerEffects`, `playerLeds`, `speakerAudio`, `micLed`, `sessionClose`), plus the four send failures (`unroutable`, `encryptFailed`, `oversize`, `sendFailed`) |
| `audio` | Controller-audio stream health: `micAccepted` / `micLate` / `micDropped` partition every inbound frame, `micDecoded` + `micFecRecovered` + `micConcealed` count what reached the pad, and `speakerSent` / `speakerSilenceSuppressed` / `speakerEncodeFailed` / `speakerLockContended` the outbound side |
| `auth` | Client-API 401s, split `notPaired` / `badProof` |
| `peakLoopUs` | True hot-path peak. `maxLoopUs` keeps its read-and-zero window semantics for benchmark tooling and reads 0 unless a new all-time record was set since the last read |
| `webPort`, `mdnsResponderActive`, `clientApiListening`, `connections`, `controllers`, `maxControllers`, `sessionsReaped` | Host gauges |


The admin surface never sets descriptor fields (single-writer rule). The former
`POST /api/devices/touchpad-mode` (both surfaces) and `POST /api/devices/remove` are
Expand Down
28 changes: 23 additions & 5 deletions src/adapters/client_adapter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

#include "net/session_crypto.h"

#include "app/wire_stats.h"

#include <cstring>

void ClientAdapter::setSocket(SOCKET sock) { sock_ = sock; }
Expand Down Expand Up @@ -51,15 +53,25 @@ uint32_t ClientAdapter::nextTxCounter(uint32_t token) {

void ClientAdapter::sendEncryptedPacket(const Connection& conn, const uint8_t* inner,
size_t innerLen) {
if (sock_ == INVALID_SOCKET) return;
const uint16_t msgType = ((uint16_t)inner[0] << 8) | (uint16_t)inner[1];
if (sock_ == INVALID_SOCKET) {
satellite::g_wire.txUnroutable.fetch_add(1, std::memory_order_relaxed);
return;
}

// The buffers below are sized for MAX_INNER_MESSAGE_BYTES exactly, and
// encryptPacket has no length of its own to check against; an oversized
// caller is a bug, and dropping the frame beats overrunning the stack.
if (innerLen > static_cast<size_t>(MAX_INNER_MESSAGE_BYTES)) return;
if (innerLen > static_cast<size_t>(MAX_INNER_MESSAGE_BYTES)) {
satellite::g_wire.txOversize.fetch_add(1, std::memory_order_relaxed);
return;
}

sockaddr_in addr{};
if (!getAddr(conn.token, addr)) return;
if (!getAddr(conn.token, addr)) {
satellite::g_wire.txUnroutable.fetch_add(1, std::memory_order_relaxed);
return;
}

// Monotonic per-token counter in the nonce; the direction byte keeps this
// direction's nonces disjoint from the client's under the shared session key.
Expand All @@ -72,6 +84,7 @@ void ClientAdapter::sendEncryptedPacket(const Connection& conn, const uint8_t* i
unsigned long long ctLen = 0;
if (!encryptPacket(conn.sessionKey, CRYPTO_DIR_SERVER_TO_CLIENT, counter, conn.token, inner,
innerLen, ct, &ctLen)) {
satellite::g_wire.txEncryptFailed.fetch_add(1, std::memory_order_relaxed);
return;
}

Expand All @@ -89,8 +102,13 @@ void ClientAdapter::sendEncryptedPacket(const Connection& conn, const uint8_t* i
pkt[7] = (uint8_t)(counter);
memcpy(pkt + HEADER_SIZE, ct, ctLen);

sendto(sock_, reinterpret_cast<const char*>(pkt), (int)(HEADER_SIZE + ctLen), 0,
reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
const int sent = sendto(sock_, reinterpret_cast<const char*>(pkt), (int)(HEADER_SIZE + ctLen),
0, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
if (sent == SOCKET_ERROR) {
satellite::g_wire.txSendFailed.fetch_add(1, std::memory_order_relaxed);
return;
}
satellite::g_wire.recordOutbound(msgType, static_cast<size_t>(sent));
}

void ClientAdapter::sendHeartbeatAck(const Connection& conn, bool backendAvailable,
Expand Down
Loading
Loading