Skip to main content

mj_controller/
daemon.rs

1//! Persistent per-user controller daemon and its authenticated local protocol.
2
3mod session_move;
4use crate::controller::move_session::{
5    MoveMutationGuard, MoveOutcome, MovePreparation, MoveSelection, MoveSessionRequest,
6};
7pub use mj_client::daemon::*;
8
9use std::collections::{BTreeMap, BTreeSet, VecDeque};
10use std::fs::{self, OpenOptions};
11use std::io::Write;
12use std::net::{IpAddr, Ipv4Addr, SocketAddr};
13use std::path::Path;
14use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
15use std::sync::{Arc, Mutex, PoisonError};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18use crate::database::StoreSchemaMismatch;
19use crate::recovery_gate::RecoveryObserver;
20use crate::targets::{
21    CancellableProcessExecutor, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor,
22    ProvisionStage, ProvisionStageGuard,
23};
24use anyhow::{Context, Result, anyhow, bail, ensure};
25use mj_core::config::Config;
26use mj_core::relay::RelayCommand;
27use mj_core::state::{RecoveryObservation, SessionRecord, SessionState};
28use mj_core::subagent::SubagentRecord;
29
30use crate::controller::{
31    BranchDisposition, Controller, ControllerStoreGuard, SessionLaunchOptions, SessionResumeOptions,
32};
33use crate::review_host::TurnReviewHost;
34use crate::session_manager::{
35    ManagedSessionView, RemoteSessionPublisher, RemoteSessionRequest, SessionManagerChannels,
36    SessionManagerControl, ViewError, new_command_id, spawn_remote_session_manager,
37    spawn_session_manager,
38};
39#[cfg(test)]
40use crate::session_manager::{RelaySessionTarget, RemoteSessionRequests, SessionManagerShutdown};
41use crate::worker_upgrade::{WorkerUpgradeObservation, WorkerUpgradeObserver};
42use mj_core::workspace::WorkspaceRecord;
43use tokio::net::{TcpListener, TcpStream};
44use tokio_util::sync::CancellationToken;
45
46use crate::pollers::{
47    dashboard_worker_targets, dashboard_worker_targets_excluding, interrupted_close_session_ids,
48    reserve_recovery_or_cancel, spawn_image_refresher, spawn_interrupted_close_recovery,
49};
50
51// Move preparation now reports whether source state must be recovered without its harness.
52
53/// How long the epilogue is given before the process leaves anyway.
54///
55/// Every daemon exit -- stop, SIGTERM, idle, a store that moved underneath it
56/// -- unwinds through the same epilogue, and every step of it is bounded in
57/// practice. This makes "the daemon did not stop" impossible rather than
58/// unlikely, and it must stay well inside [`STOP_TIMEOUT`] so a client waiting
59/// on a stop sees the exit rather than its own deadline.
60const SHUTDOWN_FORCE_EXIT_TIMEOUT: Duration = Duration::from_secs(10);
61
62/// How long force destruction waits for a cancelled lifecycle to actually
63/// stop before refusing to destroy under it. Cancellation kills the
64/// operation's child process groups and unwinds its persistence, which is
65/// fast in practice; an operation that outlives this bound is wedged in a
66/// way destruction must not paper over.
67const FORCE_DESTROY_PREEMPT_TIMEOUT: Duration = Duration::from_secs(8);
68
69/// Cancellation and committing a newly started session are one atomic decision.
70#[derive(Clone, Default)]
71pub struct CreateSessionControl {
72    state: Arc<AtomicU8>,
73    pub cancelled: Arc<AtomicBool>,
74}
75
76impl CreateSessionControl {
77    pub fn request_cancel(&self) -> bool {
78        let accepted = self
79            .state
80            .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
81            .is_ok();
82        if accepted {
83            self.cancelled.store(true, Ordering::Release);
84        }
85        accepted
86    }
87
88    pub fn grant_commit(&self) -> bool {
89        self.state
90            .compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire)
91            .is_ok()
92    }
93
94    fn is_cancellable(&self) -> bool {
95        self.state.load(Ordering::Acquire) == 0
96    }
97}
98
99#[derive(Debug, Clone)]
100struct Attachment {
101    pid: u32,
102}
103
104pub struct RuntimeState {
105    attachments: Mutex<BTreeMap<String, Attachment>>,
106    phone_status: Mutex<WebViewerStatus>,
107    pub web_viewer: crate::web_viewer::ViewerControl,
108    ever_attached: AtomicBool,
109    sessions: Mutex<BTreeMap<String, RuntimeSessionView>>,
110    revisions: RuntimeRevisions,
111    workspaces_tx: tokio::sync::watch::Sender<Vec<WorkspaceRecord>>,
112    session_manager: SessionManagerControl,
113    lifecycle: Mutex<BTreeMap<String, ActiveLifecycle>>,
114    close_requested: Mutex<BTreeSet<String>>,
115    controller: Mutex<Controller>,
116    controller_loader: fn() -> Result<Controller>,
117    config_mutation: tokio::sync::Mutex<()>,
118    recovery_observer: RecoveryObserver,
119    worker_upgrade_observer: WorkerUpgradeObserver,
120    /// Recent background notices, newest last, with the id of the next one.
121    /// Bounded: a surface that never attaches must not make this grow.
122    notices: Mutex<VecDeque<RuntimeNotice>>,
123    next_notice_id: AtomicU64,
124    /// What `[review]` last said, republished by the target refresher.
125    review_config: Arc<Mutex<mj_core::config::ReviewConfig>>,
126    /// Turn review runs here, in the process that owns every session, so a
127    /// review happens whether the terminal, the phone, or nobody is attached.
128    review_host: TurnReviewHost,
129    /// Publishes checkpointed sessions into the user's SessionWiki index.
130    wiki: crate::sessionwiki::WikiIndexer,
131}
132
133/// One monotonic cursor shared by daemon snapshots and their wake-up feed.
134///
135/// Allocations can come from independent UI and daemon tasks. Publishing an
136/// older allocation after a newer one must not move the watch channel
137/// backwards, so publication compares against the last visible cursor.
138#[derive(Clone)]
139struct RuntimeRevisions {
140    allocated: Arc<std::sync::atomic::AtomicU64>,
141    published: tokio::sync::watch::Sender<u64>,
142}
143
144impl RuntimeRevisions {
145    fn new(initial: u64) -> Self {
146        let (published, _) = tokio::sync::watch::channel(initial);
147        Self {
148            allocated: Arc::new(std::sync::atomic::AtomicU64::new(initial)),
149            published,
150        }
151    }
152
153    fn allocate(&self) -> u64 {
154        self.allocated.fetch_add(1, Ordering::AcqRel) + 1
155    }
156
157    fn publish(&self) -> u64 {
158        let revision = self.allocate();
159        self.publish_allocated(revision);
160        revision
161    }
162
163    fn publish_allocated(&self, revision: u64) {
164        self.published.send_if_modified(|visible| {
165            if revision > *visible {
166                *visible = revision;
167                true
168            } else {
169                false
170            }
171        });
172    }
173
174    fn notifier(&self) -> Arc<dyn Fn() + Send + Sync> {
175        let revisions = self.clone();
176        Arc::new(move || {
177            revisions.publish();
178        })
179    }
180
181    fn subscribe(&self) -> tokio::sync::watch::Receiver<u64> {
182        self.published.subscribe()
183    }
184
185    fn current(&self) -> u64 {
186        self.allocated.load(Ordering::Acquire)
187    }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191enum LifecycleKind {
192    Create,
193    Close,
194    Resume,
195    Move,
196    ForceStop,
197    DestroyStopped,
198    /// The archive job's destruction: the same teardown as `DestroyStopped`
199    /// with the session's git branch kept. Surfaces see it as a destroy.
200    ArchiveStopped,
201    ForceDestroy,
202    Cleanup,
203}
204
205/// Whether a lifecycle has exclusive ownership of the worker target, so the
206/// session manager must stop polling it. A graceful close needs the manager's
207/// relay lease through checkpointing and sealing; once the durable state says
208/// `Destroying`, that lease has been released and target teardown is exclusive.
209fn lifecycle_owns_worker_target(kind: LifecycleKind, state: Option<SessionState>) -> bool {
210    match kind {
211        LifecycleKind::Close => state == Some(SessionState::Destroying),
212        LifecycleKind::Move => !matches!(
213            state,
214            Some(
215                SessionState::Running
216                    | SessionState::Disconnected
217                    | SessionState::Checkpointing
218                    | SessionState::Closing
219            )
220        ),
221        _ => true,
222    }
223}
224
225/// Whether a running lifecycle can still be cancelled. A graceful close has a
226/// point of no return: once the durable state says `Destroying`, the verified
227/// checkpoint is sealed and the record has already committed to losing its
228/// target, so stopping the teardown only strands the target. Every other
229/// lifecycle stays cancellable while it runs.
230fn lifecycle_cancellable(kind: LifecycleKind, state: Option<SessionState>) -> bool {
231    !(kind == LifecycleKind::Close && state == Some(SessionState::Destroying))
232}
233
234/// How a stop request has to be carried out, given the durable record.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236enum CloseRoute {
237    /// Run the graceful close from the start.
238    Graceful,
239    /// A previous close stopped partway; finish it from its checkpoint.
240    RecoverInterrupted,
241    /// Already stopped, but the target still has to be removed.
242    DeferredCleanup,
243    /// Already stopped with nothing left to do.
244    Done,
245}
246
247/// A record mid-close with a live target cannot be closed again from the start:
248/// its worker socket is gone, so a fresh checkpoint attempt only fails on
249/// connect. Recovery finishes it from the checkpoint the first close verified.
250fn close_route(session: Option<&SessionRecord>) -> CloseRoute {
251    let Some(session) = session else {
252        return CloseRoute::Graceful;
253    };
254    if crate::pollers::is_interrupted_close(session) {
255        CloseRoute::RecoverInterrupted
256    } else if session.state == SessionState::Stopped {
257        if session.target.is_some() {
258            CloseRoute::DeferredCleanup
259        } else {
260            CloseRoute::Done
261        }
262    } else {
263        CloseRoute::Graceful
264    }
265}
266
267/// The durable state of one record as the locked controller holds it.
268fn durable_session_state(controller: &Controller, session_id: &str) -> Option<SessionState> {
269    controller
270        .state
271        .sessions
272        .get(session_id)
273        .map(|session| session.state)
274}
275
276struct ActiveLifecycle {
277    operation_id: String,
278    create_control: Option<CreateSessionControl>,
279    kind: LifecycleKind,
280    cancelled: Arc<AtomicBool>,
281    started_at_epoch_seconds: u64,
282    active_stages: BTreeMap<ProvisionStage, (usize, u64)>,
283    /// The workspace a resume is claiming before its durable record changes.
284    /// Workspace deletion consults this so it cannot race the claim.
285    resume_workspace_id: Option<String>,
286    resume_destination: Option<(String, String)>,
287    notice: Option<String>,
288    request_key: Option<String>,
289    _move_guard: Option<MoveMutationGuard>,
290    move_source_closed: bool,
291    result:
292        tokio::sync::watch::Receiver<Option<std::result::Result<DaemonLifecycleResult, String>>>,
293}
294
295impl ActiveLifecycle {
296    fn is_visible(&self) -> bool {
297        let result = self.result.borrow();
298        result.is_none()
299            || matches!(
300                result.as_ref(),
301                Some(Ok(DaemonLifecycleResult::DeferredCleanup))
302            )
303    }
304
305    fn request_cancel(&self) -> bool {
306        if let Some(control) = &self.create_control {
307            control.request_cancel()
308        } else {
309            !self.cancelled.swap(true, Ordering::AcqRel)
310        }
311    }
312
313    fn is_cancellable(&self) -> bool {
314        self.result.borrow().is_none()
315            && self.create_control.as_ref().map_or_else(
316                || !self.cancelled.load(Ordering::Acquire),
317                CreateSessionControl::is_cancellable,
318            )
319    }
320}
321
322#[derive(Debug, Clone)]
323enum DaemonLifecycleResult {
324    Done,
325    DeferredCleanup,
326    Move(MoveOutcome),
327}
328
329impl From<LifecycleKind> for RuntimeLifecycleKind {
330    fn from(kind: LifecycleKind) -> Self {
331        match kind {
332            LifecycleKind::Create => Self::Create,
333            LifecycleKind::Close => Self::Close,
334            LifecycleKind::Resume => Self::Resume,
335            LifecycleKind::Move => Self::Move,
336            LifecycleKind::ForceStop => Self::ForceStop,
337            LifecycleKind::DestroyStopped | LifecycleKind::ArchiveStopped => Self::DestroyStopped,
338            LifecycleKind::ForceDestroy => Self::ForceDestroy,
339            LifecycleKind::Cleanup => Self::Cleanup,
340        }
341    }
342}
343
344mod close;
345mod create;
346mod lifecycle;
347mod resume;
348mod snapshot;
349mod state;
350mod support;
351mod views;
352use support::*;
353mod process;
354pub use process::*;
355mod serve;
356use serve::*;
357mod actions;
358use actions::*;
359mod guards;
360pub(crate) use guards::*;
361
362#[cfg(test)]
363mod tests;