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