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;