1use std::{
6 collections::{HashMap, VecDeque},
7 sync::{Arc, Mutex},
8 time::Duration,
9};
10
11use subc_protocol::{
12 error_codes,
13 session::{OperatorConfirmReply, OperatorConfirmRequest},
14 ErrorBody, Flags, FrameType, Principal, Priority,
15};
16use tokio::{
17 sync::Notify,
18 time::{timeout_at, Instant},
19};
20
21use crate::{
22 forwarding::{ModuleEndpointId, ModuleRouteKey, RouteBinding},
23 router::RouteCtx,
24 Frame,
25};
26
27pub trait OperatorProvider: Send + Sync + 'static {
31 fn prompt(
32 &self,
33 text: &str,
34 publish: Box<dyn FnOnce(Arc<dyn OperatorWithdraw>) + Send>,
35 ) -> ProviderResult;
36}
37
38pub trait OperatorWithdraw: Send + Sync + 'static {
40 fn withdraw(&self, reason: &str);
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ProviderResult {
45 Approved,
46 Declined,
47 Unavailable,
48 UnsupportedPlatform,
49 NoPresence,
50}
51
52#[derive(Debug, Clone, Copy)]
53pub struct OperatorLimits {
54 pub queue_wait: Duration,
55 pub prompt_timeout: Duration,
56 pub stuck_grace: Duration,
57}
58
59impl Default for OperatorLimits {
60 fn default() -> Self {
61 Self {
62 queue_wait: Duration::from_secs(150),
63 prompt_timeout: Duration::from_secs(120),
64 stuck_grace: Duration::from_secs(10),
65 }
66 }
67}
68
69impl OperatorLimits {
70 fn delivery_budget(self) -> Duration {
71 self.queue_wait + self.prompt_timeout + self.stuck_grace + Duration::from_secs(10)
72 }
73}
74
75struct OsProvider;
76
77#[cfg(any(target_os = "macos", windows))]
78struct OsWithdraw(Arc<dyn subc_presence::Withdraw>);
79#[cfg(any(target_os = "macos", windows))]
80impl OperatorWithdraw for OsWithdraw {
81 fn withdraw(&self, _: &str) {
82 self.0.withdraw();
83 }
84}
85
86impl OperatorProvider for OsProvider {
87 fn prompt(
88 &self,
89 text: &str,
90 publish: Box<dyn FnOnce(Arc<dyn OperatorWithdraw>) + Send>,
91 ) -> ProviderResult {
92 #[cfg(target_os = "linux")]
96 {
97 let _ = (text, publish);
98 ProviderResult::UnsupportedPlatform
99 }
100 #[cfg(any(target_os = "macos", windows))]
101 {
102 os_outcome(subc_presence::prompt(
103 text,
104 Box::new(move |handle| publish(Arc::new(OsWithdraw(handle)))),
105 ))
106 }
107 #[cfg(not(any(target_os = "macos", windows, target_os = "linux")))]
108 {
109 let _ = (text, publish);
110 ProviderResult::Unavailable
111 }
112 }
113}
114
115#[cfg(any(target_os = "macos", windows))]
120fn os_outcome(outcome: subc_presence::Outcome) -> ProviderResult {
121 match outcome {
122 subc_presence::Outcome::Approved => ProviderResult::Approved,
123 subc_presence::Outcome::Declined => ProviderResult::Declined,
124 subc_presence::Outcome::ProviderError => ProviderResult::Unavailable,
125 subc_presence::Outcome::NoPresence => ProviderResult::NoPresence,
126 subc_presence::Outcome::UnsupportedPlatform => ProviderResult::UnsupportedPlatform,
127 }
128}
129
130#[cfg(all(test, any(target_os = "macos", windows)))]
131mod os_outcome_tests {
132 use super::*;
133
134 #[test]
135 fn only_an_os_approval_becomes_approved() {
136 use subc_presence::Outcome as Os;
137 assert_eq!(os_outcome(Os::Approved), ProviderResult::Approved);
138 assert_eq!(os_outcome(Os::Declined), ProviderResult::Declined);
139 assert_eq!(os_outcome(Os::ProviderError), ProviderResult::Unavailable);
140 assert_eq!(os_outcome(Os::NoPresence), ProviderResult::NoPresence);
141 assert_eq!(
142 os_outcome(Os::UnsupportedPlatform),
143 ProviderResult::UnsupportedPlatform
144 );
145 }
146}
147
148#[derive(Clone, Copy, Debug)]
149pub(crate) struct Outcome {
150 pub code: &'static str,
151 pub reason: &'static str,
152}
153impl Outcome {
154 fn unavailable(reason: &'static str) -> Self {
155 Self {
156 code: error_codes::OPERATOR_PRESENCE_UNAVAILABLE,
157 reason,
158 }
159 }
160 fn declined(reason: &'static str) -> Self {
161 Self {
162 code: error_codes::OPERATOR_DECLINED,
163 reason,
164 }
165 }
166 pub(crate) fn refusal(code: &'static str) -> Self {
167 Self { code, reason: "" }
168 }
169 fn from_provider(result: ProviderResult) -> Self {
170 match result {
171 ProviderResult::Approved => Self {
172 code: "confirmed",
173 reason: "",
174 },
175 ProviderResult::Declined => Self::declined("person"),
176 ProviderResult::Unavailable => Self::unavailable("provider_error"),
177 ProviderResult::UnsupportedPlatform => Self::unavailable("unsupported_platform"),
178 ProviderResult::NoPresence => Self::unavailable("no_presence"),
179 }
180 }
181 pub(crate) fn frame(self, request: &Frame) -> Frame {
182 let (ty, body) = if self.code == "confirmed" {
183 (
184 FrameType::Response,
185 serde_json::to_vec(&OperatorConfirmReply::confirmed()).expect("serializable reply"),
186 )
187 } else {
188 let mut error = ErrorBody::new(self.code, "operator confirmation refused");
189 if !self.reason.is_empty() {
190 error = error.with_detail(serde_json::json!({"reason": self.reason}));
191 }
192 (
193 FrameType::Error,
194 serde_json::to_vec(&error).expect("serializable error"),
195 )
196 };
197 Frame::build_with_version(
198 request.header.ver,
199 ty,
200 Flags::new(false, Priority::Passive, false),
201 0,
202 0,
203 request.header.corr,
204 body,
205 )
206 .expect("bounded control reply")
207 }
208}
209
210pub(crate) fn audit(
211 module_id: &str,
212 summary: &str,
213 principal: &str,
214 outcome: Outcome,
215 wait: Duration,
216 prompt: Duration,
217 prompt_shown: bool,
218) {
219 tracing::info!(target: "subc_daemon::operator_confirm", module_id, summary = ?summary,
220 principal, outcome = outcome.code, reason = outcome.reason,
221 wait_ms = wait.as_millis() as u64, prompt_ms = prompt.as_millis() as u64,
222 prompt_shown, "operator_confirm_audit");
223}
224
225fn principal_label(principal: &Principal) -> String {
226 match principal {
227 Principal::Direct => "direct".into(),
228 Principal::Reserved { module_id } => format!("reserved:{module_id}"),
229 Principal::Unverified => String::new(),
230 }
231}
232
233fn valid_summary(summary: &str) -> bool {
234 let count = summary.chars().count();
235 (1..=200).contains(&count)
236 && !summary.starts_with(char::is_whitespace)
237 && !summary.ends_with(char::is_whitespace)
238 && !summary.chars().any(|c| {
239 c.is_control()
240 || matches!(c,
241 '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2028}' | '\u{2029}' |
242 '\u{2060}'..='\u{2069}' | '\u{feff}')
243 })
244}
245
246struct Request {
247 id: u64,
248 module_id: String,
249 summary: String,
250 principal: String,
251 text: String,
252 key: ModuleRouteKey,
253 ctx: RouteCtx,
254 frame: Frame,
255 arrived: Instant,
256 delivery_deadline: Instant,
257}
258struct Active {
259 request: Arc<Request>,
260 started: Option<Instant>,
261 handle: Option<Arc<dyn OperatorWithdraw>>,
262 committed: bool,
263 withdrawn: Option<(Instant, &'static str)>,
264 withdraw_started: bool,
265}
266struct State {
267 provider: Arc<dyn OperatorProvider>,
268 limits: OperatorLimits,
269 queue: VecDeque<Arc<Request>>,
270 modules: HashMap<String, u64>,
271 backoff: HashMap<(String, String), Instant>,
272 prompt: Option<Active>,
273 stuck: bool,
274 running: bool,
275 next_id: u64,
276 #[cfg(test)]
277 deliveries: usize,
278}
279
280pub(crate) struct OperatorConfirms {
281 state: Mutex<State>,
282 wake: Notify,
283}
284impl std::fmt::Debug for OperatorConfirms {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 f.debug_struct("OperatorConfirms").finish_non_exhaustive()
287 }
288}
289impl Default for OperatorConfirms {
290 fn default() -> Self {
291 Self {
292 state: Mutex::new(State {
293 provider: Arc::new(OsProvider),
294 limits: OperatorLimits::default(),
295 queue: VecDeque::new(),
296 modules: HashMap::new(),
297 backoff: HashMap::new(),
298 prompt: None,
299 stuck: false,
300 running: false,
301 next_id: 0,
302 #[cfg(test)]
303 deliveries: 0,
304 }),
305 wake: Notify::new(),
306 }
307 }
308}
309impl OperatorConfirms {
310 fn lock(&self) -> std::sync::MutexGuard<'_, State> {
311 self.state.lock().unwrap_or_else(|p| p.into_inner())
312 }
313 pub(crate) fn configure(&self, provider: Arc<dyn OperatorProvider>, limits: OperatorLimits) {
314 let mut state = self.lock();
315 assert!(
316 !state.running && state.modules.is_empty(),
317 "configure before serving"
318 );
319 state.provider = provider;
320 state.limits = limits;
321 }
322 pub(crate) fn configure_bootstrap(&self, config: &crate::bootstrap::BootstrapConfig) {
323 let provider: Arc<dyn OperatorProvider> = Arc::new(OsProvider);
324 #[cfg(feature = "test-support")]
325 let provider =
326 test_provider::select(provider, config, std::env::current_exe().ok().as_deref());
327 self.configure(
328 provider,
329 OperatorLimits {
330 queue_wait: config.operator_queue_wait,
331 prompt_timeout: config.operator_timeout,
332 stuck_grace: config.operator_stuck_grace,
333 },
334 );
335 }
336
337 pub(crate) fn admit(
340 self: &Arc<Self>,
341 ctx: &RouteCtx,
342 frame: Frame,
343 module_id: String,
344 nonce_proven: bool,
345 request: OperatorConfirmRequest,
346 binding: Option<&RouteBinding>,
347 ) -> Vec<Frame> {
348 let principal = binding
349 .map(|b| principal_label(&b.principal))
350 .unwrap_or_default();
351 let mut state = self.lock();
352 let now = Instant::now();
353 state.backoff.retain(|_, until| *until > now);
354 let refusal = if !nonce_proven
355 || binding.is_none_or(|b| matches!(b.principal, Principal::Unverified))
356 {
357 Some(Outcome::refusal(
358 error_codes::OPERATOR_REQUEST_NOT_PERMITTED,
359 ))
360 } else if !valid_summary(&request.summary) {
361 Some(Outcome::refusal(error_codes::OPERATOR_SUMMARY_INVALID))
362 } else if state
363 .backoff
364 .contains_key(&(module_id.clone(), principal.clone()))
365 {
366 Some(Outcome::declined("backoff"))
367 } else if state.stuck {
368 Some(Outcome::unavailable("provider_stuck"))
369 } else if state.modules.contains_key(&module_id) {
370 Some(Outcome::unavailable("module_limit"))
371 } else if state.queue.len() >= 4 {
372 Some(Outcome::unavailable("queue_full"))
373 } else {
374 None
375 };
376 if let Some(outcome) = refusal {
377 audit(
378 &module_id,
379 &request.summary,
380 &principal,
381 outcome,
382 Duration::ZERO,
383 Duration::ZERO,
384 false,
385 );
386 return vec![outcome.frame(&frame)];
387 }
388 let binding = binding.expect("permission checked");
389 let opener = match &binding.principal {
390 Principal::Direct => "a local program",
391 Principal::Reserved { module_id } => module_id,
392 Principal::Unverified => unreachable!(),
393 };
394 state.next_id += 1;
395 let pending = Arc::new(Request {
396 id: state.next_id,
397 text: format!(
398 "{module_id} asks: {} (requested by {opener})",
399 request.summary
400 ),
401 module_id,
402 summary: request.summary,
403 principal,
404 key: ModuleRouteKey {
405 endpoint: binding.module_endpoint,
406 channel: binding.module_channel,
407 },
408 ctx: ctx.clone(),
409 frame,
410 arrived: now,
411 delivery_deadline: now + state.limits.delivery_budget(),
412 });
413 state.modules.insert(pending.module_id.clone(), pending.id);
414 if state.prompt.is_none() && state.queue.is_empty() {
415 state.prompt = Some(Active {
416 request: pending,
417 started: None,
418 handle: None,
419 committed: false,
420 withdrawn: None,
421 withdraw_started: false,
422 });
423 } else {
424 state.queue.push_back(pending);
425 }
426 if !state.running {
427 state.running = true;
428 tokio::spawn(Arc::clone(self).run());
429 }
430 self.wake.notify_one();
431 Vec::new()
432 }
433
434 fn commit(
435 self: &Arc<Self>,
436 state: &mut State,
437 request: Arc<Request>,
438 outcome: Outcome,
439 started: Option<Instant>,
440 shown: bool,
441 ) {
442 let now = Instant::now();
443 let wait = started.unwrap_or(now).duration_since(request.arrived);
444 let prompt = started.map(|at| now.duration_since(at)).unwrap_or_default();
445 if outcome.reason == "person" {
446 state.backoff.insert(
447 (request.module_id.clone(), request.principal.clone()),
448 now + Duration::from_secs(30),
449 );
450 }
451 audit(
452 &request.module_id,
453 &request.summary,
454 &request.principal,
455 outcome,
456 wait,
457 prompt,
458 shown,
459 );
460 let this = Arc::clone(self);
461 #[cfg(test)]
462 {
463 state.deliveries += 1;
464 }
465 tokio::spawn(async move {
466 let answer = outcome.frame(&request.frame);
467 match timeout_at(request.delivery_deadline, request.ctx.egress.send(answer)).await {
468 Ok(Ok(())) => {}
469 result => {
470 tracing::warn!(target: "subc_daemon::operator_confirm", module_id = request.module_id, ?result, "operator confirm answer dropped")
471 }
472 }
473 let mut state = this.lock();
474 #[cfg(test)]
475 {
476 state.deliveries -= 1;
477 }
478 if state.modules.get(&request.module_id) == Some(&request.id) {
479 state.modules.remove(&request.module_id);
480 }
481 });
482 }
483
484 fn withdraw_matching(
485 self: &Arc<Self>,
486 predicate: impl Fn(&Request) -> bool,
487 reason: &'static str,
488 ) -> bool {
489 let mut state = self.lock();
490 let mut matched = false;
491 let mut index = 0;
492 while index < state.queue.len() {
493 if predicate(&state.queue[index]) {
494 let request = state.queue.remove(index).expect("queue index");
495 self.commit(&mut state, request, Outcome::declined(reason), None, false);
496 matched = true;
497 } else {
498 index += 1;
499 }
500 }
501 if let Some(active) = state
502 .prompt
503 .as_mut()
504 .filter(|a| !a.committed && predicate(&a.request))
505 {
506 active.committed = true;
507 active.withdrawn = Some((Instant::now(), reason));
508 let (request, started, shown) = (
509 Arc::clone(&active.request),
510 active.started,
511 active.handle.is_some(),
512 );
513 self.commit(
514 &mut state,
515 request,
516 Outcome::declined(reason),
517 started,
518 shown,
519 );
520 matched = true;
521 }
522 self.wake.notify_one();
523 matched
524 }
525 pub(crate) fn route_closed(self: &Arc<Self>, key: ModuleRouteKey) {
526 self.withdraw_matching(|r| r.key == key, "route_closed");
527 }
528 pub(crate) fn module_closed(self: &Arc<Self>, endpoint: ModuleEndpointId) {
529 self.withdraw_matching(|r| r.key.endpoint == endpoint, "module_closed");
530 }
531 pub(crate) fn cancel(self: &Arc<Self>, connection: crate::ConnectionId, corr: u64) -> bool {
532 self.withdraw_matching(
533 |r| r.ctx.connection_id == connection && r.frame.header.corr == corr,
534 "caller_cancelled",
535 )
536 }
537
538 fn publish(self: &Arc<Self>, id: u64, handle: Arc<dyn OperatorWithdraw>) {
539 let mut state = self.lock();
540 if let Some(active) = state.prompt.as_mut().filter(|a| a.request.id == id) {
541 active.handle = Some(Arc::clone(&handle));
542 if let Some((_, reason)) = active.withdrawn {
543 active.withdraw_started = true;
544 tokio::task::spawn_blocking(move || handle.withdraw(reason));
545 }
546 }
547 self.wake.notify_one();
548 }
549 fn returned(self: &Arc<Self>, id: u64, result: ProviderResult) {
550 let mut state = self.lock();
551 let Some(active) = state.prompt.take() else {
552 return;
553 };
554 assert_eq!(active.request.id, id);
555 if !active.withdraw_started {
558 if let (Some(handle), Some((_, reason))) = (active.handle.as_ref(), active.withdrawn) {
559 let handle = Arc::clone(handle);
560 tokio::task::spawn_blocking(move || handle.withdraw(reason));
561 }
562 }
563 if !active.committed {
564 self.commit(
565 &mut state,
566 active.request,
567 Outcome::from_provider(result),
568 active.started,
569 active.handle.is_some(),
570 );
571 } else {
572 tracing::debug!(target: "subc_daemon::operator_confirm", ?result, "late operator provider result discarded");
573 }
574 state.stuck = false;
575 self.wake.notify_one();
576 }
577
578 async fn run(self: Arc<Self>) {
579 loop {
580 let deadline = {
581 let mut state = self.lock();
582 let now = Instant::now();
583 let mut index = 0;
584 while index < state.queue.len() {
585 if state.queue[index].arrived + state.limits.queue_wait <= now {
586 let request = state.queue.remove(index).expect("queue index");
587 self.commit(
588 &mut state,
589 request,
590 Outcome::unavailable("queue_wait"),
591 None,
592 false,
593 );
594 } else {
595 index += 1;
596 }
597 }
598 let timeout = state.limits.prompt_timeout;
599 if let Some(active) = state
600 .prompt
601 .as_mut()
602 .filter(|a| !a.committed && a.started.is_some_and(|at| at + timeout <= now))
603 {
604 active.committed = true;
605 active.withdrawn = Some((now, "timeout"));
606 let (request, started, shown) = (
607 Arc::clone(&active.request),
608 active.started,
609 active.handle.is_some(),
610 );
611 self.commit(
612 &mut state,
613 request,
614 Outcome::unavailable("timeout"),
615 started,
616 shown,
617 );
618 }
619 let grace = state.limits.stuck_grace;
620 if !state.stuck
621 && state.prompt.as_ref().is_some_and(|a| {
622 a.started.is_some() && a.withdrawn.is_some_and(|(at, _)| at + grace <= now)
623 })
624 {
625 state.stuck = true;
626 tracing::error!(target: "subc_daemon::operator_confirm", "operator provider stuck after withdraw");
627 while let Some(request) = state.queue.pop_front() {
628 self.commit(
629 &mut state,
630 request,
631 Outcome::unavailable("provider_stuck"),
632 None,
633 false,
634 );
635 }
636 }
637 if let Some(active) = state
638 .prompt
639 .as_mut()
640 .filter(|a| !a.withdraw_started && a.withdrawn.is_some() && a.handle.is_some())
641 {
642 active.withdraw_started = true;
643 let handle = Arc::clone(active.handle.as_ref().expect("handle checked"));
644 let reason = active.withdrawn.expect("withdraw checked").1;
645 tokio::task::spawn_blocking(move || handle.withdraw(reason));
646 }
647 if state
649 .prompt
650 .as_ref()
651 .is_some_and(|a| a.started.is_none() && a.committed)
652 {
653 state.prompt = None;
654 }
655 if state.prompt.is_none() {
656 if let Some(request) = state.queue.pop_front() {
657 state.prompt = Some(Active {
658 request,
659 started: None,
660 handle: None,
661 committed: false,
662 withdrawn: None,
663 withdraw_started: false,
664 });
665 }
666 }
667 if let Some(active) = state.prompt.as_mut().filter(|a| a.started.is_none()) {
668 active.started = Some(now);
669 let request = Arc::clone(&active.request);
670 let provider = Arc::clone(&state.provider);
671 let this = Arc::clone(&self);
672 tokio::spawn(async move {
673 let publish_to = Arc::clone(&this);
674 let id = request.id;
675 let result = tokio::task::spawn_blocking(move || {
676 provider.prompt(
677 &request.text,
678 Box::new(move |handle| publish_to.publish(id, handle)),
679 )
680 })
681 .await
682 .unwrap_or(ProviderResult::Unavailable);
683 this.returned(id, result);
684 });
685 }
686 if state.prompt.is_none() && state.queue.is_empty() {
687 state.running = false;
688 return;
689 }
690 let mut deadline = state
691 .queue
692 .iter()
693 .map(|r| r.arrived + state.limits.queue_wait)
694 .min();
695 if let Some(active) = &state.prompt {
696 let next = if !active.committed {
697 active.started.map(|at| at + timeout)
698 } else if !state.stuck {
699 active.withdrawn.map(|(at, _)| at + grace)
700 } else {
701 None
702 };
703 if let Some(next) = next {
704 deadline = Some(deadline.map_or(next, |d| d.min(next)));
705 }
706 }
707 deadline
708 };
709 if let Some(at) = deadline {
710 tokio::select! { _ = self.wake.notified() => {}, _ = tokio::time::sleep_until(at) => {} }
711 } else {
712 self.wake.notified().await;
713 }
714 }
715 }
716}
717
718#[cfg(feature = "test-support")]
719mod test_provider;
720#[cfg(test)]
721mod tests;