Skip to main content

subc_daemon/
operator_confirm.rs

1//! Operator confirmation policy and its blocking provider boundary.
2//!
3//! The forwarding lock precedes the state lock. Providers and deliveries never
4//! acquire the forwarding lock, and neither can hold up the policy timers.
5use std::{
6    collections::{HashMap, VecDeque},
7    path::PathBuf,
8    sync::{Arc, Mutex},
9    time::Duration,
10};
11
12use subc_protocol::{
13    error_codes,
14    session::{OperatorConfirmReply, OperatorConfirmRequest},
15    ErrorBody, Flags, FrameType, Principal, Priority,
16};
17use tokio::{
18    sync::{oneshot, Notify},
19    time::{timeout_at, Instant},
20};
21
22use crate::{
23    caller_identity::CallerIdentity,
24    forwarding::{ModuleEndpointId, ModuleRouteKey, RouteBinding},
25    router::RouteCtx,
26    ConnectionId, Frame,
27};
28
29/// Person declines of privileged client requests that start a cooldown when
30/// they all fall within `DECLINE_WINDOW` of the newest one.
31const DECLINE_LIMIT: usize = 3;
32const DECLINE_WINDOW: Duration = Duration::from_secs(10 * 60);
33/// Measured from the decline that started the cooldown. Refusals during the
34/// cooldown are not declines by a person, so they never extend it.
35const DECLINE_COOLDOWN: Duration = Duration::from_secs(10 * 60);
36
37/// Monotonic time for the repeated-decline cooldown. Tests replace it so the
38/// ten-minute window and cooldown can be crossed without waiting.
39pub(crate) type CooldownClock = Arc<dyn Fn() -> Instant + Send + Sync>;
40
41/// Who repeated person declines of privileged requests are counted against.
42/// The executable path keeps a reconnecting program under its cooldown; a
43/// caller whose executable could not be resolved is counted per connection,
44/// because nothing else the daemon owns ties its connections together.
45#[derive(Clone, Debug, PartialEq, Eq, Hash)]
46pub(crate) enum DeclineKey {
47    Executable(PathBuf),
48    Connection(ConnectionId),
49}
50
51impl DeclineKey {
52    pub(crate) fn for_caller(caller: &CallerIdentity, connection: ConnectionId) -> Self {
53        match caller.executable() {
54            Some(executable) => Self::Executable(executable.to_path_buf()),
55            None => Self::Connection(connection),
56        }
57    }
58}
59
60impl std::fmt::Display for DeclineKey {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            Self::Executable(path) => write!(f, "{}", path.display()),
64            Self::Connection(connection) => write!(
65                f,
66                "an unidentified local process on connection {}",
67                connection.get()
68            ),
69        }
70    }
71}
72
73/// A blocking provider publishes a withdraw handle as soon as one exists, then
74/// returns only when its prompt has ended. Publishing is allowed to be delayed:
75/// the daemon's timeout also covers setup and a delayed handle is still withdrawn.
76pub trait OperatorProvider: Send + Sync + 'static {
77    fn prompt(
78        &self,
79        text: &str,
80        publish: Box<dyn FnOnce(Arc<dyn OperatorWithdraw>) + Send>,
81    ) -> ProviderResult;
82}
83
84/// Called on a blocking thread, never while a daemon lock is held.
85pub trait OperatorWithdraw: Send + Sync + 'static {
86    fn withdraw(&self, reason: &str);
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum ProviderResult {
91    Approved,
92    Declined,
93    Unavailable,
94    UnsupportedPlatform,
95    NoPresence,
96}
97
98#[derive(Debug, Clone, Copy)]
99pub struct OperatorLimits {
100    pub queue_wait: Duration,
101    pub prompt_timeout: Duration,
102    pub stuck_grace: Duration,
103}
104
105impl Default for OperatorLimits {
106    fn default() -> Self {
107        Self {
108            queue_wait: Duration::from_secs(150),
109            prompt_timeout: Duration::from_secs(120),
110            stuck_grace: Duration::from_secs(10),
111        }
112    }
113}
114
115impl OperatorLimits {
116    fn delivery_budget(self) -> Duration {
117        self.queue_wait + self.prompt_timeout + self.stuck_grace + Duration::from_secs(10)
118    }
119}
120
121struct OsProvider;
122
123#[cfg(any(target_os = "macos", windows))]
124struct OsWithdraw(Arc<dyn subc_presence::Withdraw>);
125#[cfg(any(target_os = "macos", windows))]
126impl OperatorWithdraw for OsWithdraw {
127    fn withdraw(&self, _: &str) {
128        self.0.withdraw();
129    }
130}
131
132impl OperatorProvider for OsProvider {
133    fn prompt(
134        &self,
135        text: &str,
136        publish: Box<dyn FnOnce(Arc<dyn OperatorWithdraw>) + Send>,
137    ) -> ProviderResult {
138        // On Linux the prompt comes from polkit, whose agents may show only the
139        // fixed message of an installed action file, not this request's summary.
140        // A person must see what they approve, so Linux refuses rather than prompts.
141        #[cfg(target_os = "linux")]
142        {
143            let _ = (text, publish);
144            ProviderResult::UnsupportedPlatform
145        }
146        #[cfg(any(target_os = "macos", windows))]
147        {
148            os_outcome(subc_presence::prompt(
149                text,
150                Box::new(move |handle| publish(Arc::new(OsWithdraw(handle)))),
151            ))
152        }
153        #[cfg(not(any(target_os = "macos", windows, target_os = "linux")))]
154        {
155            let _ = (text, publish);
156            ProviderResult::Unavailable
157        }
158    }
159}
160
161/// Convert the operating-system prompt's result into the daemon's provider
162/// result. Only an explicit approval may become `Approved`. The real prompt
163/// needs a person, so tests can't reach this mapping through it; keeping it a
164/// separate function lets a test check that no other result grants a write.
165#[cfg(any(target_os = "macos", windows))]
166fn os_outcome(outcome: subc_presence::Outcome) -> ProviderResult {
167    match outcome {
168        subc_presence::Outcome::Approved => ProviderResult::Approved,
169        subc_presence::Outcome::Declined => ProviderResult::Declined,
170        subc_presence::Outcome::ProviderError => ProviderResult::Unavailable,
171        subc_presence::Outcome::NoPresence => ProviderResult::NoPresence,
172        subc_presence::Outcome::UnsupportedPlatform => ProviderResult::UnsupportedPlatform,
173    }
174}
175
176#[cfg(all(test, any(target_os = "macos", windows)))]
177mod os_outcome_tests {
178    use super::*;
179
180    #[test]
181    fn only_an_os_approval_becomes_approved() {
182        use subc_presence::Outcome as Os;
183        assert_eq!(os_outcome(Os::Approved), ProviderResult::Approved);
184        assert_eq!(os_outcome(Os::Declined), ProviderResult::Declined);
185        assert_eq!(os_outcome(Os::ProviderError), ProviderResult::Unavailable);
186        assert_eq!(os_outcome(Os::NoPresence), ProviderResult::NoPresence);
187        assert_eq!(
188            os_outcome(Os::UnsupportedPlatform),
189            ProviderResult::UnsupportedPlatform
190        );
191    }
192}
193
194#[derive(Clone, Copy, Debug)]
195pub(crate) struct Outcome {
196    pub code: &'static str,
197    pub reason: &'static str,
198}
199impl Outcome {
200    pub(crate) fn unavailable(reason: &'static str) -> Self {
201        Self {
202            code: error_codes::OPERATOR_PRESENCE_UNAVAILABLE,
203            reason,
204        }
205    }
206    fn declined(reason: &'static str) -> Self {
207        Self {
208            code: error_codes::OPERATOR_DECLINED,
209            reason,
210        }
211    }
212    pub(crate) fn refusal(code: &'static str) -> Self {
213        Self { code, reason: "" }
214    }
215    fn from_provider(result: ProviderResult) -> Self {
216        match result {
217            ProviderResult::Approved => Self {
218                code: "confirmed",
219                reason: "",
220            },
221            ProviderResult::Declined => Self::declined("person"),
222            ProviderResult::Unavailable => Self::unavailable("provider_error"),
223            ProviderResult::UnsupportedPlatform => Self::unavailable("unsupported_platform"),
224            ProviderResult::NoPresence => Self::unavailable("no_presence"),
225        }
226    }
227    pub(crate) fn frame(self, request: &Frame) -> Frame {
228        let (ty, body) = if self.code == "confirmed" {
229            (
230                FrameType::Response,
231                serde_json::to_vec(&OperatorConfirmReply::confirmed()).expect("serializable reply"),
232            )
233        } else {
234            let mut error = ErrorBody::new(self.code, "operator confirmation refused");
235            if !self.reason.is_empty() {
236                error = error.with_detail(serde_json::json!({"reason": self.reason}));
237            }
238            (
239                FrameType::Error,
240                serde_json::to_vec(&error).expect("serializable error"),
241            )
242        };
243        Frame::build_with_version(
244            request.header.ver,
245            ty,
246            Flags::new(false, Priority::Passive, false),
247            0,
248            0,
249            request.header.corr,
250            body,
251        )
252        .expect("bounded control reply")
253    }
254}
255
256pub(crate) fn audit(
257    module_id: &str,
258    summary: &str,
259    principal: &str,
260    outcome: Outcome,
261    wait: Duration,
262    prompt: Duration,
263    prompt_shown: bool,
264) {
265    tracing::info!(target: "subc_daemon::operator_confirm", module_id, summary = ?summary,
266        principal, outcome = outcome.code, reason = outcome.reason,
267        wait_ms = wait.as_millis() as u64, prompt_ms = prompt.as_millis() as u64,
268        prompt_shown, "operator_confirm_audit");
269}
270
271fn principal_label(principal: &Principal) -> String {
272    match principal {
273        Principal::Direct => "direct".into(),
274        Principal::Reserved { module_id } => format!("reserved:{module_id}"),
275        Principal::Unverified => String::new(),
276    }
277}
278
279fn valid_summary(summary: &str) -> bool {
280    let count = summary.chars().count();
281    (1..=200).contains(&count)
282        && !summary.starts_with(char::is_whitespace)
283        && !summary.ends_with(char::is_whitespace)
284        && !summary.chars().any(|c| {
285            c.is_control()
286                || matches!(c,
287            '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2028}' | '\u{2029}' |
288            '\u{2060}'..='\u{2069}' | '\u{feff}')
289        })
290}
291
292enum RequestKind {
293    Module(ModuleRouteKey),
294    Privileged {
295        answer: Mutex<Option<oneshot::Sender<Outcome>>>,
296        key: DeclineKey,
297    },
298}
299
300struct Request {
301    id: u64,
302    module_id: String,
303    summary: String,
304    principal: String,
305    text: String,
306    kind: RequestKind,
307    ctx: RouteCtx,
308    frame: Frame,
309    arrived: Instant,
310    delivery_deadline: Instant,
311}
312struct Active {
313    request: Arc<Request>,
314    started: Option<Instant>,
315    handle: Option<Arc<dyn OperatorWithdraw>>,
316    committed: bool,
317    withdrawn: Option<(Instant, &'static str)>,
318    withdraw_started: bool,
319}
320struct State {
321    provider: Arc<dyn OperatorProvider>,
322    limits: OperatorLimits,
323    queue: VecDeque<Arc<Request>>,
324    modules: HashMap<String, u64>,
325    backoff: HashMap<(String, String), Instant>,
326    cooldown_clock: CooldownClock,
327    /// Recent person declines of privileged requests, oldest first.
328    declines: HashMap<DeclineKey, VecDeque<Instant>>,
329    /// When each privileged caller's cooldown ends.
330    cooldowns: HashMap<DeclineKey, Instant>,
331    prompt: Option<Active>,
332    stuck: bool,
333    running: bool,
334    next_id: u64,
335    #[cfg(test)]
336    deliveries: usize,
337}
338
339pub(crate) struct OperatorConfirms {
340    state: Mutex<State>,
341    wake: Notify,
342}
343
344struct WithdrawOnDrop {
345    confirms: Arc<OperatorConfirms>,
346    id: u64,
347}
348
349impl Drop for WithdrawOnDrop {
350    fn drop(&mut self) {
351        self.confirms
352            .withdraw_matching(|request| request.id == self.id, "caller_cancelled");
353    }
354}
355impl std::fmt::Debug for OperatorConfirms {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        f.debug_struct("OperatorConfirms").finish_non_exhaustive()
358    }
359}
360impl Default for OperatorConfirms {
361    fn default() -> Self {
362        Self {
363            state: Mutex::new(State {
364                provider: Arc::new(OsProvider),
365                limits: OperatorLimits::default(),
366                queue: VecDeque::new(),
367                modules: HashMap::new(),
368                backoff: HashMap::new(),
369                cooldown_clock: Arc::new(Instant::now),
370                declines: HashMap::new(),
371                cooldowns: HashMap::new(),
372                prompt: None,
373                stuck: false,
374                running: false,
375                next_id: 0,
376                #[cfg(test)]
377                deliveries: 0,
378            }),
379            wake: Notify::new(),
380        }
381    }
382}
383impl OperatorConfirms {
384    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
385        self.state.lock().unwrap_or_else(|p| p.into_inner())
386    }
387    pub(crate) fn configure(&self, provider: Arc<dyn OperatorProvider>, limits: OperatorLimits) {
388        let mut state = self.lock();
389        assert!(
390            !state.running && state.modules.is_empty(),
391            "configure before serving"
392        );
393        state.provider = provider;
394        state.limits = limits;
395    }
396    pub(crate) fn configure_bootstrap(&self, config: &crate::bootstrap::BootstrapConfig) {
397        let provider: Arc<dyn OperatorProvider> = Arc::new(OsProvider);
398        #[cfg(feature = "test-support")]
399        let provider =
400            test_provider::select(provider, config, std::env::current_exe().ok().as_deref());
401        self.configure(
402            provider,
403            OperatorLimits {
404                queue_wait: config.operator_queue_wait,
405                prompt_timeout: config.operator_timeout,
406                stuck_grace: config.operator_stuck_grace,
407            },
408        );
409    }
410
411    /// Called while the forwarding read lock still protects this binding. All
412    /// registration/nonce lookups must have finished before entering this method.
413    pub(crate) fn admit(
414        self: &Arc<Self>,
415        ctx: &RouteCtx,
416        frame: Frame,
417        module_id: String,
418        nonce_proven: bool,
419        request: OperatorConfirmRequest,
420        binding: Option<&RouteBinding>,
421    ) -> Vec<Frame> {
422        let principal = binding
423            .map(|b| principal_label(&b.principal))
424            .unwrap_or_default();
425        let mut state = self.lock();
426        let now = Instant::now();
427        state.backoff.retain(|_, until| *until > now);
428        let refusal = if !nonce_proven
429            || binding.is_none_or(|b| matches!(b.principal, Principal::Unverified))
430        {
431            Some(Outcome::refusal(
432                error_codes::OPERATOR_REQUEST_NOT_PERMITTED,
433            ))
434        } else if !valid_summary(&request.summary) {
435            Some(Outcome::refusal(error_codes::OPERATOR_SUMMARY_INVALID))
436        } else if state
437            .backoff
438            .contains_key(&(module_id.clone(), principal.clone()))
439        {
440            Some(Outcome::declined("backoff"))
441        } else if state.stuck {
442            Some(Outcome::unavailable("provider_stuck"))
443        } else if state.modules.contains_key(&module_id) {
444            Some(Outcome::unavailable("module_limit"))
445        } else if state.queue.len() >= 4 {
446            Some(Outcome::unavailable("queue_full"))
447        } else {
448            None
449        };
450        if let Some(outcome) = refusal {
451            audit(
452                &module_id,
453                &request.summary,
454                &principal,
455                outcome,
456                Duration::ZERO,
457                Duration::ZERO,
458                false,
459            );
460            return vec![outcome.frame(&frame)];
461        }
462        let binding = binding.expect("permission checked");
463        let opener = match &binding.principal {
464            Principal::Direct => "a local program",
465            Principal::Reserved { module_id } => module_id,
466            Principal::Unverified => unreachable!(),
467        };
468        state.next_id += 1;
469        let pending = Arc::new(Request {
470            id: state.next_id,
471            text: format!(
472                "approve a request from {module_id}: {} (requested by {opener})",
473                request.summary
474            ),
475            module_id,
476            summary: request.summary,
477            principal,
478            kind: RequestKind::Module(ModuleRouteKey {
479                endpoint: binding.module_endpoint,
480                channel: binding.module_channel,
481            }),
482            ctx: ctx.clone(),
483            frame,
484            arrived: now,
485            delivery_deadline: now + state.limits.delivery_budget(),
486        });
487        state.modules.insert(pending.module_id.clone(), pending.id);
488        self.enqueue(&mut state, pending);
489        Vec::new()
490    }
491
492    /// Client-control approval shares the module confirmation queue and timers,
493    /// but returns a decision to dispatch instead of sending an operator reply.
494    /// A caller in its repeated-decline cooldown is refused before anything else
495    /// is checked, without a prompt.
496    pub(crate) async fn approve_privileged(
497        self: &Arc<Self>,
498        ctx: &RouteCtx,
499        frame: &Frame,
500        operation: &str,
501        text: String,
502        caller: String,
503        key: DeclineKey,
504    ) -> Outcome {
505        let (answer, receive) = oneshot::channel();
506        let id = {
507            let mut state = self.lock();
508            let now = (state.cooldown_clock)();
509            state.cooldowns.retain(|_, until| *until > now);
510            state.declines.retain(|_, declines| {
511                declines.retain(|at| now.saturating_duration_since(*at) < DECLINE_WINDOW);
512                !declines.is_empty()
513            });
514            let refusal = if state.cooldowns.contains_key(&key) {
515                Some(Outcome::declined("backoff"))
516            } else if state.stuck {
517                Some(Outcome::unavailable("provider_stuck"))
518            } else if state.queue.len() >= 4 {
519                Some(Outcome::unavailable("queue_full"))
520            } else {
521                None
522            };
523            if let Some(outcome) = refusal {
524                audit(
525                    operation,
526                    &text,
527                    &caller,
528                    outcome,
529                    Duration::ZERO,
530                    Duration::ZERO,
531                    false,
532                );
533                return outcome;
534            }
535            state.next_id += 1;
536            let id = state.next_id;
537            let now = Instant::now();
538            let pending = Arc::new(Request {
539                id,
540                module_id: operation.into(),
541                summary: text.clone(),
542                principal: caller,
543                text,
544                kind: RequestKind::Privileged {
545                    answer: Mutex::new(Some(answer)),
546                    key,
547                },
548                ctx: ctx.clone(),
549                frame: frame.clone(),
550                arrived: now,
551                delivery_deadline: now + state.limits.delivery_budget(),
552            });
553            self.enqueue(&mut state, pending);
554            id
555        };
556        let _withdraw = WithdrawOnDrop {
557            confirms: Arc::clone(self),
558            id,
559        };
560        receive
561            .await
562            .unwrap_or_else(|_| Outcome::unavailable("provider_error"))
563    }
564
565    fn enqueue(self: &Arc<Self>, state: &mut State, pending: Arc<Request>) {
566        if state.prompt.is_none() && state.queue.is_empty() {
567            state.prompt = Some(Active {
568                request: pending,
569                started: None,
570                handle: None,
571                committed: false,
572                withdrawn: None,
573                withdraw_started: false,
574            });
575        } else {
576            state.queue.push_back(pending);
577        }
578        if !state.running {
579            state.running = true;
580            tokio::spawn(Arc::clone(self).run());
581        }
582        self.wake.notify_one();
583    }
584
585    fn commit(
586        self: &Arc<Self>,
587        state: &mut State,
588        request: Arc<Request>,
589        outcome: Outcome,
590        started: Option<Instant>,
591        shown: bool,
592    ) {
593        let now = Instant::now();
594        let wait = started.unwrap_or(now).duration_since(request.arrived);
595        let prompt = started.map(|at| now.duration_since(at)).unwrap_or_default();
596        if outcome.reason == "person" && matches!(request.kind, RequestKind::Module(_)) {
597            state.backoff.insert(
598                (request.module_id.clone(), request.principal.clone()),
599                now + Duration::from_secs(30),
600            );
601        }
602        audit(
603            &request.module_id,
604            &request.summary,
605            &request.principal,
606            outcome,
607            wait,
608            prompt,
609            shown,
610        );
611        if let RequestKind::Privileged { answer, key } = &request.kind {
612            if let Some(answer) = answer.lock().unwrap_or_else(|p| p.into_inner()).take() {
613                let _ = answer.send(outcome);
614            }
615            if outcome.reason == "person" {
616                self.count_person_decline(state, key);
617            }
618            return;
619        }
620        let this = Arc::clone(self);
621        #[cfg(test)]
622        {
623            state.deliveries += 1;
624        }
625        tokio::spawn(async move {
626            let answer = outcome.frame(&request.frame);
627            match timeout_at(request.delivery_deadline, request.ctx.egress.send(answer)).await {
628                Ok(Ok(())) => {}
629                result => {
630                    tracing::warn!(target: "subc_daemon::operator_confirm", module_id = request.module_id, ?result, "operator confirm answer dropped")
631                }
632            }
633            let mut state = this.lock();
634            #[cfg(test)]
635            {
636                state.deliveries -= 1;
637            }
638            if state.modules.get(&request.module_id) == Some(&request.id) {
639                state.modules.remove(&request.module_id);
640            }
641        });
642    }
643
644    /// Only a person's decline at the prompt reaches here: withdrawals, timeouts
645    /// and unavailability commit other reasons. The prompt that recorded the
646    /// limit-reaching decline has already ended, so starting the cooldown only
647    /// refuses this caller's requests still waiting in the queue.
648    fn count_person_decline(self: &Arc<Self>, state: &mut State, key: &DeclineKey) {
649        let now = (state.cooldown_clock)();
650        let declines = state.declines.entry(key.clone()).or_default();
651        declines.retain(|at| now.saturating_duration_since(*at) < DECLINE_WINDOW);
652        declines.push_back(now);
653        if declines.len() < DECLINE_LIMIT {
654            return;
655        }
656        // Counting restarts after the cooldown instead of carrying old declines.
657        state.declines.remove(key);
658        state.cooldowns.insert(key.clone(), now + DECLINE_COOLDOWN);
659        tracing::warn!(target: "subc_daemon::operator_confirm", caller = %key,
660            cooldown_secs = DECLINE_COOLDOWN.as_secs(),
661            "privileged requests refused without a prompt after repeated operator declines");
662        let mut index = 0;
663        while index < state.queue.len() {
664            if matches!(&state.queue[index].kind,
665                RequestKind::Privileged { key: queued, .. } if queued == key)
666            {
667                let request = state.queue.remove(index).expect("queue index");
668                self.commit(state, request, Outcome::declined("backoff"), None, false);
669            } else {
670                index += 1;
671            }
672        }
673    }
674
675    fn withdraw_matching(
676        self: &Arc<Self>,
677        predicate: impl Fn(&Request) -> bool,
678        reason: &'static str,
679    ) -> bool {
680        let mut state = self.lock();
681        let mut matched = false;
682        let mut index = 0;
683        while index < state.queue.len() {
684            if predicate(&state.queue[index]) {
685                let request = state.queue.remove(index).expect("queue index");
686                self.commit(&mut state, request, Outcome::declined(reason), None, false);
687                matched = true;
688            } else {
689                index += 1;
690            }
691        }
692        if let Some(active) = state
693            .prompt
694            .as_mut()
695            .filter(|a| !a.committed && predicate(&a.request))
696        {
697            active.committed = true;
698            active.withdrawn = Some((Instant::now(), reason));
699            let (request, started, shown) = (
700                Arc::clone(&active.request),
701                active.started,
702                active.handle.is_some(),
703            );
704            self.commit(
705                &mut state,
706                request,
707                Outcome::declined(reason),
708                started,
709                shown,
710            );
711            matched = true;
712        }
713        self.wake.notify_one();
714        matched
715    }
716    pub(crate) fn route_closed(self: &Arc<Self>, key: ModuleRouteKey) {
717        self.withdraw_matching(
718            |r| matches!(r.kind, RequestKind::Module(k) if k == key),
719            "route_closed",
720        );
721    }
722    pub(crate) fn module_closed(self: &Arc<Self>, endpoint: ModuleEndpointId) {
723        self.withdraw_matching(
724            |r| matches!(r.kind, RequestKind::Module(k) if k.endpoint == endpoint),
725            "module_closed",
726        );
727    }
728    pub(crate) fn connection_closed(self: &Arc<Self>, connection: crate::ConnectionId) {
729        self.withdraw_matching(
730            |r| {
731                matches!(r.kind, RequestKind::Privileged { .. })
732                    && r.ctx.connection_id == connection
733            },
734            "caller_cancelled",
735        );
736    }
737    pub(crate) fn cancel(self: &Arc<Self>, connection: crate::ConnectionId, corr: u64) -> bool {
738        self.withdraw_matching(
739            |r| r.ctx.connection_id == connection && r.frame.header.corr == corr,
740            "caller_cancelled",
741        )
742    }
743
744    fn publish(self: &Arc<Self>, id: u64, handle: Arc<dyn OperatorWithdraw>) {
745        let mut state = self.lock();
746        if let Some(active) = state.prompt.as_mut().filter(|a| a.request.id == id) {
747            active.handle = Some(Arc::clone(&handle));
748            if let Some((_, reason)) = active.withdrawn {
749                active.withdraw_started = true;
750                tokio::task::spawn_blocking(move || handle.withdraw(reason));
751            }
752        }
753        self.wake.notify_one();
754    }
755    fn returned(self: &Arc<Self>, id: u64, result: ProviderResult) {
756        let mut state = self.lock();
757        let Some(active) = state.prompt.take() else {
758            return;
759        };
760        assert_eq!(active.request.id, id);
761        // A fast late return can beat the prompt task's wake-up. The decision
762        // still requires a withdraw, even though the result is discarded.
763        if !active.withdraw_started {
764            if let (Some(handle), Some((_, reason))) = (active.handle.as_ref(), active.withdrawn) {
765                let handle = Arc::clone(handle);
766                tokio::task::spawn_blocking(move || handle.withdraw(reason));
767            }
768        }
769        if !active.committed {
770            self.commit(
771                &mut state,
772                active.request,
773                Outcome::from_provider(result),
774                active.started,
775                active.handle.is_some(),
776            );
777        } else {
778            tracing::debug!(target: "subc_daemon::operator_confirm", ?result, "late operator provider result discarded");
779        }
780        state.stuck = false;
781        self.wake.notify_one();
782    }
783
784    async fn run(self: Arc<Self>) {
785        loop {
786            let deadline = {
787                let mut state = self.lock();
788                let now = Instant::now();
789                let mut index = 0;
790                while index < state.queue.len() {
791                    if state.queue[index].arrived + state.limits.queue_wait <= now {
792                        let request = state.queue.remove(index).expect("queue index");
793                        self.commit(
794                            &mut state,
795                            request,
796                            Outcome::unavailable("queue_wait"),
797                            None,
798                            false,
799                        );
800                    } else {
801                        index += 1;
802                    }
803                }
804                let timeout = state.limits.prompt_timeout;
805                if let Some(active) = state
806                    .prompt
807                    .as_mut()
808                    .filter(|a| !a.committed && a.started.is_some_and(|at| at + timeout <= now))
809                {
810                    active.committed = true;
811                    active.withdrawn = Some((now, "timeout"));
812                    let (request, started, shown) = (
813                        Arc::clone(&active.request),
814                        active.started,
815                        active.handle.is_some(),
816                    );
817                    self.commit(
818                        &mut state,
819                        request,
820                        Outcome::unavailable("timeout"),
821                        started,
822                        shown,
823                    );
824                }
825                let grace = state.limits.stuck_grace;
826                if !state.stuck
827                    && state.prompt.as_ref().is_some_and(|a| {
828                        a.started.is_some() && a.withdrawn.is_some_and(|(at, _)| at + grace <= now)
829                    })
830                {
831                    state.stuck = true;
832                    tracing::error!(target: "subc_daemon::operator_confirm", "operator provider stuck after withdraw");
833                    while let Some(request) = state.queue.pop_front() {
834                        self.commit(
835                            &mut state,
836                            request,
837                            Outcome::unavailable("provider_stuck"),
838                            None,
839                            false,
840                        );
841                    }
842                }
843                if let Some(active) = state
844                    .prompt
845                    .as_mut()
846                    .filter(|a| !a.withdraw_started && a.withdrawn.is_some() && a.handle.is_some())
847                {
848                    active.withdraw_started = true;
849                    let handle = Arc::clone(active.handle.as_ref().expect("handle checked"));
850                    let reason = active.withdrawn.expect("withdraw checked").1;
851                    tokio::task::spawn_blocking(move || handle.withdraw(reason));
852                }
853                // A reserved prompt cancelled before setup needs no provider call.
854                if state
855                    .prompt
856                    .as_ref()
857                    .is_some_and(|a| a.started.is_none() && a.committed)
858                {
859                    state.prompt = None;
860                }
861                if state.prompt.is_none() {
862                    if let Some(request) = state.queue.pop_front() {
863                        state.prompt = Some(Active {
864                            request,
865                            started: None,
866                            handle: None,
867                            committed: false,
868                            withdrawn: None,
869                            withdraw_started: false,
870                        });
871                    }
872                }
873                if let Some(active) = state.prompt.as_mut().filter(|a| a.started.is_none()) {
874                    active.started = Some(now);
875                    let request = Arc::clone(&active.request);
876                    let provider = Arc::clone(&state.provider);
877                    let this = Arc::clone(&self);
878                    tokio::spawn(async move {
879                        let publish_to = Arc::clone(&this);
880                        let id = request.id;
881                        let result = tokio::task::spawn_blocking(move || {
882                            provider.prompt(
883                                &request.text,
884                                Box::new(move |handle| publish_to.publish(id, handle)),
885                            )
886                        })
887                        .await
888                        .unwrap_or(ProviderResult::Unavailable);
889                        this.returned(id, result);
890                    });
891                }
892                if state.prompt.is_none() && state.queue.is_empty() {
893                    state.running = false;
894                    return;
895                }
896                let mut deadline = state
897                    .queue
898                    .iter()
899                    .map(|r| r.arrived + state.limits.queue_wait)
900                    .min();
901                if let Some(active) = &state.prompt {
902                    let next = if !active.committed {
903                        active.started.map(|at| at + timeout)
904                    } else if !state.stuck {
905                        active.withdrawn.map(|(at, _)| at + grace)
906                    } else {
907                        None
908                    };
909                    if let Some(next) = next {
910                        deadline = Some(deadline.map_or(next, |d| d.min(next)));
911                    }
912                }
913                deadline
914            };
915            if let Some(at) = deadline {
916                tokio::select! { _ = self.wake.notified() => {}, _ = tokio::time::sleep_until(at) => {} }
917            } else {
918                self.wake.notified().await;
919            }
920        }
921    }
922}
923
924#[cfg(feature = "test-support")]
925mod test_provider;
926#[cfg(test)]
927mod tests;