Skip to main content

ninox_core/
tmux.rs

1use anyhow::{Context, Result};
2use tokio::process::Command;
3
4/// Name of the private tmux server socket all ninox sessions live on.
5/// Isolates ninox from the user's own tmux server and ~/.tmux.conf.
6const SOCKET: &str = "ninox";
7
8/// Parse a `tmux -V` version string (e.g. "tmux 3.4" or "tmux 3.5a") into
9/// (major, minor). Unparseable input degrades to (0, 0) so version checks
10/// fail closed rather than panicking.
11fn parse_tmux_version(raw: &str) -> (u32, u32) {
12    let ver = raw.trim().strip_prefix("tmux ").unwrap_or(raw.trim());
13    let mut parts = ver.split(|c: char| !c.is_ascii_digit());
14    let major: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
15    let minor: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
16    (major, minor)
17}
18
19/// The installed tmux version, detected synchronously (a single fast `tmux
20/// -V` call). Used to build a config that only uses directives the running
21/// tmux actually supports, and by tests to gate assertions that only hold on
22/// newer tmux. Returns (0, 0) if tmux is missing or unparseable.
23pub fn detected_version_sync() -> (u32, u32) {
24    std::process::Command::new("tmux")
25        .arg("-V")
26        .output()
27        .ok()
28        .filter(|o| o.status.success())
29        .map(|o| parse_tmux_version(&String::from_utf8_lossy(&o.stdout)))
30        .unwrap_or((0, 0))
31}
32
33/// `extended-keys-format` (needed to disambiguate keys like Shift+Enter as
34/// CSI-u) was added in tmux 3.5; older tmux rejects the option outright.
35fn supports_extended_keys_format((major, minor): (u32, u32)) -> bool {
36    (major, minor) >= (3, 5)
37}
38
39/// Build the ninox-managed server config (spec §"Dedicated tmux server"),
40/// tailored to what `version` actually supports so we never ask an older
41/// tmux to parse a directive it doesn't understand.
42fn server_config_for_version(version: (u32, u32)) -> String {
43    let mut cfg = String::from("# Managed by ninox — rewritten on every app start. Do not edit.\n");
44    cfg.push_str("set -g  default-terminal \"tmux-256color\"\n");
45    cfg.push_str("set -as terminal-features \"xterm*:RGB:usstyle:extkeys:hyperlinks\"\n");
46    cfg.push_str("set -s  extended-keys always\n");
47    if supports_extended_keys_format(version) {
48        cfg.push_str("set -s  extended-keys-format csi-u\n");
49    }
50    cfg.push_str("set -g  history-limit 100000\n");
51    cfg.push_str("set -g  status off\n");
52    cfg.push_str("set -s  escape-time 0\n");
53    cfg.push_str("set -g  window-size latest\n");
54    cfg.push_str("set -g  allow-passthrough on\n");
55    cfg.push_str("set -g  focus-events on\n");
56    // Keep the server alive with zero sessions/clients so the one-time
57    // bootstrap in `ensure_server_ready` (a bare `start-server`, no session)
58    // doesn't get reaped before later commands reach it.
59    cfg.push_str("set -g  exit-empty off\n");
60    cfg
61}
62
63fn config_path() -> std::path::PathBuf {
64    dirs::config_dir()
65        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
66        .join("ninox")
67        .join("tmux.conf")
68}
69
70/// Write the ninox tmux server config. Called once at startup so config
71/// drift between app versions cannot accumulate. The content is tailored to
72/// the installed tmux version (see `server_config_for_version`).
73pub fn write_server_config() -> Result<std::path::PathBuf> {
74    let path = config_path();
75    if let Some(dir) = path.parent() {
76        std::fs::create_dir_all(dir)?;
77    }
78    std::fs::write(&path, server_config_for_version(detected_version_sync()))?;
79    Ok(path)
80}
81
82/// argv prefix routing a tmux invocation to the private ninox server.
83/// Does NOT include `-f`: config application is handled once, deterministically,
84/// by `ensure_server_ready` (see its doc comment for why `-f` on every
85/// invocation is not safe to rely on).
86fn socket_args() -> Vec<String> {
87    vec!["-L".into(), SOCKET.into()]
88}
89
90/// Fail fast if tmux is missing or older than 3.2 (extended-keys support).
91pub async fn require_version() -> Result<()> {
92    let out = Command::new("tmux").arg("-V").output().await
93        .context("tmux not found — install tmux (brew install tmux / apt install tmux)")?;
94    let v = String::from_utf8_lossy(&out.stdout);
95    let ver = v.trim().strip_prefix("tmux ").unwrap_or(v.trim());
96    let version = parse_tmux_version(&v);
97    anyhow::ensure!(
98        version >= (3, 2),
99        "ninox requires tmux >= 3.2 for extended keyboard support; found {ver}"
100    );
101    if !supports_extended_keys_format(version) {
102        tracing::warn!(
103            "tmux {ver} detected — extended-keys-format csi-u requires tmux >= 3.5; \
104             Shift+Enter and other disambiguated keys may not reach apps correctly \
105             on this version"
106        );
107    }
108    Ok(())
109}
110
111/// Ensure the ninox config file exists, the private tmux server is running,
112/// and that server has our config applied — exactly once per process, no
113/// matter how many concurrent callers race to be first.
114///
115/// Why this exists: `tmux -f <path>` silently falls back to built-in
116/// defaults — no error, nothing on stderr — if `<path>` doesn't exist yet
117/// (verified directly: `tmux -f /does/not/exist new-session -d ...` exits 0
118/// with default options). `write_server_config` is normally called once by
119/// `main` before any session is created, but nothing else guarantees that
120/// ordering — a caller that creates a session before the config file has
121/// ever been written (e.g. this crate's test suite, or a future call site)
122/// gets a server silently running with tmux defaults (status bar visible,
123/// 2000-line history, no window-size follow) for that server's entire
124/// lifetime, since config is only read once, at server start. Reproduced
125/// deterministically on a from-scratch Linux `$HOME` (no prior ninox run to
126/// have left the file behind) — exactly the shape of a fresh CI runner or a
127/// fresh install, which is why this only ever showed up on Ubuntu CI.
128/// Funnelling every ninox-socket command through this guard first means the
129/// file is written, and the server started against it, exactly once, before
130/// anything else can race ahead and start the server unconfigured.
131async fn ensure_server_ready() {
132    static READY: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
133    READY.get_or_init(|| async {
134        if let Err(e) = write_server_config() {
135            tracing::warn!("failed to write tmux config: {e}");
136        }
137        let conf = config_path().display().to_string();
138        // `start-server` needs no session; `exit-empty off` (in our config)
139        // keeps the freshly-started server alive with none. If a server
140        // from an older ninox run is already up, `-f` here is a no-op, so
141        // `source-file` re-applies our (possibly newer) config explicitly —
142        // this is also what makes "rewritten on every app start" true for a
143        // long-lived server, not just for the file on disk.
144        let _ = run_raw(&["-L", SOCKET, "-f", &conf, "start-server"]).await;
145        let _ = run_raw(&["-L", SOCKET, "source-file", &conf]).await;
146    }).await;
147}
148
149fn is_missing_session(e: &anyhow::Error) -> bool {
150    let msg = e.to_string();
151    msg.contains("can't find session")
152        || msg.contains("session not found")
153        || msg.contains("no server running")
154        || msg.contains("no sessions")
155        // tmux's message for a session-targeted command (has-session,
156        // kill-session, list-panes, ...) when the server is up but holds
157        // zero sessions total — a state that's now reachable because
158        // `ensure_server_ready` keeps the ninox server alive empty
159        // (`exit-empty off`) rather than only ever existing once a real
160        // session has been created on it.
161        || msg.contains("no current target")
162}
163
164/// Metadata about a running tmux session from `list-sessions`.
165#[derive(Debug, Clone)]
166pub struct TmuxSession {
167    pub id:         String,
168    pub created_ms: i64,
169    pub pid:        Option<u32>,
170    pub tty:        Option<String>,
171}
172
173/// Run a tmux subcommand against the ninox server and return trimmed stdout.
174async fn run(args: &[&str]) -> Result<String> {
175    ensure_server_ready().await;
176    let prefix = socket_args();
177    let mut full: Vec<&str> = prefix.iter().map(String::as_str).collect();
178    full.extend_from_slice(args);
179    run_raw(&full).await
180}
181
182/// Run tmux with NO socket routing (the user's default server) — only for
183/// legacy sessions created by pre-private-socket builds.
184async fn run_default(args: &[&str]) -> Result<String> {
185    run_raw(args).await
186}
187
188/// The old `run` body, renamed: spawn tmux with exactly these args.
189async fn run_raw(args: &[&str]) -> Result<String> {
190    let out = Command::new("tmux")
191        .args(args)
192        .kill_on_drop(true)
193        .output()
194        .await
195        .context("tmux not found — install tmux (brew install tmux / apt install tmux)")?;
196    if !out.status.success() {
197        let stderr = String::from_utf8_lossy(&out.stderr);
198        anyhow::bail!("tmux {:?} failed: {}", args, stderr.trim());
199    }
200    Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
201}
202
203/// Run a command that targets an existing session. Tries the ninox server
204/// first, then falls back to the default server for legacy sessions.
205async fn run_session_scoped(args: &[&str]) -> Result<String> {
206    match run(args).await {
207        Err(e) if is_missing_session(&e) => run_default(args).await,
208        other => other,
209    }
210}
211
212/// Run tmux; swallow errors and return empty string on failure.
213/// Logs warnings for debugging; does not propagate errors.
214async fn run_best_effort(args: &[&str]) -> String {
215    match run(args).await {
216        Ok(result) => result,
217        Err(e) => {
218            tracing::warn!("tmux {:?} failed (ignored): {}", args, e);
219            String::new()
220        }
221    }
222}
223
224/// `run_best_effort`, but against the default (non-ninox) socket — used to
225/// surface legacy sessions from pre-private-socket builds.
226async fn run_best_effort_default(args: &[&str]) -> String {
227    match run_default(args).await {
228        Ok(result) => result,
229        Err(e) => {
230            tracing::warn!("tmux (default socket) {:?} failed (ignored): {}", args, e);
231            String::new()
232        }
233    }
234}
235
236/// Shell-quote a string to prevent injection in tmux commands.
237/// Wraps the string in single quotes and escapes interior single quotes.
238fn shell_quote(s: &str) -> String {
239    format!("'{}'", s.replace('\'', "'\\''"))
240}
241
242/// Create a detached tmux session.  Kills a stale session with the same name
243/// if one exists, then hides the status bar so the terminal widget is clean.
244pub async fn create_session(
245    id:        &str,
246    workspace: &str,
247    cmd:       &str,
248    env:       &[(&str, &str)],
249) -> Result<()> {
250    // Build -e KEY=VALUE pairs
251    let mut env_pairs: Vec<String> = Vec::new();
252    for (k, v) in env {
253        anyhow::ensure!(!k.contains('='), "env key must not contain '=': {k}");
254        env_pairs.push(format!("{k}={v}"));
255    }
256    let mut extra: Vec<&str> = Vec::new();
257    for pair in &env_pairs {
258        // Values are passed as separate argv tokens via execve — no shell quoting needed.
259        extra.push("-e");
260        extra.push(pair.as_str());
261    }
262
263    // Wrap the command in a login shell so the full user PATH is available.
264    // tmux sessions do not inherit shell rc files, so tools installed via
265    // nvm / cargo / homebrew etc. would not be found otherwise.
266    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
267    let shell_cmd = format!("{shell} -l -c {}", shell_quote(cmd));
268
269    // Fix the terminal dimensions to match the canvas.  The canvas is roughly
270    // (window_width - 220px sidebar) / 7.8px_per_col ≈ 135 cols on a 1280-wide
271    // window.  Use 140 as a safe default; too-wide values push Claude Code's
272    // centered content off-screen.
273    let mut base = vec!["new-session", "-d", "-s", id, "-x", "140", "-y", "50", "-c", workspace];
274    base.extend_from_slice(&extra);
275    base.push(&shell_cmd);
276
277    // A duplicate name means a LIVE tmux session already exists under this
278    // id. Killing it to make room (the old behavior) silently destroys a
279    // running agent whenever the store and tmux disagree about what exists —
280    // surface the conflict to the caller instead; the spawn UI shows it.
281    run(&base).await.map(|_| ())
282}
283
284/// Kill a tmux session.  Succeeds even if the session doesn't exist.
285/// `run_session_scoped` falls back to the default socket when the session
286/// isn't found on the ninox server, so legacy sessions are killed there too.
287pub async fn kill_session(id: &str) -> Result<()> {
288    match run_session_scoped(&["kill-session", "-t", id]).await {
289        Ok(_) => Ok(()),
290        Err(e) => {
291            if is_missing_session(&e) {
292                Ok(())
293            } else {
294                Err(e)
295            }
296        }
297    }
298}
299
300/// Returns `true` if a tmux session with this name is currently running.
301pub async fn has_session(id: &str) -> bool {
302    run_session_scoped(&["has-session", "-t", id]).await.is_ok()
303}
304
305/// List every live tmux session.  Sessions on the ninox server are listed
306/// first; legacy sessions on the default server are appended for any id not
307/// already seen (ninox socket wins on conflicts).
308pub async fn list_sessions() -> Result<Vec<TmuxSession>> {
309    // A literal tab column separator is mangled by tmux's -F formatter on
310    // older tmux (verified: tmux 3.4 rewrites an embedded tab byte in a -F
311    // template to `_` in the output, so every field collapses into one;
312    // tmux 3.6 passes it through untouched). `|` survives on both and can't
313    // appear in any of these fields (ninox controls session-name shape;
314    // the rest are numeric or a `/dev/...` path).
315    const SEP: &str = "|";
316    let fmt = format!("#{{session_name}}{SEP}#{{session_created}}{SEP}#{{pane_pid}}{SEP}#{{pane_tty}}");
317    let ninox_raw = run_best_effort(&["list-sessions", "-F", &fmt]).await;
318    let default_raw = run_best_effort_default(&["list-sessions", "-F", &fmt]).await;
319
320    let mut seen = std::collections::HashSet::new();
321    let mut sessions = Vec::new();
322    for raw in [ninox_raw, default_raw] {
323        for line in raw.lines().filter(|l| !l.is_empty()) {
324            let mut cols = line.splitn(4, SEP);
325            let Some(id) = cols.next().map(str::to_string) else { continue };
326            if !seen.insert(id.clone()) {
327                continue;
328            }
329            let sec = cols.next().and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
330            let pid = cols.next().and_then(|s| s.parse::<u32>().ok());
331            let tty = cols.next().map(str::to_string).filter(|s| !s.is_empty());
332            sessions.push(TmuxSession { id, created_ms: sec * 1000, pid, tty });
333        }
334    }
335    Ok(sessions)
336}
337
338/// Return the tty device path (e.g. `/dev/ttys003`) for the session's active pane.
339pub async fn get_pane_tty(id: &str) -> Result<Option<String>> {
340    let out = run_session_scoped(&["list-panes", "-t", id, "-F", "#{pane_tty}"]).await?;
341    Ok(out
342        .lines()
343        .next()
344        .map(|s| s.trim().to_string())
345        .filter(|s| !s.is_empty()))
346}
347
348/// Start piping pane output to `dest_path` (regular file, not FIFO).
349/// Does NOT use `-o` so it force-restarts any existing pipe — required for reconnect.
350pub async fn pipe_pane(id: &str, dest_path: &str) -> Result<()> {
351    run_session_scoped(&["pipe-pane", "-t", id, &format!("cat > {}", shell_quote(dest_path))]).await?;
352    Ok(())
353}
354
355/// Full argv for `tmux attach` for this session (element 0 is "tmux"),
356/// resolving whether it lives on the ninox or the legacy default server.
357pub async fn attach_args(session_id: &str) -> Vec<String> {
358    let mut argv = vec!["tmux".to_string()];
359    if run(&["has-session", "-t", session_id]).await.is_ok() {
360        argv.extend(socket_args());
361    } else {
362        tracing::warn!(
363            "session {session_id} predates the ninox socket — attaching on the \
364             legacy default tmux server without the managed config (extended \
365             keys / resize guarantees are degraded until it terminates naturally)"
366        );
367    }
368    argv.extend(["attach-session", "-t", session_id].map(String::from));
369    argv
370}
371
372/// Number of scrolled-off lines tmux holds for this pane.
373pub async fn history_size(session_id: &str) -> i64 {
374    run_session_scoped(&["display-message", "-p", "-t", session_id, "#{history_size}"])
375        .await
376        .ok()
377        .and_then(|s| s.trim().parse().ok())
378        .unwrap_or(0)
379}
380
381/// Capture styled pane content for the line range [start, end], where
382/// negative indices address history (-1 = newest history line). No -J:
383/// lines stay wrapped at pane width so they re-parse at the same columns.
384pub async fn capture_history(session_id: &str, start: i64, end: i64) -> Vec<u8> {
385    run_session_scoped(&[
386        "capture-pane", "-p", "-e", "-t", session_id,
387        "-S", &start.to_string(), "-E", &end.to_string(),
388    ])
389    .await
390    .map(|s| s.into_bytes())
391    .unwrap_or_default()
392}
393
394/// Send text to a tmux session as if typed at the keyboard.
395/// The text is followed by Enter so the agent receives and acts on it.
396/// Uses `tmux send-keys -l` (literal mode) to avoid tmux interpreting
397/// special characters like `{`, `}`, arrows.
398pub async fn send_keys(session_id: &str, text: &str) -> Result<()> {
399    // Send the message text in literal mode
400    run_session_scoped(&["send-keys", "-t", session_id, "-l", text]).await?;
401    // Send Enter to submit
402    run_session_scoped(&["send-keys", "-t", session_id, "Enter"]).await?;
403    Ok(())
404}
405
406/// Write `bytes` to the session's master PTY via tmux's paste-buffer
407/// mechanism — the supported way to inject raw input (as opposed to
408/// `send-keys`, which tmux may reinterpret). `tmp_path` is a scratch file
409/// used to stage the buffer contents and is removed afterwards regardless
410/// of outcome.
411pub async fn paste_buffer(session_id: &str, buf_name: &str, tmp_path: &str, bytes: &[u8]) -> Result<()> {
412    std::fs::write(tmp_path, bytes)?;
413    let result = run_session_scoped(&[
414        "load-buffer", "-b", buf_name, tmp_path, ";",
415        "paste-buffer", "-b", buf_name, "-t", session_id, "-d",
416    ]).await;
417    let _ = std::fs::remove_file(tmp_path);
418    result.map(|_| ())
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use tokio::time::{sleep, Duration};
425
426    fn tmux_available() -> bool {
427        std::process::Command::new("tmux")
428            .args(["-V"])
429            .output()
430            .map(|o| o.status.success())
431            .unwrap_or(false)
432    }
433
434    fn unique_id() -> String {
435        // Millis alone collide when parallel test threads start within the
436        // same tick, producing duplicate tmux session names; a per-process
437        // counter guarantees uniqueness regardless of clock resolution (see
438        // the same fix in client.rs's unique_id).
439        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
440        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
441        format!(
442            "test-{}-{n}",
443            std::time::SystemTime::now()
444                .duration_since(std::time::UNIX_EPOCH)
445                .unwrap()
446                .as_millis()
447        )
448    }
449
450    #[tokio::test]
451    async fn create_and_has_and_kill() {
452        if !tmux_available() { return; }
453        let id = unique_id();
454        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
455        assert!(has_session(&id).await);
456        kill_session(&id).await.unwrap();
457        assert!(!has_session(&id).await);
458    }
459
460    #[tokio::test]
461    async fn list_includes_created() {
462        if !tmux_available() { return; }
463        let id = unique_id();
464        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
465        let sessions = list_sessions().await.unwrap();
466        assert!(sessions.iter().any(|s| s.id == id));
467        kill_session(&id).await.unwrap();
468    }
469
470    #[tokio::test]
471    async fn get_pane_tty_returns_dev_path() {
472        if !tmux_available() { return; }
473        let id = unique_id();
474        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
475        let tty = get_pane_tty(&id).await.unwrap();
476        assert!(tty.map(|t| t.starts_with("/dev/")).unwrap_or(false));
477        kill_session(&id).await.unwrap();
478    }
479
480    #[tokio::test]
481    async fn send_keys_builds_correct_command() {
482        // This test validates our argument construction without actually calling tmux.
483        // We test the shell_quote helper used by send_keys.
484        let quoted = shell_quote("hello world");
485        assert_eq!(quoted, "'hello world'");
486        let with_apostrophe = shell_quote("don't");
487        assert_eq!(with_apostrophe, "'don'\\''t'");
488    }
489
490    #[test]
491    fn server_config_is_written_and_contains_required_settings() {
492        let path = write_server_config().unwrap();
493        let body = std::fs::read_to_string(&path).unwrap();
494        for required in [
495            "default-terminal \"tmux-256color\"",
496            "extended-keys always",
497            "history-limit 100000",
498            "status off",
499            "window-size latest",
500            "allow-passthrough on",
501            "exit-empty off",
502        ] {
503            assert!(body.contains(required), "config missing {required:?}\n{body}");
504        }
505        // extended-keys-format requires tmux >= 3.5 (older tmux rejects the
506        // option outright); the written config must match what's installed.
507        if supports_extended_keys_format(detected_version_sync()) {
508            assert!(body.contains("extended-keys-format csi-u"),
509                    "config missing extended-keys-format on tmux >= 3.5\n{body}");
510        } else {
511            assert!(!body.contains("extended-keys-format"),
512                    "config must omit extended-keys-format on tmux < 3.5 (rejected as invalid)\n{body}");
513        }
514    }
515
516    #[tokio::test]
517    async fn require_version_passes_on_installed_tmux() {
518        if !tmux_available() { return; }
519        require_version().await.unwrap();
520    }
521
522    #[tokio::test]
523    async fn sessions_are_created_on_the_ninox_socket() {
524        if !tmux_available() { return; }
525        let id = unique_id();
526        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
527        // Visible via -L ninox …
528        assert!(has_session(&id).await);
529        // … and NOT on the default socket.
530        let default_out = std::process::Command::new("tmux")
531            .args(["has-session", "-t", &id])
532            .output()
533            .unwrap();
534        assert!(!default_out.status.success(), "session leaked onto the default socket");
535        kill_session(&id).await.unwrap();
536    }
537
538    #[tokio::test]
539    async fn legacy_default_socket_sessions_are_still_reachable() {
540        if !tmux_available() { return; }
541        let id = unique_id();
542        // Simulate a session created by an older build: default socket, no -L.
543        let st = std::process::Command::new("tmux")
544            .args(["new-session", "-d", "-s", &id, "-x", "80", "-y", "24", "sleep 30"])
545            .status()
546            .unwrap();
547        assert!(st.success());
548        assert!(has_session(&id).await, "has_session must fall back to the default socket");
549        let argv = attach_args(&id).await;
550        assert!(!argv.contains(&"-L".to_string()), "legacy session must attach without -L: {argv:?}");
551        kill_session(&id).await.unwrap(); // must kill on the default socket too
552        assert!(!has_session(&id).await);
553    }
554
555    #[tokio::test]
556    async fn capture_history_returns_scrolled_off_lines() {
557        if !tmux_available() { return; }
558        let id = unique_id();
559        // 50-row pane; print 80 numbered lines so the earliest ones scroll into history.
560        create_session(&id, "/tmp", "bash -c 'for i in $(seq 1 80); do echo line-$i; done; sleep 30'", &[]).await.unwrap();
561        sleep(Duration::from_millis(500)).await;
562        let hist = history_size(&id).await;
563        assert!(hist > 0, "expected history to accumulate, got {hist}");
564        let bytes = capture_history(&id, -hist, -1).await;
565        let text = String::from_utf8_lossy(&bytes);
566        assert!(text.contains("line-1"), "oldest line missing from history capture: {text}");
567        kill_session(&id).await.unwrap();
568    }
569}