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