Skip to main content

onlyne_client/backend/
zellij.rs

1//! The zellij backend: one zellij session per task.
2//!
3//! Session names are derived from the task id rather than remembered, so
4//! `spawn`, `attach`, `probe` and `close` agree on the name with no state
5//! carried between them — and the derivation is what fits the name inside
6//! zellij's socket path budget.
7
8use super::*;
9use serde_json::Value;
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14/// Prefix every onlyne session name carries, so `zellij list-sessions` reads as
15/// the onlyne sessions among the user's.
16const SESSION_PREFIX: &str = "onlyne-";
17
18/// How many task-id characters the name keeps after the prefix.
19///
20/// A uuid v4 is 32 hex digits plus four dashes, so twelve dash-free characters
21/// are twelve hex digits: 48 bits of name entropy. Two tasks in one workspace
22/// would have to share their first twelve id characters to collide, which is
23/// negligible at workspace scale, and the truncation is what buys a 19-byte
24/// name. Length is the point: zellij refuses a session whose IPC socket path
25/// reaches 104 bytes on macOS (`sun_path`), the full `onlyne-<uuid>` name is 43
26/// bytes, and a socket directory can spend most of the rest — 79 bytes on this
27/// machine — so the untruncated name failed every spawn with zellij's report of
28/// a negative character budget.
29const SESSION_ID_CHARS: usize = 12;
30
31/// Zellij's client-server contract directory, appended to the socket directory
32/// (`zellij-utils/src/consts.rs`).
33const CONTRACT_DIR: &str = "contract_version_1";
34
35/// The longest session IPC socket path zellij accepts: `check_ipc_pipe_length`
36/// refuses a path that reaches it (`zellij-client/src/lib.rs`), and
37/// `ZELLIJ_SOCK_MAX_LENGTH` carries `sun_path`'s 104 bytes on macOS/BSD and 108
38/// elsewhere.
39#[cfg(target_os = "macos")]
40const SOCK_PATH_LIMIT: usize = 104;
41#[cfg(not(target_os = "macos"))]
42const SOCK_PATH_LIMIT: usize = 108;
43
44/// The session name for one task: the prefix plus the first
45/// [`SESSION_ID_CHARS`] characters of the task id with its dashes dropped.
46///
47/// Pure by design. Nothing stores the name: `spawn` builds it, and `attach`,
48/// `probe` and `close` rebuild it from the `SessionRef::task_id` they already
49/// hold, so the name cannot disagree between the call that made a session and
50/// the call that ends it.
51fn short_session_name(task_id: &str) -> String {
52    let id: String = task_id
53        .chars()
54        .filter(|c| *c != '-')
55        .take(SESSION_ID_CHARS)
56        .collect();
57    format!("{SESSION_PREFIX}{id}")
58}
59
60/// The checked session name for one task: [`short_session_name`], refused when
61/// not even that fits zellij's socket path budget.
62fn session_name(task_id: &str) -> Result<String> {
63    let name = short_session_name(task_id);
64    check_socket_budget(&socket_dir(), &name)?;
65    Ok(name)
66}
67
68/// Refuse a session name whose socket path overruns the budget zellij enforces,
69/// naming the override that fixes it.
70///
71/// Shortening the name cannot help at this point — the socket directory itself
72/// is what is over budget — so the only cure is a shorter directory, which
73/// zellij reads from `ZELLIJ_SOCKET_DIR`. Left to zellij the operator instead
74/// gets its report of a negative character budget, naming neither the cause nor
75/// the cure.
76fn check_socket_budget(dir: &Path, name: &str) -> Result<()> {
77    let socket = dir.join(name);
78    let path_len = socket.as_os_str().len();
79    if path_len >= SOCK_PATH_LIMIT {
80        anyhow::bail!(
81            "zellij session {name} needs a {path_len}-byte socket path ({}), over the \
82             {SOCK_PATH_LIMIT}-byte unix socket limit; set ZELLIJ_SOCKET_DIR to a shorter directory",
83            socket.display()
84        );
85    }
86    Ok(())
87}
88
89/// Zellij's session socket directory, rebuilt the way zellij computes it
90/// (`zellij-utils/src/consts.rs`): `ZELLIJ_SOCKET_DIR` when set, else the
91/// project runtime directory on platforms that have one, else a per-uid
92/// directory under the temp dir — with the client-server contract directory
93/// appended in every case.
94fn socket_dir() -> PathBuf {
95    let base = std::env::var("ZELLIJ_SOCKET_DIR").map_or_else(
96        |_| {
97            runtime_dir()
98                .unwrap_or_else(|| std::env::temp_dir().join(format!("zellij-{}", temp_dir_uid())))
99        },
100        PathBuf::from,
101    );
102    base.join(CONTRACT_DIR)
103}
104
105/// The project runtime directory zellij prefers where a platform defines one.
106/// `ProjectDirs::runtime_dir` is `Some` only on Linux, as
107/// `$XDG_RUNTIME_DIR/zellij`.
108#[cfg(target_os = "linux")]
109fn runtime_dir() -> Option<PathBuf> {
110    std::env::var_os("XDG_RUNTIME_DIR")
111        .filter(|dir| !dir.is_empty())
112        .map(|dir| PathBuf::from(dir).join("zellij"))
113}
114
115#[cfg(not(target_os = "linux"))]
116fn runtime_dir() -> Option<PathBuf> {
117    None
118}
119
120/// The uid zellij stamps into its temp socket directory.
121///
122/// std exposes no `getuid`, and on macOS the temp dir is per-user, so its owner
123/// is that uid there. Where the temp dir is shared the number can be a digit or
124/// two off, which moves the budget check by the same amount; that check exists
125/// for a socket directory far past the limit, where a digit cannot decide the
126/// answer.
127#[cfg(unix)]
128fn temp_dir_uid() -> u32 {
129    use std::os::unix::fs::MetadataExt;
130
131    std::fs::metadata(std::env::temp_dir()).map_or(0, |meta| meta.uid())
132}
133
134#[cfg(not(unix))]
135fn temp_dir_uid() -> u32 {
136    0
137}
138
139enum SessionListing {
140    Missing,
141    Exited,
142    Live,
143}
144
145fn classify_session_listing(listing: &str, name: &str) -> SessionListing {
146    let Some(line) = listing.lines().find(|line| listing_line_names(line, name)) else {
147        return SessionListing::Missing;
148    };
149    if line.contains("EXITED") {
150        SessionListing::Exited
151    } else {
152        SessionListing::Live
153    }
154}
155
156fn listing_line_names(line: &str, name: &str) -> bool {
157    let trimmed = line.trim();
158    trimmed == name
159        || trimmed.starts_with(&format!("{name} "))
160        || trimmed.starts_with(&format!("{name}\t"))
161        || trimmed.split_whitespace().any(|tok| tok == name)
162}
163
164fn parse_pane_token(pane: &str) -> Option<(u32, bool)> {
165    let pane = pane.trim();
166    if let Some(rest) = pane.strip_prefix("terminal_") {
167        rest.parse().ok().map(|id| (id, false))
168    } else if let Some(rest) = pane.strip_prefix("plugin_") {
169        rest.parse().ok().map(|id| (id, true))
170    } else {
171        pane.parse().ok().map(|id| (id, false))
172    }
173}
174
175fn collect_panes<'a>(value: &'a Value, out: &mut Vec<&'a Value>) {
176    match value {
177        Value::Array(items) => {
178            for item in items {
179                collect_panes(item, out);
180            }
181        }
182        Value::Object(map) => {
183            if map.contains_key("id") {
184                out.push(value);
185            }
186            for nested in map.values() {
187                collect_panes(nested, out);
188            }
189        }
190        _ => {}
191    }
192}
193
194fn probe_pane(rows: &Value, pane_ref: &str) -> ResourceProbe {
195    let Some((want_id, want_plugin)) = parse_pane_token(pane_ref) else {
196        return ResourceProbe {
197            alive: false,
198            attached: false,
199            detail: Some(serde_json::json!({"reason": "pane_missing"})),
200        };
201    };
202    let mut panes = Vec::new();
203    collect_panes(rows, &mut panes);
204    let Some(row) = panes.iter().copied().find(|row| {
205        let id = row
206            .get("id")
207            .and_then(Value::as_u64)
208            .map(|id| id as u32)
209            .or_else(|| {
210                row.get("id")
211                    .and_then(Value::as_str)
212                    .and_then(|id| id.parse().ok())
213            });
214        let plugin = row
215            .get("is_plugin")
216            .and_then(Value::as_bool)
217            .unwrap_or(false);
218        id == Some(want_id) && plugin == want_plugin
219    }) else {
220        return ResourceProbe {
221            alive: false,
222            attached: false,
223            detail: Some(serde_json::json!({"reason": "pane_missing"})),
224        };
225    };
226    let exited = row.get("exited").and_then(Value::as_bool).unwrap_or(false);
227    let held = row.get("is_held").and_then(Value::as_bool).unwrap_or(false);
228    if exited || held {
229        let exit = row.get("exit_status").cloned().unwrap_or(Value::Null);
230        return ResourceProbe {
231            alive: false,
232            attached: false,
233            detail: Some(serde_json::json!({"exit": exit, "exited": true})),
234        };
235    }
236    ResourceProbe {
237        alive: true,
238        attached: true,
239        detail: None,
240    }
241}
242
243pub struct ZellijBackend {
244    runner: Arc<dyn Runner>,
245    command: String,
246}
247impl ZellijBackend {
248    pub fn new(runner: Arc<dyn Runner>) -> Self {
249        Self {
250            runner,
251            command: std::env::var("ZELLIJ_COMMAND").unwrap_or_else(|_| "zellij".into()),
252        }
253    }
254
255    /// Make sure the session exists before an action is addressed to it, and
256    /// report whether this call created it.
257    ///
258    /// `zellij run` is an action sent to a *live* session: with none, zellij
259    /// answers `There is no active session!`. A task's first spawn therefore has
260    /// to bring the session up first. `attach --create-background` makes one
261    /// detached without a TTY (the interactive `--create` requires one), and it
262    /// is not idempotent — on a session that already exists it exits 1 with
263    /// `Session already exists` — so the listing decides rather than the exit
264    /// code, which would break the day zellij rewords its message.
265    fn ensure_session(&self, name: &str) -> Result<bool> {
266        if self.session_listed(name)? {
267            return Ok(false);
268        }
269        run_checked(
270            self.runner.as_ref(),
271            &self.command,
272            &["attach".into(), "--create-background".into(), name.into()],
273            None,
274            &BTreeMap::new(),
275        )
276        .map_err(|error| anyhow::anyhow!("zellij attach --create-background {name}: {error}"))?;
277        Ok(true)
278    }
279
280    /// Whether `list-sessions --short` names a session, which is the one place
281    /// "this session exists" is decided: `attach` uses it to answer whether the
282    /// resource is still there, and `spawn` uses it to decide on creating one.
283    fn session_listed(&self, name: &str) -> Result<bool> {
284        let out = self.runner.run(
285            &self.command,
286            &["list-sessions".into(), "--short".into()],
287            None,
288            &BTreeMap::new(),
289        )?;
290        Ok(out.status == 0
291            && String::from_utf8_lossy(&out.stdout)
292                .lines()
293                .any(|line| line.trim() == name))
294    }
295
296    /// `list-sessions --no-formatting` keeps the EXITED marker `--short` strips.
297    fn session_listing(&self, name: &str) -> Result<SessionListing> {
298        let out = self.runner.run(
299            &self.command,
300            &["list-sessions".into(), "--no-formatting".into()],
301            None,
302            &BTreeMap::new(),
303        )?;
304        if out.status != 0 {
305            return Ok(SessionListing::Missing);
306        }
307        Ok(classify_session_listing(
308            &String::from_utf8_lossy(&out.stdout),
309            name,
310        ))
311    }
312
313    fn list_panes(&self, name: &str) -> Result<Value> {
314        let out = self.runner.run(
315            &self.command,
316            &[
317                "--session".into(),
318                name.into(),
319                "action".into(),
320                "list-panes".into(),
321                "--json".into(),
322                "--state".into(),
323                "--command".into(),
324            ],
325            None,
326            &BTreeMap::new(),
327        )?;
328        if out.status != 0 {
329            anyhow::bail!(
330                "zellij action list-panes {name} failed (status {})",
331                out.status
332            );
333        }
334        serde_json::from_slice(&out.stdout)
335            .map_err(|error| anyhow::anyhow!("zellij list-panes json: {error}"))
336    }
337
338    /// `kill-session` for an already-derived name.
339    fn kill_session(&self, name: &str) -> Result<()> {
340        run_checked(
341            self.runner.as_ref(),
342            &self.command,
343            &["kill-session".into(), name.into()],
344            None,
345            &BTreeMap::new(),
346        )
347        .map(|_| ())
348    }
349
350    /// Undo a session this call created when the spawn then failed, so a spawn
351    /// that cannot produce a usable session ref leaves nothing running behind
352    /// it.
353    ///
354    /// Only a session created by *this* call is reclaimed: one that was already
355    /// listed may have a live pane a previous session ref addresses, and a
356    /// failed run is no reason to take that away. A failed cleanup is logged and
357    /// not propagated, because the run's own error is what the caller must see.
358    fn reclaim_created(&self, created: bool, name: &str) {
359        if !created {
360            return;
361        }
362        if let Err(error) = self.kill_session(name) {
363            tracing::warn!(
364                session = %name,
365                %error,
366                "zellij could not reclaim the session of a failed spawn"
367            );
368        }
369    }
370}
371impl SessionBackend for ZellijBackend {
372    fn name(&self) -> &'static str {
373        "zellij"
374    }
375    fn capabilities(&self) -> Capabilities {
376        Capabilities {
377            spawn: true,
378            attach: true,
379            probe: true,
380            close: true,
381            focus: false,
382            rename: false,
383        }
384    }
385    fn available(&self) -> Result<bool> {
386        Ok(self
387            .runner
388            .run(
389                &self.command,
390                &["list-sessions".into(), "--short".into()],
391                None,
392                &BTreeMap::new(),
393            )
394            .map(|o| o.status == 0)
395            .unwrap_or(false))
396    }
397
398    /// Bring the session up if this is the task's first spawn, then run the
399    /// command as a pane in it.
400    ///
401    /// The ref records the pane the command runs in, so `probe`/`close` address
402    /// the session by the name they derive and a caller can read the pane id.
403    /// A spawn that cannot finish takes the session it created with it.
404    fn spawn(&self, spec: SpawnSpec) -> Result<SessionRef> {
405        let session = session_name(&spec.task_id)?;
406        let created = self.ensure_session(&session)?;
407        let mut args = vec![
408            "--session".into(),
409            session.clone(),
410            "run".into(),
411            "--cwd".into(),
412            spec.cwd.to_string_lossy().into_owned(),
413            "--no-focus".into(),
414            "--".into(),
415        ];
416        args.extend(spec.command);
417        let pane = match run_checked(self.runner.as_ref(), &self.command, &args, None, &spec.env) {
418            Ok(output) => String::from_utf8_lossy(&output.stdout).trim().to_owned(),
419            Err(error) => {
420                self.reclaim_created(created, &session);
421                return Err(error);
422            }
423        };
424        if pane.is_empty() {
425            self.reclaim_created(created, &session);
426            return Err(anyhow::anyhow!("zellij run returned no pane id"));
427        }
428        Ok(SessionRef {
429            task_id: spec.task_id,
430            backend: self.name().into(),
431            backend_ref: serde_json::json!({"session": session, "pane": pane}),
432            generation: 1,
433        })
434    }
435    fn attach(&self, session: &SessionRef) -> Result<SessionRef> {
436        let name = session_name(&session.task_id)?;
437        if !self.session_listed(&name)? {
438            return Err(anyhow::anyhow!("zellij session not found: {name}"));
439        }
440        Ok(session.clone())
441    }
442    fn probe(&self, session: &SessionRef) -> Result<ResourceProbe> {
443        let name = session_name(&session.task_id)?;
444        match self.session_listing(&name)? {
445            SessionListing::Missing => Ok(ResourceProbe {
446                alive: false,
447                attached: false,
448                detail: Some(serde_json::json!({"reason": "session_missing"})),
449            }),
450            SessionListing::Exited => Ok(ResourceProbe {
451                alive: false,
452                attached: false,
453                detail: Some(serde_json::json!({"reason": "session_exited"})),
454            }),
455            SessionListing::Live => {
456                let pane = session
457                    .backend_ref
458                    .get("pane")
459                    .and_then(Value::as_str)
460                    .unwrap_or("");
461                match self.list_panes(&name) {
462                    Ok(rows) => Ok(probe_pane(&rows, pane)),
463                    Err(_) => Ok(ResourceProbe {
464                        alive: false,
465                        attached: false,
466                        detail: Some(serde_json::json!({"reason": "pane_missing"})),
467                    }),
468                }
469            }
470        }
471    }
472    fn close(&self, session: &SessionRef, _reason: CloseReason, _force: bool) -> Result<()> {
473        self.kill_session(&session_name(&session.task_id)?)
474    }
475}