Skip to main content

onlyne_client/session/dispatch/
env.rs

1use super::state::DispatchInner;
2use super::*;
3
4/// Refuse to open a session when the role's drive cannot run under this
5/// machine's placement.
6///
7/// The one rule is `onlyne_config::validate_drive_placement`: `acp` pairs only
8/// with `headless`, because stdio carries the ACP channel and cannot also be a
9/// pane's terminal. It is checked in the config crate, where both halves of
10/// what `backend` used to be are defined, and again here, where the two of them
11/// meet for the first time — the drive arrives with `welcome` from the spec and
12/// the placement from the machine.
13///
14/// A refusal is the delivery's, not a retry's: nothing this client can do makes
15/// the pair work, so the row is refused with the sentence and an operator reads
16/// the fix in the ledger. Running anyway — a pane printing protocol frames, or
17/// an ACP child with no pane it could ever have — is the silent drift the split
18/// exists to remove.
19pub(super) fn reject_unpaired_runtime(inner: &DispatchInner) -> Result<()> {
20    if let Some(refusal) = &inner.runtime_refusal {
21        return Err(anyhow!("{refusal}"));
22    }
23    let (Some(drive), Some(placement)) = (inner.drive, inner.placement) else {
24        // No role slice yet, or a test that installed no machine fact: nothing
25        // has been declared to be inconsistent.
26        return Ok(());
27    };
28    let Some(named) = placement.named() else {
29        // The in-process test runtime owns no place on the machine.
30        return Ok(());
31    };
32    onlyne_config::validate_drive_placement(drive, named).map_err(|message| anyhow!("{message}"))
33}
34
35/// The socket a session spawned in `workspace` dials.
36///
37/// `dispatch` passes this to [`session_env`] and the same tree lands in
38/// `SpawnSpec.cwd`, so one resolve answers both halves of the spawn, and a
39/// workspace past the unix bound yields the short path the client bound.
40pub(super) fn served_socket(workspace: &Path) -> PathBuf {
41    RoleWorkspace::resolve(workspace).socket_path()
42}
43
44/// The environment one spawned session process carries.
45///
46/// The three `ONLYNE_` identity variables are what the plugin mounts with. The
47/// obligation a session owes is not among them: the guard is this client's own
48/// (`guards.rs`), read off the role's `allowed_targets`, so there is no policy
49/// for a session process to carry and no variable for it to read.
50///
51/// `ONLYNE_CLUSTER` names the server's topology and is the address a host
52/// backend groups sessions under. No welcome yet means no variable, and a pane
53/// host then keeps its own default-labelled tree.
54///
55/// `ONLYNE_SOCKET` is the path the client is serving: the same accessor the
56/// daemon bound, so a short endpoint reaches the session as the served path and
57/// the plugin needs no guess of its own. A hand-started pi keeps its own
58/// resolution as the fallback, which is the reason an empty path injects no key
59/// at all.
60pub(super) fn session_env(
61    role: &str,
62    session_id: &str,
63    task_id: &str,
64    topology: &str,
65    adapter_socket: &Path,
66) -> BTreeMap<String, String> {
67    let mut env = BTreeMap::new();
68    env.insert("ONLYNE_SESSION_ID".into(), session_id.to_string());
69    env.insert("ONLYNE_TASK_ID".into(), task_id.to_string());
70    env.insert("ONLYNE_ROLE".into(), role.to_string());
71    if !adapter_socket.as_os_str().is_empty() {
72        env.insert(
73            "ONLYNE_SOCKET".into(),
74            adapter_socket.to_string_lossy().into_owned(),
75        );
76    }
77    if !topology.is_empty() {
78        env.insert("ONLYNE_CLUSTER".into(), topology.to_string());
79    }
80    env
81}
82
83pub fn missing_capability(capabilities: &[Capability], capability: Capability) -> bool {
84    !capabilities.contains(&capability)
85}
86
87pub fn plugin_gap(capabilities: &[Capability]) -> Vec<onlyne_adapter::HostGap> {
88    onlyne_adapter::degrade_for(
89        &[Capability::Recycle, Capability::Report, Capability::Inject]
90            .iter()
91            .copied()
92            .filter(|cap| missing_capability(capabilities, *cap))
93            .collect::<Vec<_>>(),
94    )
95}
96
97/// Agent name this binary reports during the handshake.
98pub(super) const AGENT: &str = "onlyne-client";
99/// Wait bound for one request round trip.
100pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
101/// Server heartbeat interval from the observation rules of §4.
102pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
103
104/// How many heartbeat intervals a live connection may go quiet before the
105/// reconnect sweep reads the silence as an agent that stopped.
106///
107/// The protocol already answers this question once: `heartbeat_timeout_ms`
108/// (`onlyne_config::DEFAULT_HEARTBEAT_TIMEOUT_MS`) is the presence liveness
109/// timeout, and it is exactly three of these intervals — the plugin's own
110/// comment on its cadence names it as the pair. Taking the margin from that
111/// number keeps one answer in the tree instead of inventing a second threshold
112/// beside `[client] reconnect_grace_secs`, and it is a margin over the cadence
113/// rather than a fixed duration, so a plugin configured to beat slower than the
114/// default is not swept for keeping its own word.
115pub const HEARTBEAT_SILENCE_MARGIN: u32 = 3;