1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
//! What the transport is doing right now.
//!
//! Pure: a plain state value with no machinery behind it. The watch cell
//! that publishes transitions, and the aggregation that turns several
//! peers' connection types into one of these, live with the handles.
/// What the transport is doing right now.
///
/// The `Relayed` case is worth surfacing to users: it explains latency
/// and is expected under carrier-grade or strict corporate NAT. `Closed`
/// is the terminal state, and `status_changed` guarantees to deliver it —
/// a watcher never blocks forever on a pipe that is already gone.
///
/// One listener can serve several peers at once — a phone and a laptop
/// holding the same ticket — so on the serve side this is an aggregate,
/// and it reports **the worst active path**: `Relayed` if any connected
/// peer is relayed, `Direct` only when all of them are direct. Reporting
/// the best path instead would hide exactly what `Relayed` exists to
/// explain, leaving the owner of the slow device with no way to find out
/// why. The cost is accepted and stated plainly: with a mixed set this
/// value describes no single peer, and a per-peer accessor is the
/// additive change that would fix that if the need proves real.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(rename_all = "lowercase")
)]
#[non_exhaustive]
pub enum PipeStatus {
/// No peer is connected — waiting for the first, or between
/// connections.
Idle,
/// Every connected peer has a direct hole-punched connection.
Direct,
/// At least one connected peer is falling back through an (encrypted,
/// unreadable) relay.
Relayed,
/// The pipe is gone — shut down, dropped, or, on the connect side, the
/// *local* listener stopped accepting under it. Terminal: no transition
/// follows.
///
/// **A transport failure is not one of the ways to get here**, and the
/// sentence that used to say it was described a path neither side has.
/// Nothing gives up on reaching a peer: an unreachable one is
/// [`Idle`](Self::Idle), retried for as long as the pipe is held, which
/// is the whole reason that state and this one are different answers.
/// Every close is a local decision, and [`CloseReason`] says which one.
///
/// Carried as a bare state rather than a reason so this type stays
/// `Copy`; the need proved real, and the diagnostic accessor that
/// answers *which* of those it was is
/// [`ConnectHandle::close_reason`](crate::ConnectHandle::close_reason).
Closed,
}
/// Why a pipe reached [`PipeStatus::Closed`].
///
/// The distinction [`PipeStatus`] deliberately does not carry, kept out of
/// it so that type stays `Copy` and stays small enough to sit in a
/// watcher's own state. Read it from
/// [`ConnectHandle::close_reason`](crate::ConnectHandle::close_reason),
/// which answers `None` for as long as the pipe is live.
///
/// **What it is for is telling a failure from a success**, which the status
/// alone cannot do. A connect side reports [`PipeStatus::Idle`] both while
/// it is looking for a peer that has gone away and before it has ever
/// reached one, and it reports [`PipeStatus::Closed`] whether a caller
/// asked for that or the local listener died under it. So: `None` means the
/// pipe is still live and still trying, however idle it looks;
/// [`Shutdown`](Self::Shutdown) means this side ended it on purpose; and
/// [`ListenerFailed`](Self::ListenerFailed) means nobody did.
///
/// `#[non_exhaustive]`, because the set of ways a pipe can end is not
/// closed — a caller matching on it needs a `_` arm and a plan for the
/// reasons that do not exist yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CloseReason {
/// This side ended the pipe on purpose: `shutdown`,
/// `shutdown_timeout`, or the handle being dropped.
///
/// The three differ in what becomes of the requests already in flight
/// — `shutdown` drains, a drop cuts — and not in why the pipe ended,
/// which is what this answers. There is deliberately no separate
/// reason for the drop, because there would be no way to read one:
/// the accessor needs a handle, and a dropped handle is the one thing
/// a caller no longer has.
Shutdown,
/// The local listener stopped accepting and could not carry on. Nobody
/// asked for this one: it is the reason here that reports a failure
/// rather than an intention, and the one worth waking somebody over.
ListenerFailed,
}
impl CloseReason {
/// A stable lowercase identifier: `"shutdown"`, `"listener_failed"`.
///
/// Same contract as [`PipeStatus::as_str`], for the same reasons —
/// an identifier rather than a sentence, frozen once anything greps
/// for it, and a reason added later returns its own new one.
pub const fn as_str(self) -> &'static str {
match self {
Self::Shutdown => "shutdown",
Self::ListenerFailed => "listener_failed",
}
}
}
impl PipeStatus {
/// A stable lowercase identifier: `"idle"`, `"direct"`, `"relayed"`,
/// `"closed"`.
///
/// For status output, log fields and anything else that wants to name
/// the state without matching on it. Deliberately an identifier and
/// not a sentence — freezing `"relayed"` costs nothing, while freezing
/// "falling back through a relay" would make every wording improvement
/// a breaking change for whoever grepped for it.
///
/// A variant added later returns its own new identifier, so a caller
/// rendering this string keeps working; one *matching* on the string
/// has the same obligation it would have had matching on the enum.
pub const fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Direct => "direct",
Self::Relayed => "relayed",
Self::Closed => "closed",
}
}
}
/// One connected peer, as the serve side sees it.
///
/// Returned by [`ServeHandle::peers`](crate::ServeHandle::peers). The
/// `path` is a [`PipeStatus`] rather than a narrower enum so a caller
/// renders both with one `as_str`; for a single peer it is only ever
/// `Direct` or `Relayed`.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct PeerView {
/// The peer's fingerprint: twelve hex characters, the same rule the
/// `peer` log field and the `X-Modelpipe-Peer` header use, so a device
/// is one name everywhere it appears.
pub fingerprint: String,
/// How this peer is reaching the listener right now.
///
/// **Right now, and re-read while the connection lives.** A connection
/// commonly establishes through a relay and hole-punches to a direct
/// path a moment later, and this follows that; before it did, a
/// listener reported whichever path a peer happened to arrive on for
/// the whole of that peer's session.
pub path: PipeStatus,
/// Round-trip time to this peer over the path above, in whole
/// milliseconds, or `None` while no path is established yet.
///
/// QUIC's own smoothed estimate rather than a probe this crate sends,
/// so it costs nothing to read and moves a little between calls even on
/// a path that has not changed. It is what turns [`path`](Self::path)
/// into a measurement: `relayed` says a peer went the long way round,
/// and this says what the long way round cost it.
///
/// Milliseconds rather than a `Duration` because this struct is a DTO —
/// it is the shape a status page renders, and the one the `serde`
/// feature exists for — and `Duration` serializes as a two-field
/// struct of seconds and nanoseconds. A round trip is never measured
/// finer than this by anything that would display it.
pub rtt_ms: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::*;
/// The identifiers are frozen surface once anything greps for them, so
/// pin the spellings and the distinctness in one place.
#[test]
fn every_status_renders_a_distinct_stable_identifier() {
let all = [
PipeStatus::Idle,
PipeStatus::Direct,
PipeStatus::Relayed,
PipeStatus::Closed,
];
let rendered: Vec<&str> = all.iter().map(|s| s.as_str()).collect();
assert_eq!(rendered, ["idle", "direct", "relayed", "closed"]);
let mut deduped = rendered.clone();
deduped.sort_unstable();
deduped.dedup();
assert_eq!(
deduped.len(),
rendered.len(),
"identifiers must be distinct"
);
}
/// The same rule for the reasons, and the same reason for it.
#[test]
fn every_close_reason_renders_a_distinct_stable_identifier() {
let all = [CloseReason::Shutdown, CloseReason::ListenerFailed];
let rendered: Vec<&str> = all.iter().map(|r| r.as_str()).collect();
assert_eq!(rendered, ["shutdown", "listener_failed"]);
let mut deduped = rendered.clone();
deduped.sort_unstable();
deduped.dedup();
assert_eq!(
deduped.len(),
rendered.len(),
"identifiers must be distinct"
);
}
/// The whole point of the type: a pipe somebody ended and a pipe that
/// broke are not the same answer, and comparing them must say so.
#[test]
fn a_failure_is_not_equal_to_a_deliberate_close() {
assert_ne!(CloseReason::ListenerFailed, CloseReason::Shutdown);
}
}