Skip to main content

vissue_tui/
control.rs

1//! Socket-backed board. Unix only; clients never bind the control socket.
2
3use std::path::Path;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Mutex;
6use std::time::Duration;
7
8use serde_json::Value;
9use vissue_control::client::Client;
10use vissue_control::rpc::{
11    ClaimParams, Error as RpcError, IdParams, InitializeResult, IssueListParams, IssueListResult,
12    MutResult as WireMut, NoteParams, Notification, RelatedParams, Request, SearchParams,
13    TreeParams, UpdateParams, CONFLICT, CYCLE, INVALID_STATE, NOT_FOUND,
14};
15use vissue_control::{InitializeParams, PROTOCOL_VERSION};
16use vissue_core::config::Layout;
17use vissue_core::error::Error;
18use vissue_core::views::{
19    AgendaRow, ClaimRow, Excerpt, IssueDetail, ListQuery, RelatedHit, SearchHit, TreeNode,
20};
21
22use crate::backend::{BackendKind, BoardBackend, ListPage, MutResult, SinceGate, UpdateReq};
23
24/// JSON-RPC client after a matching `initialize`.
25pub struct ControlBackend {
26    layout: Layout,
27    identity: String,
28    client: Mutex<Client>,
29    generation: AtomicU64,
30    revision: AtomicU64,
31    /// Revision of the last full list/ready page, not the last mut/notify.
32    page_revision: AtomicU64,
33    since: SinceGate,
34    last_query: Mutex<Option<ListQuery>>,
35    last_since: Mutex<Option<Option<u64>>>,
36}
37
38impl ControlBackend {
39    /// Connect, `initialize` with a required agent, and refuse a root/prefix
40    /// mismatch so mutations never hit the wrong vault.
41    pub fn connect(path: &Path, layout: &Layout, agent: &str) -> Result<Self, ControlAttachError> {
42        Self::connect_as(path, layout, agent, "vissue-tui")
43    }
44
45    /// Same as [`Self::connect`] with an explicit `initialize.client` name.
46    pub fn connect_as(
47        path: &Path,
48        layout: &Layout,
49        agent: &str,
50        client: &str,
51    ) -> Result<Self, ControlAttachError> {
52        let mut client_conn = Client::connect(path).map_err(ControlAttachError::Rpc)?;
53        let params = InitializeParams {
54            protocol_version: PROTOCOL_VERSION,
55            client: client.into(),
56            agent: agent.to_string(),
57        };
58        let value = client_conn
59            .request_typed(&Request::Initialize(params))
60            .map_err(ControlAttachError::Rpc)?;
61        let init: InitializeResult = serde_json::from_value(value)
62            .map_err(|e| ControlAttachError::Rpc(RpcError::Json(e)))?;
63        if !roots_match(layout, &init.root, &init.prefix) {
64            return Err(ControlAttachError::Mismatch {
65                want_root: layout.root().display().to_string(),
66                want_prefix: layout.prefix().to_string(),
67                got_root: init.root,
68                got_prefix: init.prefix,
69            });
70        }
71        Ok(Self {
72            layout: layout.clone(),
73            identity: init.identity,
74            client: Mutex::new(client_conn),
75            generation: AtomicU64::new(init.generation),
76            revision: AtomicU64::new(init.revision),
77            page_revision: AtomicU64::new(0),
78            since: SinceGate::after_attach(),
79            last_query: Mutex::new(None),
80            last_since: Mutex::new(None),
81        })
82    }
83
84    fn call(&self, req: &Request) -> Result<Value, Error> {
85        let mut client = self.client.lock().expect("control client");
86        client.request_typed(req).map_err(map_rpc)
87    }
88
89    fn list_params(&self, q: ListQuery) -> IssueListParams {
90        // Serve `unchanged` is catalog-wide. Only send since_revision when
91        // this is the same ready/project/query as the last full page.
92        let mut last_query = self.last_query.lock().expect("query");
93        let same = last_query.as_ref() == Some(&q);
94        *last_query = Some(q.clone());
95        drop(last_query);
96        let page = self.page_revision.load(Ordering::SeqCst);
97        let since = if same {
98            self.since.next(page)
99        } else {
100            self.since.invalidate();
101            let _ = self.since.next(page);
102            None
103        };
104        *self.last_since.lock().expect("since") = Some(since);
105        IssueListParams {
106            project: q.project,
107            state: q.state,
108            ready: if q.ready { Some(true) } else { None },
109            query: q.query,
110            limit: q.limit,
111            offset: q.offset,
112            since_revision: since,
113        }
114    }
115
116    fn apply_list(&self, result: IssueListResult) -> ListPage {
117        if !result.unchanged {
118            self.page_revision.store(result.revision, Ordering::SeqCst);
119            self.revision.store(result.revision, Ordering::SeqCst);
120            self.generation.store(result.generation, Ordering::SeqCst);
121        }
122        ListPage {
123            issues: result.issues,
124            total: result.total,
125            matched: result.matched,
126            revision: result.revision,
127            generation: result.generation,
128            unchanged: result.unchanged,
129        }
130    }
131
132    fn apply_mut(&self, wire: WireMut) -> MutResult {
133        self.revision.store(wire.revision, Ordering::SeqCst);
134        self.generation.store(wire.generation, Ordering::SeqCst);
135        MutResult {
136            ok: wire.ok,
137            report: wire.report,
138            issue: wire.issue,
139            revision: wire.revision,
140            generation: wire.generation,
141        }
142    }
143}
144
145fn roots_match(layout: &Layout, root: &str, prefix: &str) -> bool {
146    let want_root = layout.root().display().to_string();
147    (root == want_root || Path::new(root) == layout.root()) && prefix == layout.prefix()
148}
149
150/// Why attach refused the live socket.
151#[derive(Debug)]
152pub enum ControlAttachError {
153    Rpc(RpcError),
154    Mismatch {
155        want_root: String,
156        want_prefix: String,
157        got_root: String,
158        got_prefix: String,
159    },
160}
161
162impl std::fmt::Display for ControlAttachError {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        match self {
165            Self::Rpc(err) => write!(f, "{err}"),
166            Self::Mismatch {
167                want_root,
168                want_prefix,
169                got_root,
170                got_prefix,
171            } => write!(
172                f,
173                "serve root/prefix mismatch: want {want_root} {want_prefix}, got {got_root} {got_prefix}"
174            ),
175        }
176    }
177}
178
179impl std::error::Error for ControlAttachError {}
180
181fn map_rpc(err: RpcError) -> Error {
182    match err {
183        RpcError::Rpc(rpc) => match rpc.code {
184            NOT_FOUND => Error::IssueNotFound {
185                id: rpc
186                    .data
187                    .as_ref()
188                    .and_then(|d| d.get("id"))
189                    .and_then(Value::as_str)
190                    .unwrap_or("")
191                    .to_string(),
192            },
193            CONFLICT => Error::ClaimConflict {
194                id: rpc
195                    .data
196                    .as_ref()
197                    .and_then(|d| d.get("id"))
198                    .and_then(Value::as_str)
199                    .unwrap_or("")
200                    .to_string(),
201                holder: rpc
202                    .data
203                    .as_ref()
204                    .and_then(|d| d.get("holder"))
205                    .and_then(Value::as_str)
206                    .unwrap_or("")
207                    .to_string(),
208                claimed_at: None,
209            },
210            CYCLE => Error::BlockerCycle {
211                blocker: rpc
212                    .data
213                    .as_ref()
214                    .and_then(|d| d.get("block"))
215                    .and_then(Value::as_str)
216                    .unwrap_or("")
217                    .to_string(),
218                issue: rpc
219                    .data
220                    .as_ref()
221                    .and_then(|d| d.get("id"))
222                    .and_then(Value::as_str)
223                    .unwrap_or("")
224                    .to_string(),
225            },
226            INVALID_STATE => Error::InvalidState {
227                id: rpc
228                    .data
229                    .as_ref()
230                    .and_then(|d| d.get("id"))
231                    .and_then(Value::as_str)
232                    .unwrap_or("")
233                    .to_string(),
234                state: rpc
235                    .data
236                    .as_ref()
237                    .and_then(|d| d.get("state"))
238                    .and_then(Value::as_str)
239                    .unwrap_or("")
240                    .to_string(),
241            },
242            _ => Error::Other(anyhow::anyhow!("{}", rpc.message)),
243        },
244        other => Error::Other(anyhow::anyhow!("{other}")),
245    }
246}
247
248fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, Error> {
249    serde_json::from_value(value).map_err(|e| Error::Other(e.into()))
250}
251
252impl BoardBackend for ControlBackend {
253    fn layout(&self) -> &Layout {
254        &self.layout
255    }
256
257    fn generation(&self) -> u64 {
258        self.generation.load(Ordering::SeqCst)
259    }
260
261    fn revision(&self) -> u64 {
262        self.revision.load(Ordering::SeqCst)
263    }
264
265    fn live(&self) -> BackendKind {
266        BackendKind::Control
267    }
268
269    fn identity(&self) -> &str {
270        &self.identity
271    }
272
273    fn list(&self, q: ListQuery) -> Result<ListPage, Error> {
274        let params = self.list_params(q);
275        let value = self.call(&Request::IssueList(params))?;
276        Ok(self.apply_list(decode(value)?))
277    }
278
279    fn ready(&self, project: Option<&str>) -> Result<ListPage, Error> {
280        let params = self.list_params(ListQuery {
281            project: project.map(str::to_string),
282            ready: true,
283            ..ListQuery::default()
284        });
285        let value = self.call(&Request::IssueReady(params))?;
286        Ok(self.apply_list(decode(value)?))
287    }
288
289    fn get(&self, id: &str) -> Result<IssueDetail, Error> {
290        let value = self.call(&Request::IssueGet(IdParams { id: id.to_string() }))?;
291        let row: vissue_control::rpc::IssueGetResult = decode(value)?;
292        Ok(row.issue)
293    }
294
295    fn excerpt(&self, id: &str) -> Result<Excerpt, Error> {
296        let value = self.call(&Request::IssueExcerpt(IdParams { id: id.to_string() }))?;
297        decode(value)
298    }
299
300    fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, Error> {
301        let value = self.call(&Request::IssueSearch(SearchParams {
302            query: query.to_string(),
303            limit: Some(limit),
304        }))?;
305        decode(value)
306    }
307
308    fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>, Error> {
309        let value = self.call(&Request::IssueClaims(vissue_control::rpc::ClaimsParams {
310            holder: holder.map(str::to_string),
311            project: project.map(str::to_string),
312        }))?;
313        decode(value)
314    }
315
316    fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>, Error> {
317        let value = self.call(&Request::IssueAgenda(vissue_control::rpc::AgendaParams {
318            days: Some(days),
319            project: project.map(str::to_string),
320        }))?;
321        decode(value)
322    }
323
324    fn tree(&self, id: &str) -> Result<TreeNode, Error> {
325        let value = self.call(&Request::IssueTree(TreeParams {
326            id: id.to_string(),
327            format: Some("nodes".into()),
328        }))?;
329        match decode::<vissue_control::rpc::TreeResult>(value)? {
330            vissue_control::rpc::TreeResult::Nodes(node) => Ok(node),
331            vissue_control::rpc::TreeResult::Text { text } => Err(Error::Other(anyhow::anyhow!(
332                "serve returned tree text, not nodes: {text}"
333            ))),
334        }
335    }
336
337    fn related(&self, id: &str, depth: usize, limit: usize) -> Result<Vec<RelatedHit>, Error> {
338        let value = self.call(&Request::IssueRelated(RelatedParams {
339            id: id.to_string(),
340            depth: Some(depth),
341            limit: Some(limit),
342        }))?;
343        decode(value)
344    }
345
346    fn projects(&self) -> Result<Vec<String>, Error> {
347        let value = self.call(&Request::ProjectList)?;
348        let row: vissue_control::rpc::ProjectListResult = decode(value)?;
349        Ok(row.projects)
350    }
351
352    fn claim(&self, id: &str, force: bool) -> Result<MutResult, Error> {
353        let value = self.call(&Request::IssueClaim(ClaimParams {
354            id: id.to_string(),
355            force,
356            agent: None,
357        }))?;
358        Ok(self.apply_mut(decode(value)?))
359    }
360
361    fn note(&self, id: &str, text: &str) -> Result<MutResult, Error> {
362        let value = self.call(&Request::IssueNote(NoteParams {
363            id: id.to_string(),
364            text: text.to_string(),
365        }))?;
366        Ok(self.apply_mut(decode(value)?))
367    }
368
369    fn update(&self, req: UpdateReq) -> Result<MutResult, Error> {
370        let value = self.call(&Request::IssueUpdate(UpdateParams {
371            id: req.id,
372            state: req.state,
373            priority: req.priority.map(|c| c.to_string()),
374            block: req.block,
375            unblock: req.unblock,
376            agent: None,
377        }))?;
378        Ok(self.apply_mut(decode(value)?))
379    }
380
381    fn open(&self, id: &str) -> Result<IssueDetail, Error> {
382        let value = self.call(&Request::IssueOpen(IdParams { id: id.to_string() }))?;
383        let row: vissue_control::rpc::IssueGetResult = decode(value)?;
384        Ok(row.issue)
385    }
386
387    fn wait(&self, last: u64, timeout_ms: u64) -> Result<u64, Error> {
388        let mut client = self.client.lock().expect("control client");
389        match client.wait_notification(Duration::from_millis(timeout_ms.max(1))) {
390            Ok(Notification::VaultChanged(changed)) => {
391                self.revision.store(changed.revision, Ordering::SeqCst);
392                self.generation.store(changed.generation, Ordering::SeqCst);
393                Ok(changed.revision)
394            }
395            Ok(_) => Ok(self.revision.load(Ordering::SeqCst)),
396            Err(_) => Ok(last),
397        }
398    }
399
400    fn last_since_revision(&self) -> Option<Option<u64>> {
401        *self.last_since.lock().expect("since")
402    }
403
404    fn invalidate_since(&self) {
405        self.since.invalidate();
406        *self.last_query.lock().expect("query") = None;
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::backend::{BoardBackend, UpdateReq};
414    use serde_json::json;
415    use std::io::{BufReader, Write};
416    use std::os::unix::net::UnixListener;
417    use std::sync::{Arc, Mutex};
418    use std::thread;
419    use vissue_control::frame::{read_message, write_message};
420    use vissue_control::rpc::JsonRpcRequest;
421    use vissue_core::views::ListQuery;
422
423    #[test]
424    fn after_initialize_the_next_list_omits_since_revision() {
425        let dir = tempfile::tempdir().unwrap();
426        let sock = dir.path().join("control.sock");
427        let layout = Layout::new(dir.path().join("vault"), "Software");
428        let seen = Arc::new(Mutex::new(Vec::new()));
429        let seen_cb = Arc::clone(&seen);
430        let root = layout.root().display().to_string();
431        let listener = UnixListener::bind(&sock).unwrap();
432        thread::spawn(move || {
433            let (stream, _) = listener.accept().unwrap();
434            let mut reader = BufReader::new(stream.try_clone().unwrap());
435            let mut writer = stream;
436            while let Ok((payload, framing)) = read_message(&mut reader) {
437                let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
438                let body = if req.method == "initialize" {
439                    json!({
440                        "jsonrpc": "2.0",
441                        "id": req.id,
442                        "result": {
443                            "protocolVersion": 1,
444                            "capabilities": [],
445                            "root": root,
446                            "prefix": "Software",
447                            "generation": 9,
448                            "revision": 41,
449                            "identity": "tui"
450                        }
451                    })
452                } else {
453                    let since = req
454                        .params
455                        .as_ref()
456                        .and_then(|p| p.get("since_revision"))
457                        .cloned();
458                    seen_cb.lock().unwrap().push(since);
459                    json!({
460                        "jsonrpc": "2.0",
461                        "id": req.id,
462                        "result": {
463                            "issues": [],
464                            "total": 0,
465                            "matched": 0,
466                            "revision": 41,
467                            "generation": 9,
468                            "unchanged": false
469                        }
470                    })
471                };
472                write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
473                writer.flush().unwrap();
474            }
475        });
476
477        let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
478        assert_eq!(backend.revision(), 41);
479        assert_eq!(backend.live(), BackendKind::Control);
480        backend.ready(None).unwrap();
481        assert_eq!(backend.last_since_revision(), Some(None));
482        backend.ready(None).unwrap();
483        assert_eq!(backend.last_since_revision(), Some(Some(41)));
484        backend.list(ListQuery::default()).unwrap();
485        assert_eq!(backend.last_since_revision(), Some(None));
486        let seen = seen.lock().unwrap();
487        assert_eq!(seen.len(), 3);
488        assert_eq!(seen[0], None);
489        assert_eq!(seen[1], Some(json!(41)));
490        assert_eq!(seen[2], None);
491    }
492
493    fn serve_methods(path: &std::path::Path, root: String) {
494        let listener = UnixListener::bind(path).unwrap();
495        thread::spawn(move || {
496            let (stream, _) = listener.accept().unwrap();
497            let mut reader = BufReader::new(stream.try_clone().unwrap());
498            let mut writer = stream;
499            while let Ok((payload, framing)) = read_message(&mut reader) {
500                let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
501                let result = match req.method.as_str() {
502                    "initialize" => json!({
503                        "protocolVersion":1,"capabilities":[],"root":root,
504                        "prefix":"Software","generation":2,"revision":3,"identity":"tui"
505                    }),
506                    "issue/get" | "issue/show" | "issue/open" => json!({
507                        "id":"atlas-1a2b","project":"atlas","title":"t","state":"TODO",
508                        "priority":"B","properties":{},"org_tags":[],"tags":[],
509                        "blocked_by":[],"parent":null,"claimed_by":null,"claimed_at":null,
510                        "file":"f","line_start":1,"line_end":2,"revision":3
511                    }),
512                    "issue/excerpt" => json!({
513                        "id":"atlas-1a2b","file":"f","line_start":1,"line_end":2,
514                        "text":"body","suppressed":false
515                    }),
516                    "issue/search" | "issue/claims" | "issue/agenda" | "issue/related" => {
517                        json!([])
518                    }
519                    "issue/tree" => json!({
520                        "id":"atlas-1a2b","state":"TODO","title":"t",
521                        "children":[],"blocked_by":[]
522                    }),
523                    "project/list" => json!({"projects":["atlas"],"revision":3}),
524                    "issue/claim" | "issue/note" | "issue/update" => json!({
525                        "ok":true,"report":"ok","issue":null,"revision":4,"generation":3
526                    }),
527                    "issue/list" | "issue/ready" => json!({
528                        "issues":[],"total":0,"matched":0,"revision":3,
529                        "generation":2,"unchanged":false
530                    }),
531                    other => panic!("unexpected {other}"),
532                };
533                let body = json!({"jsonrpc":"2.0","id":req.id,"result":result});
534                write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
535                writer.flush().unwrap();
536            }
537        });
538    }
539
540    #[test]
541    fn control_verbs_roundtrip() {
542        let dir = tempfile::tempdir().unwrap();
543        let sock = dir.path().join("control.sock");
544        let layout = Layout::new(dir.path().join("vault"), "Software");
545        serve_methods(&sock, layout.root().display().to_string());
546        let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
547        assert_eq!(backend.get("atlas-1a2b").unwrap().id, "atlas-1a2b");
548        assert_eq!(backend.excerpt("atlas-1a2b").unwrap().text, "body");
549        assert!(backend.search("x", 5).unwrap().is_empty());
550        assert!(backend.claims(None, None).unwrap().is_empty());
551        assert!(backend.agenda(14, None).unwrap().is_empty());
552        assert_eq!(backend.tree("atlas-1a2b").unwrap().id, "atlas-1a2b");
553        assert!(backend.related("atlas-1a2b", 2, 5).unwrap().is_empty());
554        assert_eq!(backend.projects().unwrap(), ["atlas"]);
555        assert!(backend.claim("atlas-1a2b", false).unwrap().ok);
556        assert!(backend.note("atlas-1a2b", "hi").unwrap().ok);
557        assert!(
558            backend
559                .update(UpdateReq {
560                    id: "atlas-1a2b".into(),
561                    state: Some("STARTED".into()),
562                    ..UpdateReq::default()
563                })
564                .unwrap()
565                .ok
566        );
567        assert_eq!(backend.open("atlas-1a2b").unwrap().id, "atlas-1a2b");
568        assert_eq!(backend.wait(3, 5).unwrap(), 3);
569    }
570
571    #[test]
572    fn after_claim_next_list_sends_page_revision_not_head() {
573        let dir = tempfile::tempdir().unwrap();
574        let sock = dir.path().join("control.sock");
575        let layout = Layout::new(dir.path().join("vault"), "Software");
576        let seen = Arc::new(Mutex::new(Vec::new()));
577        let seen_cb = Arc::clone(&seen);
578        let root = layout.root().display().to_string();
579        let listener = UnixListener::bind(&sock).unwrap();
580        thread::spawn(move || {
581            let (stream, _) = listener.accept().unwrap();
582            let mut reader = BufReader::new(stream.try_clone().unwrap());
583            let mut writer = stream;
584            while let Ok((payload, framing)) = read_message(&mut reader) {
585                let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
586                let result = match req.method.as_str() {
587                    "initialize" => json!({
588                        "protocolVersion":1,"capabilities":[],"root":root,
589                        "prefix":"Software","generation":2,"revision":10,"identity":"tui"
590                    }),
591                    "issue/ready" | "issue/list" => {
592                        let since = req
593                            .params
594                            .as_ref()
595                            .and_then(|p| p.get("since_revision"))
596                            .cloned();
597                        seen_cb.lock().unwrap().push(since);
598                        json!({
599                            "issues":[{
600                                "id":"atlas-2c3d","state":"TODO","priority":"B",
601                                "title":"Emit a summary table","project":"atlas",
602                                "blocked_by":[],"claimed_by":null,"claimed_at":null
603                            }],
604                            "total":1,"matched":1,"revision":10,
605                            "generation":2,"unchanged":false
606                        })
607                    }
608                    "issue/claim" => json!({
609                        "ok":true,"report":"claimed","issue":null,
610                        "revision":11,"generation":3
611                    }),
612                    other => panic!("unexpected {other}"),
613                };
614                let body = json!({"jsonrpc":"2.0","id":req.id,"result":result});
615                write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
616                writer.flush().unwrap();
617            }
618        });
619
620        let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
621        let page = backend.ready(None).unwrap();
622        assert_eq!(page.issues[0].id, "atlas-2c3d");
623        assert_eq!(backend.last_since_revision(), Some(None));
624        assert!(backend.claim("atlas-2c3d", false).unwrap().ok);
625        assert_eq!(backend.revision(), 11);
626        backend.ready(None).unwrap();
627        assert_eq!(backend.last_since_revision(), Some(Some(10)));
628        let seen = seen.lock().unwrap();
629        assert_eq!(seen[0], None);
630        assert_eq!(seen[1], Some(json!(10)));
631    }
632
633    #[test]
634    fn root_mismatch_refuses_the_socket() {
635        let dir = tempfile::tempdir().unwrap();
636        let sock = dir.path().join("control.sock");
637        let layout = Layout::new(dir.path().join("vault"), "Software");
638        let listener = UnixListener::bind(&sock).unwrap();
639        thread::spawn(move || {
640            let (stream, _) = listener.accept().unwrap();
641            let mut reader = BufReader::new(stream.try_clone().unwrap());
642            let mut writer = stream;
643            let (payload, framing) = read_message(&mut reader).unwrap();
644            let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
645            let body = json!({
646                "jsonrpc": "2.0",
647                "id": req.id,
648                "result": {
649                    "protocolVersion": 1,
650                    "capabilities": [],
651                    "root": "/other/vault",
652                    "prefix": "Software",
653                    "generation": 1,
654                    "revision": 1,
655                    "identity": "tui"
656                }
657            });
658            write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
659            writer.flush().unwrap();
660        });
661        let err = match ControlBackend::connect(&sock, &layout, "tui") {
662            Ok(_) => panic!("expected root mismatch"),
663            Err(err) => err,
664        };
665        match err {
666            ControlAttachError::Mismatch { got_root, .. } => {
667                assert_eq!(got_root, "/other/vault");
668            }
669            other => panic!("{other:?}"),
670        }
671    }
672}