Skip to main content

packset_client/
lib.rs

1//! Loopback HTTP client for packsetd.
2//!
3//! Reads `PACKSET_URL` or `INSIDE_MEMORY_URL`. search/get against packsetd; no SQLite.
4//! Does not open LMDB.
5
6use serde::{Deserialize, Serialize};
7use std::env;
8use std::error::Error as StdError;
9use std::path::PathBuf;
10use std::time::Duration;
11
12/// How long one request may take: `PACKSET_TIMEOUT_MS`, else thirty seconds.
13/// A write that waits behind thirty others on a busy seat is late, not failed.
14fn timeout() -> Duration {
15    std::env::var("PACKSET_TIMEOUT_MS")
16        .ok()
17        .and_then(|v| v.trim().parse::<u64>().ok())
18        .filter(|ms| *ms > 0)
19        .map_or(Duration::from_secs(30), Duration::from_millis)
20}
21
22/// The wait before a down or busy writer is asked again, tripled each time,
23/// and never longer than five seconds. The first waits are 50, 150 and 450 ms.
24const BUSY_WAIT: Duration = Duration::from_millis(50);
25/// Each wait is this many times the last.
26const BUSY_GROWTH: u32 = 3;
27/// The longest pause between asks.
28const BUSY_WAIT_CAP: Duration = Duration::from_secs(5);
29
30/// Whether another ask still ends inside `budget`.
31///
32/// `waited` is the sum of the waits already taken, not the clock. A round
33/// trip that is slow on one host does not spend the next wait, so every
34/// host asks the same number of times for one budget.
35fn ask_again(waited: Duration, wait: Duration, budget: Duration) -> bool {
36    waited.saturating_add(wait) < budget
37}
38
39/// How many times [`send`] asks inside `budget`. The first ask costs no
40/// wait. Each later ask costs the next backoff, and a wait that would pass
41/// the budget is not taken.
42fn attempts_in(budget: Duration) -> u32 {
43    let mut waited = Duration::ZERO;
44    let mut wait = BUSY_WAIT;
45    let mut attempts = 1u32;
46    while ask_again(waited, wait, budget) {
47        waited = waited.saturating_add(wait);
48        wait = (wait * BUSY_GROWTH).min(BUSY_WAIT_CAP);
49        attempts = attempts.saturating_add(1);
50    }
51    attempts
52}
53
54/// What [`send`] could not turn into a response.
55enum Attempt {
56    /// The writer is down or still busy after the budget. One sentence.
57    Plain(String),
58    /// Any other failure, for the caller to read.
59    Ureq(Box<ureq::Error>),
60}
61
62/// Send `req`, with `body` when there is one, inside `budget`. A connection
63/// refused, a 429, or a 503 is asked again for every wait that fits in the
64/// budget. The waits are what the budget spends. A slow round trip does not
65/// drop an ask. The writer answers 503 only when its queue or its lane is
66/// full, before any of the request runs, so a write sent again is not stored
67/// twice. The first refusal of a loopback writer starts the local daemon
68/// once, when this seat has a home and a `packsetd` binary.
69fn send(
70    req: ureq::Request,
71    body: Option<&serde_json::Value>,
72    budget: Duration,
73) -> Result<ureq::Response, Attempt> {
74    let attempts = attempts_in(budget);
75    let mut waited = Duration::ZERO;
76    let mut wait = BUSY_WAIT;
77    let mut asked = 0u32;
78    let mut started_daemon = false;
79    loop {
80        asked = asked.saturating_add(1);
81        let left = budget.saturating_sub(waited).max(Duration::from_millis(1));
82        let mut attempt = req.clone().timeout(left);
83        if started_daemon {
84            if let Some(token) = token() {
85                attempt = attempt.set("Authorization", &format!("Bearer {token}"));
86            }
87        }
88        let answered = match body {
89            Some(json) => attempt.send_json(json),
90            None => attempt.call(),
91        };
92        let again = asked < attempts;
93        match answered {
94            Err(ureq::Error::Status(code, _)) if (code == 503 || code == 429) && again => {
95                std::thread::sleep(wait);
96                waited = waited.saturating_add(wait);
97                wait = (wait * BUSY_GROWTH).min(BUSY_WAIT_CAP);
98            }
99            Err(ureq::Error::Status(code, response)) if code == 503 || code == 429 => {
100                return Err(Attempt::Plain(busy_line(response)));
101            }
102            Err(err) if connect_refused(&err) && again => {
103                if !started_daemon {
104                    started_daemon = autostart(req.url());
105                }
106                std::thread::sleep(wait);
107                waited = waited.saturating_add(wait);
108                wait = (wait * BUSY_GROWTH).min(BUSY_WAIT_CAP);
109            }
110            Err(err) if connect_refused(&err) => {
111                return Err(Attempt::Plain("packsetd is not running.".to_string()));
112            }
113            other => return other.map_err(|err| Attempt::Ureq(Box::new(err))),
114        }
115    }
116}
117
118/// The writer's own reason, as one sentence.
119fn busy_line(response: ureq::Response) -> String {
120    let text = response.into_string().unwrap_or_default();
121    let reason = serde_json::from_str::<serde_json::Value>(&text)
122        .ok()
123        .and_then(|value| {
124            value
125                .get("error")
126                .and_then(|reason| reason.as_str())
127                .map(str::to_string)
128        })
129        .unwrap_or(text);
130    let reason = reason.trim();
131    if reason.is_empty() || reason.eq_ignore_ascii_case("busy") {
132        "packsetd is busy.".to_string()
133    } else if reason.ends_with('.') {
134        format!("packsetd is busy: {reason}")
135    } else {
136        format!("packsetd is busy: {reason}.")
137    }
138}
139
140/// Whether `err` is a TCP refusal. A reset or a timeout is a different failure.
141fn connect_refused(err: &ureq::Error) -> bool {
142    let ureq::Error::Transport(transport) = err else {
143        return false;
144    };
145    if transport
146        .source()
147        .and_then(|source| source.downcast_ref::<std::io::Error>())
148        .is_some_and(|io| io.kind() == std::io::ErrorKind::ConnectionRefused)
149    {
150        return true;
151    }
152    err.to_string().contains("Connection refused")
153}
154
155fn into_error(url: &str, err: Attempt) -> Error {
156    match err {
157        Attempt::Plain(text) => Error::Unavailable(text),
158        Attempt::Ureq(err) => refused(url, err),
159    }
160}
161
162fn path_seg(id: &str) -> String {
163    let mut out = String::with_capacity(id.len());
164    for b in id.bytes() {
165        match b {
166            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
167                out.push(b as char)
168            }
169            _ => out.push_str(&format!("%{b:02X}")),
170        }
171    }
172    out
173}
174
175/// The port a writer listens on when nothing names one. The command line,
176/// the server and this client agree on it, so a seat needs no variable set.
177pub const DEFAULT_PORT: u16 = 8761;
178
179/// Load `~/.config/ljos/env` (KEY=VALUE) when the process has not set
180/// those keys. `ljos`, `packset`, and `packset-mcp` then share one pack.
181pub fn load_seat_env() {
182    let Some(home) = env::var_os("HOME") else {
183        return;
184    };
185    let path = PathBuf::from(home).join(".config/ljos/env");
186    let Ok(text) = std::fs::read_to_string(path) else {
187        return;
188    };
189    for line in text.lines() {
190        let line = line.trim();
191        if line.is_empty() || line.starts_with('#') {
192            continue;
193        }
194        let Some((k, v)) = line.split_once('=') else {
195            continue;
196        };
197        let k = k.trim();
198        if k.is_empty() || env::var_os(k).is_some() {
199            continue;
200        }
201        env::set_var(k, v.trim());
202    }
203}
204
205/// The workspace the seat's memory lives in: `PACKSET_WORKSPACE` after
206/// loading the seat env file, else `seat`. Not `default`, and not the
207/// working directory's git remote: those two are how one harness's
208/// remember missed the other's sitting.
209#[must_use]
210pub fn resolved_workspace() -> String {
211    load_seat_env();
212    env::var("PACKSET_WORKSPACE")
213        .ok()
214        .map(|w| w.trim().to_string())
215        .filter(|w| !w.is_empty())
216        .unwrap_or_else(|| "seat".to_string())
217}
218
219/// `PACKSET_PORT` (`LJOS_MEM_PORT` is an alias), else [`DEFAULT_PORT`].
220#[must_use]
221pub fn default_port() -> u16 {
222    env::var("PACKSET_PORT")
223        .or_else(|_| env::var("LJOS_MEM_PORT"))
224        .ok()
225        .and_then(|raw| raw.trim().parse().ok())
226        .unwrap_or(DEFAULT_PORT)
227}
228
229/// The token file a writer leaves in its pack home: `PACKSET_TOKEN_FILE`,
230/// else `token` under `PACKSET_HOME` (`LJOS_HOME`,
231/// `LJOS_INSIDE_MEMORY_HOME`), else under the default home
232/// ([`packset_core::home::resolve`]). packsetd resolves its home the same
233/// way.
234#[must_use]
235pub fn token_path() -> Option<PathBuf> {
236    if let Some(file) = env::var_os("PACKSET_TOKEN_FILE").filter(|v| !v.is_empty()) {
237        return Some(PathBuf::from(file));
238    }
239    packset_core::home::resolve().map(|home| home.join(TOKEN_FILE))
240}
241
242/// The name of the token file in a pack home.
243pub const TOKEN_FILE: &str = "token";
244
245/// The token this seat shows the writer: `PACKSET_TOKEN`, else the first
246/// line of [`token_path`]. `None` when there is neither, and the writer then
247/// answers 401 to everything but `/health`.
248#[must_use]
249pub fn token() -> Option<String> {
250    if let Ok(t) = env::var("PACKSET_TOKEN") {
251        let t = t.trim().to_string();
252        if !t.is_empty() {
253            return Some(t);
254        }
255    }
256    let text = std::fs::read_to_string(token_path()?).ok()?;
257    let t = text.lines().next()?.trim().to_string();
258    (!t.is_empty()).then_some(t)
259}
260
261/// Start the local writer once. `true` when a process was spawned.
262///
263/// A home has to be named (`PACKSET_HOME` or an older alias). The binary is
264/// `PACKSET_BIN`, else `packsetd` beside this program, else on `PATH`.
265fn autostart(url: &str) -> bool {
266    use std::sync::atomic::{AtomicBool, Ordering};
267    static STARTED: AtomicBool = AtomicBool::new(false);
268    if STARTED.load(Ordering::Acquire) {
269        return false;
270    }
271    let Some(port) = loopback_port(url) else {
272        return false;
273    };
274    load_seat_env();
275    let Some(home) = packset_core::home::named_home() else {
276        return false;
277    };
278    let Some(bin) = daemon_binary() else {
279        return false;
280    };
281    if STARTED.swap(true, Ordering::AcqRel) {
282        return false;
283    }
284    spawn_daemon(&bin, &home, port)
285}
286
287/// `packsetd`, if this seat has one.
288fn daemon_binary() -> Option<PathBuf> {
289    if let Some(named) = env::var_os("PACKSET_BIN").filter(|value| !value.is_empty()) {
290        let path = PathBuf::from(named);
291        if path.is_file() {
292            return Some(path);
293        }
294    }
295    if let Some(beside) = env::current_exe()
296        .ok()
297        .and_then(|exe| exe.parent().map(|dir| dir.join("packsetd")))
298        .filter(|path| path.is_file())
299    {
300        return Some(beside);
301    }
302    let paths = env::var_os("PATH")?;
303    env::split_paths(&paths)
304        .map(|dir| dir.join("packsetd"))
305        .find(|path| path.is_file())
306}
307
308/// Spawn `bin` on `port` for `home`. The child leaves the caller's session
309/// on Unix so the client's exit does not take the writer with it.
310fn spawn_daemon(bin: &std::path::Path, home: &std::path::Path, port: u16) -> bool {
311    use std::process::{Command, Stdio};
312    let mut cmd = Command::new(bin);
313    cmd.arg("--home")
314        .arg(home)
315        .arg("--port")
316        .arg(port.to_string())
317        .stdin(Stdio::null());
318    if let Ok(log) = std::fs::OpenOptions::new()
319        .create(true)
320        .append(true)
321        .open(home.join("packsetd.log"))
322    {
323        if let Ok(errs) = log.try_clone() {
324            cmd.stdout(Stdio::from(log)).stderr(Stdio::from(errs));
325        }
326    } else {
327        cmd.stdout(Stdio::null()).stderr(Stdio::null());
328    }
329    #[cfg(unix)]
330    {
331        use std::os::unix::process::CommandExt;
332        // SAFETY: `pre_exec` runs in the forked child before exec. `setsid`
333        // there detaches the writer from this client's session.
334        unsafe {
335            cmd.pre_exec(|| {
336                extern "C" {
337                    fn setsid() -> i32;
338                }
339                if setsid() == -1 {
340                    return Err(std::io::Error::last_os_error());
341                }
342                Ok(())
343            });
344        }
345    }
346    cmd.spawn().is_ok()
347}
348
349/// `request` with this seat's token as a bearer header. A caller that speaks
350/// to the writer outside [`PacksetClient`] wraps its request in this.
351///
352/// The token stays home when another user's process holds the writer's
353/// loopback port: anyone can answer `/health` with `packsetd ok`, and a
354/// listener that got there first would otherwise collect the token.
355pub fn authorize(request: ureq::Request) -> ureq::Request {
356    if foreign_listener(request.url()) {
357        return request;
358    }
359    match token() {
360        Some(t) => request.set("Authorization", &format!("Bearer {t}")),
361        None => request,
362    }
363}
364
365/// The port of a loopback `url`, `None` for any other host.
366///
367/// The foreign-listener check is Linux only. Starting a local writer uses
368/// the same parse on every host.
369fn loopback_port(url: &str) -> Option<u16> {
370    let rest = url
371        .strip_prefix("http://")
372        .or_else(|| url.strip_prefix("https://"))?;
373    let authority = rest.split(['/', '?', '#']).next()?;
374    let (host, port) = match authority.rsplit_once(':') {
375        Some((host, port)) if !port.contains(']') => (host, port.parse().ok()?),
376        _ => (authority, 80),
377    };
378    matches!(host, "127.0.0.1" | "localhost" | "[::1]").then_some(port)
379}
380
381/// The owners of the sockets listening on `port`, from one `/proc/net/tcp`
382/// or `tcp6` table: local address is the second column, state the fourth,
383/// uid the eighth.
384#[cfg(target_os = "linux")]
385fn listen_uids(table: &str, port: u16) -> Vec<u32> {
386    table
387        .lines()
388        .skip(1)
389        .filter_map(|line| {
390            let fields: Vec<&str> = line.split_whitespace().collect();
391            let local = fields.get(1)?;
392            if fields.get(3) != Some(&"0A") {
393                return None;
394            }
395            let hex = local.rsplit(':').next()?;
396            if u16::from_str_radix(hex, 16).ok()? != port {
397                return None;
398            }
399            fields.get(7)?.parse().ok()
400        })
401        .collect()
402}
403
404/// Whether every socket listening on the loopback port of `url` belongs to
405/// a user other than this one. Unknown is not foreign: off Linux, or when
406/// the tables cannot be read, the token goes as before.
407fn foreign_listener(url: &str) -> bool {
408    #[cfg(target_os = "linux")]
409    {
410        use std::os::unix::fs::MetadataExt;
411        let Some(port) = loopback_port(url) else {
412            return false;
413        };
414        let Ok(me) = std::fs::metadata("/proc/self").map(|m| m.uid()) else {
415            return false;
416        };
417        let owners: Vec<u32> = ["/proc/net/tcp", "/proc/net/tcp6"]
418            .iter()
419            .filter_map(|t| std::fs::read_to_string(t).ok())
420            .flat_map(|text| listen_uids(&text, port))
421            .collect();
422        !owners.is_empty() && owners.iter().all(|uid| *uid != me)
423    }
424    #[cfg(not(target_os = "linux"))]
425    {
426        let _ = url;
427        false
428    }
429}
430
431#[derive(Debug, thiserror::Error)]
432pub enum Error {
433    #[error("packset url missing")]
434    NoUrl,
435    #[error("http: {0}")]
436    Http(#[from] Box<ureq::Error>),
437    #[error("io: {0}")]
438    Io(#[from] std::io::Error),
439    #[error("json: {0}")]
440    Json(#[from] serde_json::Error),
441    #[error("bad response: {0}")]
442    Bad(String),
443    /// The writer was down or busy for the whole budget. One sentence.
444    #[error("{0}")]
445    Unavailable(String),
446}
447
448#[derive(Debug, Clone)]
449pub struct PacksetClient {
450    base: String,
451    /// A workspace pinned by the caller; `None` reads the environment and
452    /// the working directory.
453    workspace: Option<String>,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct Hit {
458    pub id: Option<String>,
459    pub text: String,
460    #[serde(default)]
461    pub score: f64,
462    #[serde(default)]
463    pub kind: String,
464    /// When the memory was written, the writer's clock, RFC 3339. Absent
465    /// on card paragraphs, which have no clock.
466    #[serde(default)]
467    pub ts: Option<String>,
468    /// How many of the panel's ballots named this hit, and how many ran.
469    /// Two of three is agreement; one of three is one scorer's opinion.
470    /// The atom's entities: `seat:<name>` names the seat that wrote it,
471    /// `persona:<name>` a persona's own claim, `habit:<name>` a reading.
472    #[serde(default)]
473    pub entities: Vec<String>,
474    #[serde(default)]
475    pub ballots: Option<u32>,
476    #[serde(default)]
477    pub of: Option<u32>,
478    /// The cross-encoder score when the second stage scored this hit.
479    /// Absent, or null, when it did not.
480    #[serde(default)]
481    pub rerank_score: Option<f64>,
482    /// Labels on the claim, `flag:credential-shaped` and the caller's own.
483    /// Empty when the hit has none, including a writer that omits the field.
484    #[serde(default)]
485    pub labels: Vec<String>,
486}
487
488/// A refusal, carrying the reason the writer gave in its body.
489fn refused(url: &str, e: Box<ureq::Error>) -> Error {
490    match *e {
491        ureq::Error::Status(code, response) => {
492            let text = response.into_string().unwrap_or_default();
493            let reason = serde_json::from_str::<serde_json::Value>(&text)
494                .ok()
495                .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
496                .unwrap_or(text);
497            let reason = reason.trim();
498            if code == 401 {
499                return Error::Bad(format!(
500                    "{url}: 401: {reason}; this seat's token does not match the writer's, \
501                     so it is another user's writer or PACKSET_HOME names another pack"
502                ));
503            }
504            if reason.is_empty() {
505                Error::Bad(format!("{url}: status code {code}"))
506            } else {
507                Error::Bad(format!("{url}: {code}: {reason}"))
508            }
509        }
510        other => Error::Http(Box::new(other)),
511    }
512}
513
514impl PacksetClient {
515    pub fn new(base: impl Into<String>) -> Self {
516        let mut base = base.into();
517        while base.ends_with('/') {
518            base.pop();
519        }
520        Self {
521            base,
522            workspace: None,
523        }
524    }
525
526    /// Pin the workspace this client speaks for, ahead of `PACKSET_WORKSPACE`
527    /// and the working directory. A seat that is one memory across every
528    /// repository it works in sets this once.
529    #[must_use]
530    pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
531        let workspace = workspace.into();
532        self.workspace = (!workspace.is_empty()).then_some(workspace);
533        self
534    }
535
536    /// The writer the seat talks to, with nothing set: `PACKSET_URL`
537    /// (`INSIDE_MEMORY_URL` is an alias), else the loopback port the command
538    /// line starts a writer on, `PACKSET_PORT` (`LJOS_MEM_PORT`) or 8761.
539    /// `PACKSET_URL=off` is the one way to have no pack.
540    pub fn from_env() -> Result<Self, Error> {
541        load_seat_env();
542        let url = env::var("PACKSET_URL")
543            .or_else(|_| env::var("INSIDE_MEMORY_URL"))
544            .ok()
545            .filter(|url| !url.is_empty());
546        match url {
547            Some(url) if url == "off" => Err(Error::NoUrl),
548            Some(url) => Ok(Self::new(url)),
549            None => Ok(Self::new(format!("http://127.0.0.1:{}", default_port()))),
550        }
551    }
552
553    pub fn base(&self) -> &str {
554        &self.base
555    }
556
557    pub fn workspace(&self) -> String {
558        if let Some(w) = &self.workspace {
559            return w.clone();
560        }
561        if let Ok(w) = env::var("PACKSET_WORKSPACE") {
562            if !w.is_empty() {
563                return w;
564            }
565        }
566        let cwd = env::var("HOSTSEAT_WORKSPACE")
567            .ok()
568            .map(|s| s.trim().to_string())
569            .filter(|s| !s.is_empty())
570            .map(std::path::PathBuf::from)
571            .or_else(|| env::current_dir().ok())
572            .unwrap_or_else(|| std::path::PathBuf::from("."));
573        self.workspace_for_cwd(&cwd)
574    }
575
576    /// Workspace id from `/v1/identity` for `cwd`, or `dir:<abs>` if that call fails.
577    pub fn workspace_for_cwd(&self, cwd: &std::path::Path) -> String {
578        let abs = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
579        let url = format!("{}/v1/identity", self.base);
580        let body = send(
581            authorize(ureq::get(&url)).query("cwd", abs.to_string_lossy().as_ref()),
582            None,
583            timeout(),
584        )
585        .ok()
586        .and_then(|r| r.into_string().ok());
587        if let Some(body) = body {
588            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
589                if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
590                    if !ws.is_empty() {
591                        return ws.to_string();
592                    }
593                }
594            }
595        }
596        format!("dir:{}", abs.display())
597    }
598
599    pub fn health(&self) -> Result<String, Error> {
600        let url = format!("{}/health", self.base);
601        let body = send(authorize(ureq::get(&url)), None, timeout())
602            .map_err(|e| into_error(&url, e))?
603            .into_string()?;
604        Ok(body)
605    }
606
607    pub fn get_atom(&self, workspace: &str, id: &str) -> Result<serde_json::Value, Error> {
608        let encoded = path_seg(id);
609        let url = format!("{}/v1/atoms/{encoded}", self.base);
610        let resp = match send(
611            authorize(ureq::get(&url)).query("workspace", workspace),
612            None,
613            timeout(),
614        ) {
615            Ok(resp) => resp,
616            Err(Attempt::Ureq(e)) if matches!(*e, ureq::Error::Status(404, _)) => {
617                return Err(Error::Bad(format!("no atom {id}")));
618            }
619            Err(e) => return Err(into_error(&url, e)),
620        };
621        Ok(resp.into_json()?)
622    }
623
624    pub fn list_atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
625        self.atoms_as_of(workspace, None)
626    }
627
628    /// Live-now atoms, or the ones that were live at `as_of`.
629    ///
630    /// # Errors
631    ///
632    /// The request's, or a body that is not JSON.
633    pub fn atoms_as_of(
634        &self,
635        workspace: &str,
636        as_of: Option<&str>,
637    ) -> Result<Vec<serde_json::Value>, Error> {
638        let url = format!("{}/v1/atoms", self.base);
639        let mut req = authorize(ureq::get(&url)).query("workspace", workspace);
640        if let Some(at) = as_of {
641            req = req.query("as_of", at);
642        }
643        let mut body: serde_json::Value = send(req, None, timeout())
644            .map_err(|e| into_error(&url, e))?
645            .into_json()?;
646        let atoms = body
647            .get_mut("atoms")
648            .map(serde_json::Value::take)
649            .unwrap_or(serde_json::Value::Array(vec![]));
650        Ok(serde_json::from_value(atoms)?)
651    }
652
653    /// [`Self::atoms_as_of`] without the dense vectors, for a reader of
654    /// texts, review clocks or rules: the vectors are most of the bytes. A
655    /// writer older than the `embedding=omit` query sends them anyway.
656    ///
657    /// # Errors
658    ///
659    /// The pack not answering, or an answer that is not atoms.
660    pub fn atoms_without_vectors(
661        &self,
662        workspace: &str,
663        as_of: Option<&str>,
664    ) -> Result<Vec<serde_json::Value>, Error> {
665        let url = format!("{}/v1/atoms", self.base);
666        let mut req = authorize(ureq::get(&url))
667            .query("workspace", workspace)
668            .query("embedding", "omit");
669        if let Some(at) = as_of {
670            req = req.query("as_of", at);
671        }
672        let mut body: serde_json::Value = send(req, None, timeout())
673            .map_err(|e| into_error(&url, e))?
674            .into_json()?;
675        let atoms = body
676            .get_mut("atoms")
677            .map(serde_json::Value::take)
678            .unwrap_or(serde_json::Value::Array(vec![]));
679        Ok(serde_json::from_value(atoms)?)
680    }
681
682    pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
683        self.search_as_of(workspace, q, limit, None)
684    }
685
686    /// Ranked hits, optionally over the atoms that were live at `as_of`.
687    ///
688    /// # Errors
689    ///
690    /// The request's, or a body that is not JSON.
691    pub fn search_as_of(
692        &self,
693        workspace: &str,
694        q: &str,
695        limit: u32,
696        as_of: Option<&str>,
697    ) -> Result<Vec<Hit>, Error> {
698        self.search_opts(workspace, q, limit, as_of, false)
699    }
700
701    /// Ranked hits, optionally dated and optionally through the measured
702    /// cross-encoder stage.
703    ///
704    /// Off by default. On, the writer spends a forward pass per candidate and
705    /// the request waits for that rather than the usual five-second budget.
706    ///
707    /// # Errors
708    ///
709    /// The request's, or a body that is not a hit list.
710    pub fn search_opts(
711        &self,
712        workspace: &str,
713        q: &str,
714        limit: u32,
715        as_of: Option<&str>,
716        rerank: bool,
717    ) -> Result<Vec<Hit>, Error> {
718        self.search_ranked(workspace, q, limit, as_of, rerank, None)
719    }
720
721    /// [`Self::search_opts`] with a rank. `Some("relevance")` asks for the
722    /// top k by lexical, BM25 and cosine, with no half-life and no
723    /// retrievability. `None` leaves the shipped order. An older writer
724    /// ignores the parameter; `GET /v1/status` lists `search.ranks` when
725    /// this writer honours it.
726    ///
727    /// # Errors
728    ///
729    /// The request's, including a rank the writer refuses, or a body that
730    /// is not a hit list.
731    pub fn search_ranked(
732        &self,
733        workspace: &str,
734        q: &str,
735        limit: u32,
736        as_of: Option<&str>,
737        rerank: bool,
738        rank: Option<&str>,
739    ) -> Result<Vec<Hit>, Error> {
740        let url = format!("{}/v1/search", self.base);
741        let budget = if rerank {
742            Duration::from_secs(60).max(timeout())
743        } else {
744            timeout()
745        };
746        let mut req = authorize(ureq::get(&url))
747            .query("workspace", workspace)
748            .query("q", q)
749            .query("limit", &limit.to_string());
750        if let Some(at) = as_of {
751            req = req.query("as_of", at);
752        }
753        if rerank {
754            req = req.query("rerank", "1");
755        }
756        if let Some(rank) = rank.map(str::trim).filter(|s| !s.is_empty()) {
757            req = req.query("rank", rank);
758        }
759        let body: serde_json::Value = send(req, None, budget)
760            .map_err(|e| into_error(&url, e))?
761            .into_json()?;
762        let hits = body
763            .get("hits")
764            .cloned()
765            .unwrap_or(serde_json::Value::Array(vec![]));
766        Ok(serde_json::from_value(hits)?)
767    }
768
769    /// Seat home, atom counts by kind, pin, index and embedder.
770    ///
771    /// # Errors
772    ///
773    /// The request's, or a body that is not JSON.
774    pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
775        let url = format!("{}/v1/status", self.base);
776        let mut req = authorize(ureq::get(&url));
777        if let Some(workspace) = workspace {
778            req = req.query("workspace", workspace);
779        }
780        Ok(send(req, None, timeout())
781            .map_err(|e| into_error(&url, e))?
782            .into_json()?)
783    }
784
785    /// The set a workspace is pinned to.
786    ///
787    /// # Errors
788    ///
789    /// The request's, or a body that is not JSON.
790    pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
791        let url = format!("{}/v1/pin", self.base);
792        Ok(send(
793            authorize(ureq::get(&url)).query("workspace", workspace),
794            None,
795            timeout(),
796        )
797        .map_err(|e| into_error(&url, e))?
798        .into_json()?)
799    }
800
801    /// Pin a workspace to a set.
802    ///
803    /// # Errors
804    ///
805    /// The request's, or a body that is not JSON.
806    pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
807        let url = format!("{}/v1/pin", self.base);
808        Ok(send(
809            authorize(ureq::put(&url)),
810            Some(&serde_json::json!({ "workspace": workspace, "name": name })),
811            timeout(),
812        )
813        .map_err(|e| into_error(&url, e))?
814        .into_json()?)
815    }
816
817    /// The deed accessions a workspace's live atoms cite, sorted.
818    ///
819    /// The accession is the only identifier crossing the tracker, the pack and
820    /// the deed store, so this is what `deedar evidence -` reads.
821    ///
822    /// # Errors
823    ///
824    /// The request's, or a body that is not JSON.
825    pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
826        let url = format!("{}/v1/accessions", self.base);
827        let body: serde_json::Value = send(
828            authorize(ureq::get(&url)).query("workspace", workspace),
829            None,
830            timeout(),
831        )
832        .map_err(|e| into_error(&url, e))?
833        .into_json()?;
834        let found = body
835            .get("accessions")
836            .cloned()
837            .unwrap_or(serde_json::Value::Array(vec![]));
838        Ok(serde_json::from_value(found)?)
839    }
840    /// Every live atom in a workspace.
841    ///
842    /// The bodies, not the join keys: this is what a handover carries when
843    /// somebody is given what the seat learned rather than only what it cites.
844    ///
845    /// # Errors
846    ///
847    /// The request's, or a body that is not JSON.
848    pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
849        self.atoms_as_of(workspace, None)
850    }
851
852    /// The live atoms of one kind in a workspace: personas, trust rows,
853    /// habits, without the rest of the pack.
854    ///
855    /// # Errors
856    ///
857    /// The request's, or a body that is not JSON.
858    pub fn atoms_of_kind(
859        &self,
860        workspace: &str,
861        kind: &str,
862    ) -> Result<Vec<serde_json::Value>, Error> {
863        let url = format!("{}/v1/atoms", self.base);
864        let body: serde_json::Value = send(
865            authorize(ureq::get(&url))
866                .query("workspace", workspace)
867                .query("kind", kind),
868            None,
869            timeout(),
870        )
871        .map_err(|e| into_error(&url, e))?
872        .into_json()?;
873        Ok(body
874            .get("atoms")
875            .and_then(serde_json::Value::as_array)
876            .cloned()
877            .unwrap_or_default())
878    }
879
880    /// The live atoms in a workspace that cite one deed accession.
881    ///
882    /// # Errors
883    ///
884    /// The request's, or a body that is not JSON.
885    pub fn citers(
886        &self,
887        workspace: &str,
888        accession: &str,
889    ) -> Result<Vec<serde_json::Value>, Error> {
890        let url = format!("{}/v1/citers", self.base);
891        let body: serde_json::Value = send(
892            authorize(ureq::get(&url))
893                .query("workspace", workspace)
894                .query("accession", accession),
895            None,
896            timeout(),
897        )
898        .map_err(|e| into_error(&url, e))?
899        .into_json()?;
900        let found = body
901            .get("atoms")
902            .cloned()
903            .unwrap_or(serde_json::Value::Array(vec![]));
904        Ok(serde_json::from_value(found)?)
905    }
906
907    /// Tombstone one atom. The daemon keeps the record and drops the index
908    /// entry, so a forgotten atom stops being recalled without the pack losing
909    /// the fact that it once held it.
910    ///
911    /// `why` is the deed accession that withdrew the claim, or the plain-text
912    /// reason when the seat has no accession. An accession is stored as
913    /// `retracted_by`. Plain text is stored as `why_text`, and the answer
914    /// carries one `warning` line. The retraction and what it retracted read
915    /// back together either way.
916    ///
917    /// # Errors
918    ///
919    /// [`Error::Bad`] when the workspace does not hold that atom, else the
920    /// request's or a body that is not JSON.
921    pub fn delete_atom(
922        &self,
923        workspace: &str,
924        id: &str,
925        why: Option<&str>,
926    ) -> Result<serde_json::Value, Error> {
927        let url = format!("{}/v1/atoms/delete", self.base);
928        let mut body = serde_json::json!({
929            "workspace": workspace,
930            "id": id,
931        });
932        if let Some(accession) = why {
933            body["why"] = serde_json::Value::String(accession.to_string());
934        }
935        let resp = match send(authorize(ureq::post(&url)), Some(&body), timeout()) {
936            Ok(resp) => resp,
937            Err(Attempt::Ureq(e)) if matches!(*e, ureq::Error::Status(404, _)) => {
938                return Err(Error::Bad(format!("no atom {id}")));
939            }
940            Err(e) => return Err(into_error(&url, e)),
941        };
942        Ok(resp.into_json()?)
943    }
944
945    /// Move one atom along the review clock: recalled, or lapsed.
946    pub fn grade(
947        &self,
948        workspace: &str,
949        id: &str,
950        recalled: bool,
951    ) -> Result<serde_json::Value, Error> {
952        let url = format!("{}/v1/grade", self.base);
953        let body: serde_json::Value = send(
954            authorize(ureq::post(&url)),
955            Some(&serde_json::json!({
956                "workspace": workspace,
957                "id": id,
958                "recalled": recalled,
959            })),
960            timeout(),
961        )
962        .map_err(|e| into_error(&url, e))?
963        .into_json()?;
964        Ok(body)
965    }
966
967    /// The link graph's communities, largest first.
968    /// The claims the link graph turns on, highest first.
969    pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
970        let url = format!("{}/v1/hubs", self.base);
971        let body: serde_json::Value = send(
972            authorize(ureq::get(&url))
973                .query("workspace", workspace)
974                .query("limit", &limit.to_string()),
975            None,
976            timeout(),
977        )
978        .map_err(|e| into_error(&url, e))?
979        .into_json()?;
980        Ok(body)
981    }
982
983    pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
984        let url = format!("{}/v1/islands", self.base);
985        let body: serde_json::Value = send(
986            authorize(ureq::get(&url)).query("workspace", workspace),
987            None,
988            timeout(),
989        )
990        .map_err(|e| into_error(&url, e))?
991        .into_json()?;
992        Ok(body)
993    }
994
995    /// Claims that fired together: their links gain weight.
996    pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
997        self.fire_as(workspace, ids, None)
998    }
999
1000    /// [`Self::fire`] through a persona's lens: the weights move under its
1001    /// name and the seat's stand.
1002    ///
1003    /// # Errors
1004    ///
1005    /// The request's, or a body that is not JSON.
1006    pub fn fire_as(
1007        &self,
1008        workspace: &str,
1009        ids: &[String],
1010        lens: Option<&str>,
1011    ) -> Result<serde_json::Value, Error> {
1012        let url = format!("{}/v1/fire", self.base);
1013        let body: serde_json::Value = send(
1014            authorize(ureq::post(&url)),
1015            Some(
1016                &serde_json::json!({"workspace": workspace, "ids": ids, "as": lens.unwrap_or("")}),
1017            ),
1018            timeout(),
1019        )
1020        .map_err(|e| into_error(&url, e))?
1021        .into_json()?;
1022        Ok(body)
1023    }
1024
1025    /// Consolidate the workspace: every claim that replaces an earlier one
1026    /// closes it (the write-time rule, run over what is held). `apply`
1027    /// false reports the pairs and writes nothing.
1028    pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
1029        let url = format!("{}/v1/consolidate", self.base);
1030        let body: serde_json::Value = send(
1031            authorize(ureq::post(&url)),
1032            Some(&serde_json::json!({"workspace": workspace, "apply": apply})),
1033            timeout(),
1034        )
1035        .map_err(|e| into_error(&url, e))?
1036        .into_json()?;
1037        Ok(body)
1038    }
1039
1040    /// Sweep a workspace for neglect: reviews left due past twice their
1041    /// interval lapse, and the third miss forgets a never-recalled lesson.
1042    ///
1043    /// # Errors
1044    ///
1045    /// The request's, or a body that is not JSON.
1046    pub fn sweep(&self, workspace: &str) -> Result<serde_json::Value, Error> {
1047        let url = format!("{}/v1/sweep", self.base);
1048        let body: serde_json::Value = send(
1049            authorize(ureq::post(&url)),
1050            Some(&serde_json::json!({"workspace": workspace})),
1051            timeout(),
1052        )
1053        .map_err(|e| into_error(&url, e))?
1054        .into_json()?;
1055        Ok(body)
1056    }
1057
1058    /// Drop a scratch workspace's live atoms, no deed; its tombstones stay.
1059    /// Retraction stays [`Self::delete_atom`]; this is what a per-run
1060    /// scratch workspace calls on its way out.
1061    ///
1062    /// # Errors
1063    ///
1064    /// The request's, or a body that is not JSON.
1065    pub fn forget_workspace(&self, workspace: &str) -> Result<serde_json::Value, Error> {
1066        let url = format!("{}/v1/forget", self.base);
1067        let body: serde_json::Value = send(
1068            authorize(ureq::post(&url)),
1069            Some(&serde_json::json!({"workspace": workspace})),
1070            timeout(),
1071        )
1072        .map_err(|e| into_error(&url, e))?
1073        .into_json()?;
1074        Ok(body)
1075    }
1076
1077    /// The memories a cue activates, strongest first; with `fire`, the top
1078    /// of them fire together.
1079    pub fn activate(
1080        &self,
1081        workspace: &str,
1082        q: &str,
1083        limit: u32,
1084        fire: bool,
1085    ) -> Result<serde_json::Value, Error> {
1086        self.activate_as(workspace, q, limit, fire, None)
1087    }
1088
1089    /// [`Self::activate`] through a persona's lens: the spread follows the
1090    /// weights that persona wrote, and a fire writes them.
1091    ///
1092    /// # Errors
1093    ///
1094    /// The request's, or a body that is not JSON.
1095    pub fn activate_as(
1096        &self,
1097        workspace: &str,
1098        q: &str,
1099        limit: u32,
1100        fire: bool,
1101        lens: Option<&str>,
1102    ) -> Result<serde_json::Value, Error> {
1103        let url = format!("{}/v1/activate", self.base);
1104        let mut req = authorize(ureq::get(&url))
1105            .query("workspace", workspace)
1106            .query("q", q)
1107            .query("limit", &limit.to_string())
1108            .query("fire", if fire { "1" } else { "0" });
1109        if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
1110            req = req.query("as", name);
1111        }
1112        let body: serde_json::Value = send(req, None, timeout())
1113            .map_err(|e| into_error(&url, e))?
1114            .into_json()?;
1115        Ok(body)
1116    }
1117
1118    /// The live atoms of one named set in a workspace: a persona's own
1119    /// conclusions, a pinned slice.
1120    ///
1121    /// # Errors
1122    ///
1123    /// The request's, or a body that is not JSON.
1124    pub fn atoms_in_set(
1125        &self,
1126        workspace: &str,
1127        set: &str,
1128    ) -> Result<Vec<serde_json::Value>, Error> {
1129        let url = format!("{}/v1/pack", self.base);
1130        let body: serde_json::Value = send(
1131            authorize(ureq::get(&url))
1132                .query("workspace", workspace)
1133                .query("set", set),
1134            None,
1135            timeout(),
1136        )
1137        .map_err(|e| into_error(&url, e))?
1138        .into_json()?;
1139        Ok(body
1140            .get("atoms")
1141            .and_then(serde_json::Value::as_array)
1142            .cloned()
1143            .unwrap_or_default())
1144    }
1145
1146    pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
1147        let url = format!("{}/v1/atoms", self.base);
1148        let body: serde_json::Value = send(authorize(ureq::post(&url)), Some(atom), timeout())
1149            .map_err(|e| into_error(&url, e))?
1150            .into_json()?;
1151        Ok(body)
1152    }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158
1159    // Both workspace tests below rewrite process-global environment, so
1160    // they hold this lock: without it the runner's parallel threads
1161    // interleave one test's set-and-restore with the other's read, and
1162    // the suite fails one run in five on a polluted read.
1163    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1164
1165    #[test]
1166    fn only_a_loopback_url_has_a_port_to_check() {
1167        assert_eq!(loopback_port("http://127.0.0.1:8761/health"), Some(8761));
1168        assert_eq!(loopback_port("http://localhost:18462"), Some(18462));
1169        assert_eq!(loopback_port("http://[::1]:8761/v1/search?q=a"), Some(8761));
1170        assert_eq!(loopback_port("http://127.0.0.1/health"), Some(80));
1171        assert_eq!(loopback_port("http://example.org:8761/"), None);
1172        assert_eq!(loopback_port("http://[::1]/"), Some(80));
1173    }
1174
1175    #[cfg(target_os = "linux")]
1176    #[test]
1177    fn the_owner_of_a_listening_port_is_read_off_the_table() {
1178        let table = "  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode
1179   0: 0100007F:223A 00000000:0000 0A 00000000:00000000 00:00000000 00000000  1001        0 4242 1 0 100 0 0 10 0
1180   1: 0100007F:223A 0100007F:9C40 01 00000000:00000000 00:00000000 00000000  1000        0 4343 1 0 20 4 30 10 -1
1181   2: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000  1000        0 4444 1 0 100 0 0 10 0
1182";
1183        assert_eq!(listen_uids(table, 8762), vec![1001], "only the listener");
1184        assert_eq!(listen_uids(table, 8080), vec![1000]);
1185        assert!(listen_uids(table, 9).is_empty());
1186    }
1187
1188    #[cfg(target_os = "linux")]
1189    #[test]
1190    fn a_port_this_user_holds_is_not_foreign() {
1191        let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1192        let port = held.local_addr().unwrap().port();
1193        assert!(!foreign_listener(&format!(
1194            "http://127.0.0.1:{port}/health"
1195        )));
1196        drop(held);
1197        assert!(
1198            !foreign_listener(&format!("http://127.0.0.1:{port}/health")),
1199            "nobody listening is not foreign"
1200        );
1201    }
1202
1203    #[test]
1204    fn resolved_workspace_reads_ljos_env_not_default() {
1205        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1206        let dir = std::env::temp_dir().join(format!("packset-ljos-env-{}", std::process::id()));
1207        std::fs::create_dir_all(dir.join(".config/ljos")).unwrap();
1208        std::fs::write(
1209            dir.join(".config/ljos/env"),
1210            "PACKSET_WORKSPACE=git:example.com/seat/notes\n",
1211        )
1212        .unwrap();
1213        let old_home = env::var("HOME").ok();
1214        let old_ws = env::var("PACKSET_WORKSPACE").ok();
1215        unsafe {
1216            env::remove_var("PACKSET_WORKSPACE");
1217            env::set_var("HOME", &dir);
1218        }
1219        let got = resolved_workspace();
1220        unsafe {
1221            match old_home {
1222                Some(h) => env::set_var("HOME", h),
1223                None => env::remove_var("HOME"),
1224            }
1225            match old_ws {
1226                Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1227                None => env::remove_var("PACKSET_WORKSPACE"),
1228            }
1229        }
1230        assert_eq!(got, "git:example.com/seat/notes");
1231    }
1232
1233    #[test]
1234    fn resolved_workspace_without_env_is_seat_not_default() {
1235        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1236        let dir = std::env::temp_dir().join(format!("packset-no-ljos-env-{}", std::process::id()));
1237        std::fs::create_dir_all(&dir).unwrap();
1238        let old_home = env::var("HOME").ok();
1239        let old_ws = env::var("PACKSET_WORKSPACE").ok();
1240        unsafe {
1241            env::remove_var("PACKSET_WORKSPACE");
1242            env::set_var("HOME", &dir);
1243        }
1244        let got = resolved_workspace();
1245        unsafe {
1246            match old_home {
1247                Some(h) => env::set_var("HOME", h),
1248                None => env::remove_var("HOME"),
1249            }
1250            match old_ws {
1251                Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1252                None => env::remove_var("PACKSET_WORKSPACE"),
1253            }
1254        }
1255        assert_eq!(got, "seat");
1256        assert_ne!(got, "default");
1257    }
1258
1259    /// A writer that answers busy to its first `busy` requests and counts every
1260    /// request.
1261    fn busy_for(
1262        busy: usize,
1263    ) -> (
1264        PacksetClient,
1265        std::sync::Arc<std::sync::atomic::AtomicUsize>,
1266    ) {
1267        use std::io::{BufRead, BufReader, Read, Write};
1268        use std::sync::atomic::Ordering;
1269        let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
1270        let client = PacksetClient::new(format!("http://{}", listener.local_addr().unwrap()));
1271        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1272        let counted = std::sync::Arc::clone(&seen);
1273        std::thread::spawn(move || {
1274            for stream in listener.incoming().flatten() {
1275                let mut reader = BufReader::new(&stream);
1276                let mut length = 0;
1277                let mut line = String::new();
1278                while reader.read_line(&mut line).is_ok_and(|n| n > 2) {
1279                    if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
1280                        length = v.trim().parse().unwrap_or(0);
1281                    }
1282                    line.clear();
1283                }
1284                let mut body = vec![0u8; length];
1285                let _ = reader.read_exact(&mut body);
1286                let (status, text) = if counted.fetch_add(1, Ordering::SeqCst) < busy {
1287                    ("503 Service Unavailable", r#"{"error":"busy"}"#)
1288                } else {
1289                    ("200 OK", r#"{"ok":true}"#)
1290                };
1291                let _ = (&stream).write_all(
1292                    format!(
1293                        "HTTP/1.1 {status}\r\nRetry-After: 1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
1294                        text.len()
1295                    )
1296                    .as_bytes(),
1297                );
1298            }
1299        });
1300        (client, seen)
1301    }
1302
1303    #[test]
1304    fn a_busy_writer_is_asked_again_for_a_read_and_a_write() {
1305        use std::sync::atomic::Ordering;
1306        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1307        let (client, seen) = busy_for(2);
1308        assert_eq!(client.status(None).unwrap()["ok"], true);
1309        assert_eq!(seen.load(Ordering::SeqCst), 3);
1310        let (client, seen) = busy_for(1);
1311        let stored = client
1312            .post_atom(&serde_json::json!({"text": "one write", "workspace": "w"}))
1313            .unwrap();
1314        assert_eq!(stored["ok"], true);
1315        assert_eq!(
1316            seen.load(Ordering::SeqCst),
1317            2,
1318            "the write was not sent again"
1319        );
1320    }
1321
1322    #[test]
1323    fn a_writer_that_stays_busy_fails_in_one_sentence() {
1324        use std::sync::atomic::Ordering;
1325        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1326        let old = env::var_os("PACKSET_TIMEOUT_MS");
1327        unsafe { env::set_var("PACKSET_TIMEOUT_MS", "700") };
1328        let (client, seen) = busy_for(usize::MAX);
1329        let err = client.status(None).unwrap_err().to_string();
1330        unsafe {
1331            match old {
1332                Some(value) => env::set_var("PACKSET_TIMEOUT_MS", value),
1333                None => env::remove_var("PACKSET_TIMEOUT_MS"),
1334            }
1335        }
1336        assert_eq!(err, "packsetd is busy.", "{err}");
1337        assert!(
1338            !err.contains("os error") && !err.contains("status code"),
1339            "{err}"
1340        );
1341        // The count is the waits that fit in 700 ms, not how long this host
1342        // took to read each 503.
1343        assert_eq!(
1344            seen.load(Ordering::SeqCst),
1345            usize::try_from(attempts_in(Duration::from_millis(700))).unwrap(),
1346            "{err}"
1347        );
1348    }
1349
1350    #[test]
1351    fn a_refused_connection_is_retried_and_then_one_sentence() {
1352        use std::time::Instant;
1353        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1354        let dir = std::env::temp_dir().join(format!("packset-down-{}", std::process::id()));
1355        std::fs::create_dir_all(&dir).unwrap();
1356        let saved = save_env(&[
1357            "HOME",
1358            "PACKSET_HOME",
1359            "LJOS_HOME",
1360            "LJOS_INSIDE_MEMORY_HOME",
1361            "PACKSET_BIN",
1362            "PACKSET_TIMEOUT_MS",
1363        ]);
1364        unsafe {
1365            env::set_var("HOME", &dir);
1366            env::remove_var("PACKSET_HOME");
1367            env::remove_var("LJOS_HOME");
1368            env::remove_var("LJOS_INSIDE_MEMORY_HOME");
1369            env::remove_var("PACKSET_BIN");
1370            env::set_var("PACKSET_TIMEOUT_MS", "250");
1371        }
1372        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1373        let port = listener.local_addr().unwrap().port();
1374        drop(listener);
1375        let client = PacksetClient::new(format!("http://127.0.0.1:{port}"));
1376        let started = Instant::now();
1377        let err = client.status(None).unwrap_err().to_string();
1378        let took = started.elapsed();
1379        restore_env(saved);
1380        let _ = std::fs::remove_dir_all(&dir);
1381        assert_eq!(err, "packsetd is not running.", "{err}");
1382        assert!(took >= Duration::from_millis(40), "no backoff: {took:?}");
1383        assert!(took < Duration::from_secs(2), "{took:?}");
1384    }
1385
1386    #[test]
1387    fn a_down_local_writer_is_started_once() {
1388        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1389        let dir = std::env::temp_dir().join(format!(
1390            "packset-autostart-{}-{}",
1391            std::process::id(),
1392            std::time::SystemTime::now()
1393                .duration_since(std::time::UNIX_EPOCH)
1394                .unwrap()
1395                .as_nanos()
1396        ));
1397        let home = dir.join("home");
1398        std::fs::create_dir_all(&home).unwrap();
1399        let log = dir.join("starts");
1400        let stub = dir.join("packsetd-stub");
1401        std::fs::write(
1402            &stub,
1403            format!(
1404                "#!/bin/sh\necho start >> \"{}\"\necho pid $$ >> \"{}\"\nexec python3 - \"$@\" << 'PY'\nimport sys\nargs = sys.argv[1:]\nport = int(args[args.index('--port') + 1])\nhome = args[args.index('--home') + 1]\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nclass Handler(BaseHTTPRequestHandler):\n    def do_GET(self):\n        body = b'{{\"ok\":true}}'\n        self.send_response(200)\n        self.send_header('Content-Type', 'application/json')\n        self.send_header('Content-Length', str(len(body)))\n        self.end_headers()\n        self.wfile.write(body)\n    def log_message(self, *args):\n        pass\nHTTPServer(('127.0.0.1', port), Handler).serve_forever()\nPY\n",
1405                log.display(),
1406                log.display(),
1407            ),
1408        )
1409        .unwrap();
1410        #[cfg(unix)]
1411        {
1412            use std::os::unix::fs::PermissionsExt;
1413            let mut mode = std::fs::metadata(&stub).unwrap().permissions();
1414            mode.set_mode(0o755);
1415            std::fs::set_permissions(&stub, mode).unwrap();
1416        }
1417        let saved = save_env(&[
1418            "HOME",
1419            "PACKSET_HOME",
1420            "LJOS_HOME",
1421            "LJOS_INSIDE_MEMORY_HOME",
1422            "PACKSET_BIN",
1423            "PACKSET_TIMEOUT_MS",
1424            "PACKSET_TOKEN",
1425        ]);
1426        unsafe {
1427            env::set_var("HOME", &dir);
1428            env::set_var("PACKSET_HOME", &home);
1429            env::remove_var("LJOS_HOME");
1430            env::remove_var("LJOS_INSIDE_MEMORY_HOME");
1431            env::remove_var("PACKSET_TOKEN");
1432            env::set_var("PACKSET_BIN", &stub);
1433            env::set_var("PACKSET_TIMEOUT_MS", "5000");
1434        }
1435        let reap = Reap(log.clone());
1436        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1437        let port = listener.local_addr().unwrap().port();
1438        drop(listener);
1439        let client = PacksetClient::new(format!("http://127.0.0.1:{port}"));
1440        let body = client.status(None).expect("the stub should answer");
1441        let text = std::fs::read_to_string(&log).unwrap_or_default();
1442        drop(reap);
1443        restore_env(saved);
1444        let _ = std::fs::remove_dir_all(&dir);
1445        assert_eq!(body["ok"], true, "{body}");
1446        assert_eq!(
1447            text.lines().filter(|line| *line == "start").count(),
1448            1,
1449            "{text}"
1450        );
1451    }
1452
1453    fn save_env(keys: &[&str]) -> Vec<(String, Option<std::ffi::OsString>)> {
1454        keys.iter()
1455            .map(|key| ((*key).to_string(), env::var_os(key)))
1456            .collect()
1457    }
1458
1459    fn restore_env(saved: Vec<(String, Option<std::ffi::OsString>)>) {
1460        for (key, value) in saved {
1461            unsafe {
1462                match value {
1463                    Some(value) => env::set_var(key, value),
1464                    None => env::remove_var(key),
1465                }
1466            }
1467        }
1468    }
1469
1470    /// Kills every pid the autostart stub wrote, including when the test fails.
1471    struct Reap(std::path::PathBuf);
1472
1473    impl Drop for Reap {
1474        fn drop(&mut self) {
1475            let Ok(text) = std::fs::read_to_string(&self.0) else {
1476                return;
1477            };
1478            for line in text.lines() {
1479                let Some(pid) = line.strip_prefix("pid ") else {
1480                    continue;
1481                };
1482                let _ = std::process::Command::new("kill")
1483                    .args(["-9", pid.trim()])
1484                    .status();
1485            }
1486        }
1487    }
1488
1489    /// Room for the first wait and not the second: 50 ms fits in 100 ms,
1490    /// and 50 + 150 ms does not.
1491    const BUDGET: Duration = Duration::from_millis(100);
1492
1493    /// A 100 ms budget leaves no room for the 150 ms wait. The time handed
1494    /// in is the waits already taken. A round trip does not move it.
1495    #[test]
1496    fn the_retries_stop_where_the_budget_does() {
1497        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1498        let old = env::var_os("PACKSET_TIMEOUT_MS");
1499        unsafe { env::set_var("PACKSET_TIMEOUT_MS", BUDGET.as_millis().to_string()) };
1500        let budget = timeout();
1501        unsafe {
1502            match old {
1503                Some(v) => env::set_var("PACKSET_TIMEOUT_MS", v),
1504                None => env::remove_var("PACKSET_TIMEOUT_MS"),
1505            }
1506        }
1507        assert_eq!(budget, BUDGET);
1508        let second = BUSY_WAIT * BUSY_GROWTH;
1509        assert!(
1510            ask_again(Duration::ZERO, BUSY_WAIT, budget),
1511            "the first wait did not fit"
1512        );
1513        assert!(!ask_again(BUSY_WAIT, second, budget), "the second wait fit");
1514        assert!(
1515            !ask_again(budget - BUSY_WAIT, BUSY_WAIT, budget),
1516            "a first round trip that spent the room was followed by a wait"
1517        );
1518    }
1519
1520    /// 50 ms fits in 51 and not in 50. 50 + 150 + 450 fits in 700, and the
1521    /// next wait, 1350 ms, does not. The same numbers on every host.
1522    #[test]
1523    fn the_attempt_count_is_the_waits_that_fit_the_budget() {
1524        assert_eq!(attempts_in(Duration::from_millis(50)), 1);
1525        assert_eq!(attempts_in(Duration::from_millis(51)), 2);
1526        assert_eq!(attempts_in(Duration::from_millis(100)), 2);
1527        assert_eq!(attempts_in(Duration::from_millis(700)), 4);
1528        assert_eq!(attempts_in(Duration::from_secs(30)), 10);
1529    }
1530}