1use std::collections::BTreeMap;
9
10use crate::backend::rpc_types::{MuxBusDeliveryState, MuxBusDeliveryStatus};
11use crate::broker::CredentialState;
12
13pub const PAUSE_NOTICE_MS: i64 = 2 * 60 * 1000;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Link {
21 NoSignIn,
23 SignInStale(String),
25 Unreachable(String),
28 Connecting,
32 Connected,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum Effect {
39 Changed(MuxBusDeliveryStatus),
41 NotifySignInNeeded,
43 RetractSignInNeeded,
45 PauseNote { since_ms: i64, reason: &'static str },
48 Resumed { at_ms: i64 },
50}
51
52pub fn derive(
54 session_known: bool,
55 link: &Link,
56 broker: Option<&CredentialState>,
57) -> (MuxBusDeliveryState, Option<String>) {
58 match link {
59 Link::Connected => (MuxBusDeliveryState::Connected, None),
60 _ if !session_known => (MuxBusDeliveryState::SignedOut, None),
61 Link::SignInStale(reason) => (MuxBusDeliveryState::NeedsSignIn, Some(reason.clone())),
62 Link::Connecting => (MuxBusDeliveryState::Reconnecting, Some("connecting".to_string())),
63 Link::NoSignIn => (
66 MuxBusDeliveryState::NeedsSignIn,
67 Some("the stored sign-in can't be read".to_string()),
68 ),
69 Link::Unreachable(error) => match broker {
70 Some(CredentialState::NeedsReauth { rejected: true, reason, .. }) => {
71 (MuxBusDeliveryState::NeedsSignIn, Some(format!("the sign-in was refused: {reason}")))
72 }
73 _ => (MuxBusDeliveryState::Reconnecting, Some(error.clone())),
74 },
75 }
76}
77
78pub fn pause_reason(state: MuxBusDeliveryState) -> &'static str {
80 match state {
81 MuxBusDeliveryState::NeedsSignIn => "MuxBus needs a sign-in",
82 _ => "AgentMux can't reach MuxBus",
83 }
84}
85
86#[derive(Debug, Clone)]
89pub struct Machine {
90 session_known: bool,
91 email: Option<String>,
92 link: Link,
93 pull_errors: BTreeMap<String, String>,
98 status: MuxBusDeliveryStatus,
99 paused_since_ms: Option<i64>,
102 pause_noted: bool,
104 sign_in_notified: bool,
106}
107
108impl Machine {
109 pub fn new(now_ms: i64) -> Self {
110 Machine {
111 session_known: false,
112 email: None,
113 link: Link::NoSignIn,
114 pull_errors: BTreeMap::new(),
115 status: MuxBusDeliveryStatus { since_ms: now_ms, ..Default::default() },
116 paused_since_ms: None,
117 pause_noted: false,
118 sign_in_notified: false,
119 }
120 }
121
122 pub fn status(&self) -> &MuxBusDeliveryStatus {
123 &self.status
124 }
125
126 #[cfg(test)]
128 pub fn paused_since_ms(&self) -> Option<i64> {
129 self.paused_since_ms
130 }
131
132 pub fn saw_session(&mut self, email: Option<String>, broker: Option<&CredentialState>, now_ms: i64) -> Vec<Effect> {
134 self.session_known = true;
135 if email.as_deref().is_some_and(|e| !e.is_empty()) {
136 self.email = email;
137 }
138 if self.link == Link::NoSignIn {
141 self.link = Link::Unreachable("connecting".to_string());
142 }
143 self.update(broker, now_ms)
144 }
145
146 pub fn fresh_sign_in(&mut self, email: Option<String>, now_ms: i64) -> Vec<Effect> {
149 self.session_known = true;
150 if email.as_deref().is_some_and(|e| !e.is_empty()) {
151 self.email = email;
152 }
153 self.link = Link::Connecting;
154 self.update(None, now_ms)
155 }
156
157 pub fn signed_out(&mut self, now_ms: i64) -> Vec<Effect> {
159 self.session_known = false;
160 self.email = None;
161 self.link = Link::NoSignIn;
162 self.pull_errors.clear();
163 self.update(None, now_ms)
164 }
165
166 pub fn set_link(&mut self, link: Link, broker: Option<&CredentialState>, now_ms: i64) -> Vec<Effect> {
167 self.link = link;
168 self.update(broker, now_ms)
169 }
170
171 pub fn relay_answered(&mut self, agent: &str, broker: Option<&CredentialState>, now_ms: i64) -> Vec<Effect> {
173 self.status.last_ok_ms = Some(now_ms);
174 self.pull_errors.remove(agent);
175 self.update(broker, now_ms)
176 }
177
178 pub fn pull_failed(&mut self, agent: &str, reason: String, broker: Option<&CredentialState>, now_ms: i64) -> Vec<Effect> {
180 self.pull_errors.insert(agent.to_string(), reason);
181 self.update(broker, now_ms)
182 }
183
184 pub fn keep_pull_errors_of(&mut self, agents: &[String]) {
187 self.pull_errors.retain(|agent, _| agents.contains(agent));
188 }
189
190 pub fn tick(&mut self, broker: Option<&CredentialState>, now_ms: i64) -> Vec<Effect> {
193 let mut out = self.update(broker, now_ms);
194 if let Some(since_ms) = self.paused_since_ms {
195 if !self.pause_noted && self.status.state.is_paused() && now_ms - since_ms >= PAUSE_NOTICE_MS {
196 self.pause_noted = true;
197 out.push(Effect::PauseNote { since_ms, reason: pause_reason(self.status.state) });
198 }
199 }
200 out
201 }
202
203 fn update(&mut self, broker: Option<&CredentialState>, now_ms: i64) -> Vec<Effect> {
204 let link = match (&self.link, self.pull_errors.values().next()) {
205 (Link::Connected, Some(e)) => Link::Unreachable(e.clone()),
206 (link, _) => link.clone(),
207 };
208 let (state, error) = derive(self.session_known, &link, broker);
209 let mut out = Vec::new();
210 let prev = self.status.state;
211 let changed = state != prev || error != self.status.last_error || self.email != self.status.account_email;
212 if state == MuxBusDeliveryState::Connected || prev == MuxBusDeliveryState::Connected {
213 self.status.last_ok_ms = Some(now_ms);
214 }
215 if state != prev {
216 self.status.since_ms = now_ms;
217 if state == MuxBusDeliveryState::NeedsSignIn && !self.sign_in_notified {
218 self.sign_in_notified = true;
219 out.push(Effect::NotifySignInNeeded);
220 }
221 if matches!(state, MuxBusDeliveryState::Connected | MuxBusDeliveryState::SignedOut) && self.sign_in_notified {
222 self.sign_in_notified = false;
223 out.push(Effect::RetractSignInNeeded);
224 }
225 if state == MuxBusDeliveryState::SignedOut && !self.pause_noted {
230 self.paused_since_ms = None;
231 }
232 if state.is_paused() && self.paused_since_ms.is_none() {
233 self.paused_since_ms = Some(now_ms);
234 }
235 if state == MuxBusDeliveryState::Connected {
236 if self.paused_since_ms.take().is_some() && self.pause_noted {
237 out.push(Effect::Resumed { at_ms: now_ms });
238 }
239 self.pause_noted = false;
240 }
241 }
242 self.status.state = state;
243 self.status.last_error = error;
244 self.status.account_email = self.email.clone();
245 if changed {
246 out.insert(0, Effect::Changed(self.status.clone()));
247 }
248 out
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use MuxBusDeliveryState::*;
256
257 const T0: i64 = 1_791_000_000_000;
258
259 fn refused() -> CredentialState {
260 CredentialState::NeedsReauth { since_unix: 0, reason: "invalid_grant".into(), rejected: true }
261 }
262
263 fn piled_up() -> CredentialState {
264 CredentialState::NeedsReauth { since_unix: 0, reason: "network".into(), rejected: false }
265 }
266
267 fn connected_machine() -> Machine {
268 let mut m = Machine::new(T0);
269 m.saw_session(Some("a@b.c".into()), None, T0);
270 m.set_link(Link::Connected, None, T0);
271 m
272 }
273
274 fn kinds(effects: &[Effect]) -> Vec<&'static str> {
275 effects
276 .iter()
277 .map(|e| match e {
278 Effect::Changed(_) => "changed",
279 Effect::NotifySignInNeeded => "notify",
280 Effect::RetractSignInNeeded => "retract",
281 Effect::PauseNote { .. } => "pause",
282 Effect::Resumed { .. } => "resumed",
283 })
284 .collect()
285 }
286
287 #[test]
288 fn each_state_derives_from_its_inputs() {
289 assert_eq!(derive(true, &Link::Connected, Some(&refused())).0, Connected, "an open link wins");
290 assert_eq!(derive(false, &Link::NoSignIn, None).0, SignedOut);
291 assert_eq!(derive(false, &Link::Unreachable("x".into()), Some(&refused())).0, SignedOut);
292 assert_eq!(derive(true, &Link::NoSignIn, None).0, NeedsSignIn, "a sign-in that vanished");
293 assert_eq!(derive(true, &Link::SignInStale("pool".into()), None), (NeedsSignIn, Some("pool".into())));
294 assert_eq!(derive(true, &Link::Unreachable("timeout".into()), None), (Reconnecting, Some("timeout".into())));
295 assert_eq!(derive(true, &Link::Unreachable("x".into()), Some(&piled_up())).0, Reconnecting);
296 let (state, why) = derive(true, &Link::Unreachable("x".into()), Some(&refused()));
297 assert_eq!(state, NeedsSignIn);
298 assert!(why.unwrap().contains("invalid_grant"));
299 }
300
301 #[test]
302 fn a_channel_never_signed_in_stays_signed_out_and_quiet() {
303 let mut m = Machine::new(T0);
304 let fx = m.set_link(Link::NoSignIn, Some(&refused()), T0 + 1);
305 assert!(fx.is_empty(), "{fx:?}");
306 assert_eq!(m.status().state, SignedOut);
307 assert!(m.tick(Some(&refused()), T0 + 10 * PAUSE_NOTICE_MS).is_empty());
308 }
309
310 #[test]
311 fn transitions_record_when_they_happened() {
312 let mut m = connected_machine();
313 assert_eq!(m.status().state, Connected);
314 assert_eq!(m.status().account_email.as_deref(), Some("a@b.c"));
315 m.set_link(Link::Unreachable("closed".into()), None, T0 + 5_000);
316 assert_eq!((m.status().state, m.status().since_ms), (Reconnecting, T0 + 5_000));
317 assert_eq!(m.status().last_ok_ms, Some(T0 + 5_000), "last ok is when it left connected");
318 m.set_link(Link::SignInStale("refused".into()), None, T0 + 9_000);
319 assert_eq!((m.status().state, m.status().since_ms), (NeedsSignIn, T0 + 9_000));
320 m.set_link(Link::Connected, None, T0 + 20_000);
321 assert_eq!((m.status().state, m.status().since_ms), (Connected, T0 + 20_000));
322 let fx = m.signed_out(T0 + 30_000);
323 assert_eq!(m.status().state, SignedOut);
324 assert_eq!(m.status().account_email, None);
325 assert_eq!(kinds(&fx), ["changed"]);
326 }
327
328 #[test]
329 fn the_broker_alone_can_move_reconnecting_to_needs_sign_in() {
330 let mut m = connected_machine();
331 m.set_link(Link::Unreachable("closed".into()), None, T0 + 1);
332 let fx = m.tick(Some(&refused()), T0 + 2);
333 assert_eq!(m.status().state, NeedsSignIn);
334 assert_eq!(kinds(&fx), ["changed", "notify"]);
335 }
336
337 #[test]
338 fn one_notification_per_needs_sign_in_episode() {
339 let mut m = connected_machine();
340 let fx = m.set_link(Link::SignInStale("refused".into()), None, T0 + 1);
341 assert_eq!(kinds(&fx), ["changed", "notify"]);
342 m.set_link(Link::Unreachable("x".into()), None, T0 + 2);
344 let fx = m.set_link(Link::SignInStale("refused".into()), None, T0 + 3);
345 assert_eq!(kinds(&fx), ["changed"]);
346 assert!(!kinds(&m.tick(None, T0 + 10 * PAUSE_NOTICE_MS)).contains(&"notify"));
347 let fx = m.set_link(Link::Connected, None, T0 + 20 * PAUSE_NOTICE_MS);
349 assert!(kinds(&fx).contains(&"retract"));
350 let fx = m.set_link(Link::NoSignIn, None, T0 + 21 * PAUSE_NOTICE_MS);
351 assert_eq!(kinds(&fx), ["changed", "notify"]);
352 }
353
354 #[test]
355 fn signing_out_ends_the_sign_in_episode_without_another_notification() {
356 let mut m = connected_machine();
357 m.set_link(Link::SignInStale("refused".into()), None, T0 + 1);
358 let fx = m.signed_out(T0 + 2);
359 assert_eq!(kinds(&fx), ["changed", "retract"]);
360 }
361
362 #[test]
363 fn the_pause_note_waits_two_minutes_and_goes_out_once() {
364 let mut m = connected_machine();
365 let start = T0 + 1_000;
366 m.set_link(Link::Unreachable("closed".into()), None, start);
367 assert!(!kinds(&m.tick(None, start + PAUSE_NOTICE_MS - 1)).contains(&"pause"));
368 let fx = m.tick(None, start + PAUSE_NOTICE_MS);
369 assert_eq!(fx, [Effect::PauseNote { since_ms: start, reason: "AgentMux can't reach MuxBus" }]);
370 assert!(m.tick(None, start + 5 * PAUSE_NOTICE_MS).is_empty(), "once per pause");
371 m.set_link(Link::SignInStale("refused".into()), None, start + 6 * PAUSE_NOTICE_MS);
373 assert!(!kinds(&m.tick(None, start + 9 * PAUSE_NOTICE_MS)).contains(&"pause"));
374 let fx = m.set_link(Link::Connected, None, start + 10 * PAUSE_NOTICE_MS);
375 assert!(fx.contains(&Effect::Resumed { at_ms: start + 10 * PAUSE_NOTICE_MS }));
376 }
377
378 #[test]
379 fn the_pause_reason_follows_the_state_at_note_time() {
380 let mut m = connected_machine();
381 m.set_link(Link::SignInStale("refused".into()), None, T0 + 1);
382 let fx = m.tick(None, T0 + 1 + PAUSE_NOTICE_MS);
383 assert!(fx.contains(&Effect::PauseNote { since_ms: T0 + 1, reason: "MuxBus needs a sign-in" }));
384 }
385
386 #[test]
387 fn a_blip_under_two_minutes_tells_the_agents_nothing() {
388 let mut m = connected_machine();
389 m.set_link(Link::Unreachable("closed".into()), None, T0 + 1);
390 m.tick(None, T0 + PAUSE_NOTICE_MS - 10);
391 let fx = m.set_link(Link::Connected, None, T0 + PAUSE_NOTICE_MS - 5);
392 assert_eq!(kinds(&fx), ["changed"], "no resume without a pause note");
393 assert!(m.tick(None, T0 + 10 * PAUSE_NOTICE_MS).is_empty());
394 assert_eq!(m.paused_since_ms(), None);
395 }
396
397 #[test]
398 fn a_pause_survives_a_sign_out_and_resumes_on_the_next_connect() {
399 let mut m = connected_machine();
400 m.set_link(Link::SignInStale("refused".into()), None, T0);
401 m.tick(None, T0 + PAUSE_NOTICE_MS);
402 m.signed_out(T0 + PAUSE_NOTICE_MS + 1);
403 assert!(m.tick(None, T0 + 4 * PAUSE_NOTICE_MS).is_empty(), "signed out: no further note");
404 let fx = m.saw_session(None, None, T0 + 5 * PAUSE_NOTICE_MS);
405 assert_eq!(m.status().state, Reconnecting, "signing in again is not a lost sign-in");
406 assert!(!kinds(&fx).contains(&"notify"));
407 let fx = m.set_link(Link::Connected, None, T0 + 5 * PAUSE_NOTICE_MS);
408 assert!(kinds(&fx).contains(&"resumed"));
409 }
410
411 #[test]
412 fn a_short_pause_ended_by_a_sign_out_is_not_dated_from_later() {
413 let mut m = connected_machine();
414 m.set_link(Link::Unreachable("closed".into()), None, T0);
415 m.signed_out(T0 + PAUSE_NOTICE_MS / 2);
416 assert_eq!(m.paused_since_ms(), None);
417 let later = T0 + 100 * PAUSE_NOTICE_MS;
418 m.saw_session(None, None, later);
419 assert!(!kinds(&m.tick(None, later + 1)).contains(&"pause"), "no note dated from the old outage");
420 let fx = m.tick(None, later + PAUSE_NOTICE_MS);
421 assert!(fx.contains(&Effect::PauseNote { since_ms: later, reason: pause_reason(Reconnecting) }));
422 }
423
424 #[test]
425 fn an_open_link_whose_pulls_fail_is_not_connected() {
426 let mut m = connected_machine();
427 m.pull_failed("a", "the relay answered 503 to a message fetch".into(), None, T0 + 1);
428 assert_eq!(m.status().state, Reconnecting);
429 assert_eq!(m.status().last_error.as_deref(), Some("the relay answered 503 to a message fetch"));
430 let fx = m.tick(None, T0 + 1 + PAUSE_NOTICE_MS);
431 assert!(kinds(&fx).contains(&"pause"), "the agents are told, as for any pause");
432 m.relay_answered("b", None, T0 + 2 * PAUSE_NOTICE_MS - 1);
433 assert_eq!(m.status().state, Reconnecting, "another agent's pull doesn't clear a's failure");
434 let fx = m.relay_answered("a", None, T0 + 2 * PAUSE_NOTICE_MS);
435 assert_eq!(m.status().state, Connected);
436 assert!(kinds(&fx).contains(&"resumed"));
437 m.set_link(Link::Unreachable("closed".into()), None, T0 + 3 * PAUSE_NOTICE_MS);
439 m.pull_failed("a", "x".into(), None, T0 + 3 * PAUSE_NOTICE_MS + 1);
440 assert_eq!(m.status().last_error.as_deref(), Some("closed"));
441 m.set_link(Link::Connected, None, T0 + 4 * PAUSE_NOTICE_MS);
443 assert_eq!(m.status().state, Reconnecting);
444 m.keep_pull_errors_of(&["b".to_string()]);
445 m.tick(None, T0 + 4 * PAUSE_NOTICE_MS + 1);
446 assert_eq!(m.status().state, Connected);
447 }
448
449 #[test]
450 fn a_fresh_sign_in_is_not_judged_by_the_refusal_it_replaces() {
451 let mut m = connected_machine();
452 m.set_link(Link::Unreachable("x".into()), Some(&refused()), T0 + 1);
453 assert_eq!(m.status().state, NeedsSignIn);
454 m.signed_out(T0 + 2);
455 let fx = m.fresh_sign_in(Some("a@b.c".into()), T0 + 3);
456 assert_eq!(m.status().state, Reconnecting);
457 assert!(!kinds(&fx).contains(&"notify"));
458 assert_eq!(m.tick(Some(&refused()), T0 + 4).len(), 0);
460 assert_eq!(m.status().state, Reconnecting);
461 m.set_link(Link::Connected, Some(&refused()), T0 + 5);
462 assert_eq!(m.status().state, Connected);
463 }
464
465 #[test]
466 fn an_unchanged_report_changes_nothing() {
467 let mut m = connected_machine();
468 assert!(m.set_link(Link::Connected, None, T0 + 1).is_empty());
469 assert!(m.saw_session(Some("a@b.c".into()), None, T0 + 2).is_empty());
470 }
471}