Skip to main content

onlyne_client/backend/tern/
session.rs

1//! The session layer: the backend's own type and its [`SessionBackend`]
2//! implementation.
3
4use super::cli::default_command;
5use super::policy::{Site, TernRef, session_label, split_word};
6use crate::backend::*;
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10pub struct TernBackend {
11    pub(super) runner: Arc<dyn Runner>,
12    pub(super) command: String,
13    pub(super) env: BTreeMap<String, String>,
14}
15
16impl TernBackend {
17    pub fn new(runner: Arc<dyn Runner>) -> Self {
18        Self::with_env(runner, process_env())
19    }
20
21    pub fn with_env(runner: Arc<dyn Runner>, env: BTreeMap<String, String>) -> Self {
22        let command = env
23            .get("TERN_COMMAND")
24            .cloned()
25            .filter(|value| !value.is_empty())
26            .unwrap_or_else(default_command);
27        Self {
28            runner,
29            command,
30            env,
31        }
32    }
33}
34
35impl SessionBackend for TernBackend {
36    fn name(&self) -> &'static str {
37        "tern"
38    }
39
40    fn capabilities(&self) -> Capabilities {
41        Capabilities {
42            spawn: true,
43            attach: true,
44            probe: true,
45            close: true,
46            focus: true,
47            // `tern rename BLOCK NAME` renames the block's *tab*, measured: a
48            // block id renames the tab that holds it, and a tab id is refused
49            // with `no block is called`. Renaming one session's pane would
50            // therefore rename every pane in that role's tab, so this backend
51            // answers unsupported rather than do it.
52            rename: false,
53        }
54    }
55
56    fn available(&self) -> Result<bool> {
57        // The only honest test: run the read every other call here begins with.
58        // A window key in the environment proves the client is inside a pane,
59        // not that the binary answers — and a `PATH` `tern` would fail this
60        // where a spawned pane's `ls` would too.
61        Ok(self.json(vec!["ls".into(), "--json".into()]).is_ok())
62    }
63
64    fn spawn(&self, spec: SpawnSpec) -> Result<SessionRef> {
65        if spec.command.is_empty() {
66            anyhow::bail!("tern spawn requires a command");
67        }
68        let label = session_label(&spec);
69        let (session_id, tab_id, base_pane, direction, pane_id) = match self.role_site(&spec)? {
70            // A launch already holds the agent: it is its own base, and the
71            // direction keeps the word a first split beside it would take,
72            // so the field reads the shape every ref carries. A found tab
73            // needs the split, and its block count picks the default
74            // direction as it did for herdr — a split bringing the count to
75            // a power of two goes right, every other one down. Tern takes no
76            // ratio, so the placement contributes its direction and nothing
77            // else.
78            Site::Launched {
79                session_id,
80                tab_id,
81                ref pane_id,
82            } => (
83                session_id,
84                tab_id,
85                pane_id.clone(),
86                split_word(PanePlacement::from_pane_count(0).direction),
87                pane_id.clone(),
88            ),
89            Site::Found {
90                session_id,
91                tab_id,
92                base_pane,
93                pane_count,
94            } => {
95                // Tern takes no ratio, so the placement contributes its
96                // direction and nothing else. The pane count still decides
97                // the default direction, as it did for herdr: a split
98                // bringing the count to a power of two goes right, every
99                // other one down.
100                let placement = spec
101                    .placement
102                    .unwrap_or_else(|| PanePlacement::from_pane_count(pane_count));
103                tracing::info!(
104                    pane_count,
105                    direction = split_word(placement.direction),
106                    "tern block split (tern takes no split ratio)"
107                );
108                let pane_id =
109                    self.split_and_start(&base_pane, &spec, placement, &session_id, &tab_id)?;
110                (
111                    session_id,
112                    tab_id,
113                    base_pane,
114                    split_word(placement.direction),
115                    pane_id,
116                )
117            }
118        };
119        Ok(SessionRef {
120            task_id: spec.task_id.clone(),
121            backend: self.name().into(),
122            backend_ref: TernRef {
123                session_id,
124                tab_id,
125                base_pane,
126                pane_id,
127                session_label: label,
128                split_direction: direction.to_string(),
129            }
130            .to_value(),
131            generation: 1,
132        })
133    }
134
135    fn attach(&self, session: &SessionRef) -> Result<SessionRef> {
136        let probe = self.probe(session)?;
137        if !probe.alive {
138            anyhow::bail!("tern block is gone");
139        }
140        Ok(session.clone())
141    }
142
143    fn probe(&self, session: &SessionRef) -> Result<ResourceProbe> {
144        let reference = TernRef::from_session(session)?;
145        // A listing that cannot be read is a probe that could not answer, not
146        // a verdict: the block's own state is unknown, so it reports the
147        // failure rather than claiming the pane died.
148        let listing = self.listing()?;
149        let Some((block, tab, host)) = listing.block(&reference.pane_id) else {
150            // The block is in no tab of any session: gone, or detached by the
151            // daemon. Both read the same to this backend, which can address
152            // neither.
153            return Ok(ResourceProbe {
154                alive: false,
155                attached: false,
156                detail: Some(serde_json::json!({
157                    "pane_id": reference.pane_id,
158                    "error": "block is in no tab",
159                })),
160            });
161        };
162        // Liveness from the block's own row: `exited` is null while the
163        // program runs and holds an exit code once it returns, and `live` says
164        // whether the daemon still holds the pty. A `--keep-open` block whose
165        // command has ended stays listed with `exited` set — the pane is
166        // addressable, and the session that owned it is over. So `exited`
167        // ends a session and `live` is the fallback for a build that reports
168        // one without the other.
169        let alive = block.exited.is_none() && block.live;
170        let mut detail = serde_json::json!({
171            "pane": {
172                "id": block.id,
173                "title": block.title,
174                "cwd": block.cwd,
175                "program": block.program,
176                "command": block.command,
177                "exited": block.exited,
178                "keep_open": block.keep_open,
179                "focused": block.focused,
180                "live": block.live,
181            },
182            "tab_id": tab.id,
183            "session_id": host.id,
184            "session_label": host.name,
185        });
186        if let Some(process) = self.process_of(&reference.pane_id) {
187            if let Some(map) = detail.as_object_mut() {
188                map.insert("process".into(), process);
189            }
190        }
191        Ok(ResourceProbe {
192            alive,
193            // A block still listed is one the daemon holds, so a live one is
194            // attached to a pty. A block whose program exited under
195            // `--keep-open` is addressable but holds nothing: attached follows
196            // `live`, which is what separates the two.
197            attached: block.live,
198            detail: Some(detail),
199        })
200    }
201
202    fn close(&self, session: &SessionRef, reason: CloseReason, force: bool) -> Result<()> {
203        // `tern close` has no force flag and no reason; both close paths are
204        // the same command.
205        tracing::debug!(task = %session.task_id, ?reason, force, "closing tern block");
206        let reference = TernRef::from_session(session)?;
207        match self.json(vec![
208            "close".into(),
209            reference.pane_id.clone(),
210            "--json".into(),
211        ]) {
212            Ok(_) => Ok(()),
213            // Measured: a closed block is refused with `no block is called
214            // \`N\`` and exit 1, with no JSON body to carry a code. Closing
215            // again is a no-op, which is what lets the client end a session
216            // twice without the second end failing.
217            Err(error) if self.is_gone(&error) => {
218                tracing::debug!(
219                    task = %session.task_id,
220                    pane = %reference.pane_id,
221                    "tern block already closed"
222                );
223                Ok(())
224            }
225            Err(error) => Err(error),
226        }
227    }
228
229    fn rename(&self, _session: &SessionRef, _title: &str) -> Result<()> {
230        // `tern rename BLOCK NAME` renames the block's tab, so one session's
231        // title would land on every pane in the role's tab. Tern offers no
232        // block title of its own — the listing's `title` is the pane's
233        // working directory or its program — so this is unsupported, and the
234        // capability says so.
235        Err(unsupported(
236            self.name(),
237            "rename",
238            "tern renames a block's tab, so renaming one session's block would rename the \
239             whole role tab",
240        ))
241    }
242
243    fn focus(&self, session: &SessionRef) -> Result<()> {
244        let reference = TernRef::from_session(session)?;
245        let listing = self.listing()?;
246        if let Some(site) = self.focus_here(&listing) {
247            if site.pane_id == reference.pane_id
248                && site.tab_id == reference.tab_id
249                && site.session_id == reference.session_id
250            {
251                return Ok(());
252            }
253        }
254        self.focus_block(&reference.pane_id)?;
255        // Confirm the block took focus, naming the one that holds it. The
256        // listing can drift when an operator closes or moves blocks, and a
257        // focus that lands elsewhere sends the operator's keyboard to another
258        // session — a wrong block holding focus is reported, not passed off as
259        // success.
260        let after = self.listing()?;
261        match self.focus_here(&after) {
262            Some(site) if site.pane_id == reference.pane_id => Ok(()),
263            Some(site) => Err(anyhow::anyhow!(
264                "tern left block {} unfocused (focused block {})",
265                reference.pane_id,
266                site.pane_id
267            )),
268            None => Err(anyhow::anyhow!(
269                "tern left block {} unfocused (no block holds focus)",
270                reference.pane_id
271            )),
272        }
273    }
274}