Skip to main content

ljos_cli/
pack_link.rs

1//! The only speaker that may carry this seat's packsetd token.
2//!
3//! The token stays in this module. Each request is one byte stream to the
4//! socket address resolved for the writer's URL, with no proxy and no
5//! redirect. The proof runs before the token is written on that stream. A
6//! stream that closes is not remembered: the next call proves again before
7//! the token goes out. packsetd answers one request and closes, so the two
8//! refusals and the call that carries the token are three sockets in a row;
9//! the proof is not kept for a later call.
10
11use std::collections::HashMap;
12use std::io::{Read, Write};
13use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
14use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
15use std::sync::{Arc, Mutex, OnceLock};
16use std::time::{Duration, Instant, SystemTime};
17
18use packset_client::{Error, Hit};
19use serde::de::DeserializeOwned;
20use serde_json::Value;
21use url::Url;
22
23/// The seat's pack writer. The token field is private to this module.
24pub struct PacksetClient {
25    base: String,
26    origin: Url,
27    addr: Option<SocketAddr>,
28    workspace: Option<String>,
29    token: Mutex<Option<String>>,
30}
31
32impl Clone for PacksetClient {
33    fn clone(&self) -> Self {
34        Self {
35            base: self.base.clone(),
36            origin: self.origin.clone(),
37            addr: self.addr,
38            workspace: self.workspace.clone(),
39            token: Mutex::new(self.token.lock().unwrap_or_else(|p| p.into_inner()).clone()),
40        }
41    }
42}
43
44/// One answer. `trusted` means the token was on this answer, after the
45/// proof held.
46#[derive(Debug, Clone)]
47pub(crate) struct Answer {
48    pub status: u16,
49    pub body: String,
50    pub trusted: bool,
51    /// `Retry-After` when the writer sent one, in whole seconds.
52    pub retry_after: Option<Duration>,
53}
54
55/// Answers a hook process already has for a search or a status read, by
56/// request, for [`ANSWER_MEMO`]. A prompt hook asks the same search
57/// twice and the status twice; the second asks are answered here. Only a
58/// hook keeps one, and any write through the client drops it.
59type KeptAnswer = (String, Instant, Answer);
60static KEPT_ANSWERS: Mutex<Vec<KeptAnswer>> = Mutex::new(Vec::new());
61const ANSWER_MEMO: Duration = Duration::from_secs(2);
62
63fn answer_memo_key(
64    method: &str,
65    path: &str,
66    query: &[(&str, &str)],
67    bearer: bool,
68) -> Option<String> {
69    if method != "GET" || !matches!(path, "/v1/search" | "/v1/status") {
70        return None;
71    }
72    std::env::var_os("LJOS_IN_HOOK")?;
73    let mut key = format!("{path}|{bearer}");
74    for (k, v) in query {
75        key.push('|');
76        key.push_str(k);
77        key.push('=');
78        key.push_str(v);
79    }
80    Some(key)
81}
82
83fn kept_answer(key: &str) -> Option<Answer> {
84    let kept = KEPT_ANSWERS.lock().unwrap_or_else(|p| p.into_inner());
85    kept.iter()
86        .find(|(k, at, _)| k == key && at.elapsed() < ANSWER_MEMO)
87        .map(|(_, _, answer)| answer.clone())
88}
89
90fn keep_answer(key: String, answer: &Answer) {
91    let mut kept = KEPT_ANSWERS.lock().unwrap_or_else(|p| p.into_inner());
92    kept.retain(|(k, _, _)| k != &key);
93    kept.push((key, Instant::now(), answer.clone()));
94}
95
96/// Drop every kept answer. Called on any write through the client.
97pub(crate) fn forget_kept_answers() {
98    KEPT_ANSWERS
99        .lock()
100        .unwrap_or_else(|p| p.into_inner())
101        .clear();
102}
103
104struct Slot {
105    stream: Option<TcpStream>,
106    /// The proof held on `stream` and the stream is still the one it held on.
107    proven: bool,
108    token_tag: u64,
109    /// The last answer on this socket carried the token.
110    carried: bool,
111}
112
113fn slots() -> &'static Mutex<HashMap<SocketAddr, Arc<Mutex<Slot>>>> {
114    static SLOTS: OnceLock<Mutex<HashMap<SocketAddr, Arc<Mutex<Slot>>>>> = OnceLock::new();
115    SLOTS.get_or_init(|| Mutex::new(HashMap::new()))
116}
117
118fn slot_for(addr: SocketAddr) -> Arc<Mutex<Slot>> {
119    let mut map = slots().lock().unwrap_or_else(|p| p.into_inner());
120    map.entry(addr)
121        .or_insert_with(|| {
122            Arc::new(Mutex::new(Slot {
123                stream: None,
124                proven: false,
125                token_tag: 0,
126                carried: false,
127            }))
128        })
129        .clone()
130}
131
132fn load_token() -> Option<String> {
133    packset_client::token()
134}
135
136fn token_tag(token: Option<&str>) -> u64 {
137    let Some(token) = token else {
138        return 0;
139    };
140    let mut hash = 0xcbf29ce484222325u64;
141    for byte in token.as_bytes() {
142        hash ^= u64::from(*byte);
143        hash = hash.wrapping_mul(0x100000001b3);
144    }
145    hash
146}
147
148fn nonce() -> Option<String> {
149    use std::io::Read as _;
150    let mut raw = [0u8; 32];
151    std::fs::File::open("/dev/urandom")
152        .and_then(|mut file| file.read_exact(&mut raw))
153        .ok()
154        .map(|()| hex::encode(raw))
155}
156
157fn pin(url: &Url) -> Option<SocketAddr> {
158    let host = url.host_str()?;
159    let port = url.port_or_known_default()?;
160    if !crate::tool_bound::armed() {
161        return (host, port).to_socket_addrs().ok()?.next();
162    }
163    let left = crate::wall_left().unwrap_or(Duration::from_millis(1));
164    if left.is_zero() {
165        crate::tool_bound::note("packsetd");
166        crate::tool_bound::mark_failed("packsetd timed out");
167        return None;
168    }
169    let host = host.to_string();
170    let (tx, rx) = std::sync::mpsc::channel();
171    std::thread::spawn(move || {
172        let found = (host.as_str(), port)
173            .to_socket_addrs()
174            .ok()
175            .and_then(|mut addrs| addrs.next());
176        let _ = tx.send(found);
177    });
178    match rx.recv_timeout(left) {
179        Ok(addr) => addr,
180        Err(_) => {
181            crate::tool_bound::note("packsetd");
182            crate::tool_bound::mark_failed("packsetd timed out");
183            None
184        }
185    }
186}
187
188fn host_header(url: &Url) -> String {
189    match (url.host_str(), url.port()) {
190        (Some(host), Some(port)) if host.contains(':') => format!("[{host}]:{port}"),
191        (Some(host), Some(port)) => format!("{host}:{port}"),
192        (Some(host), None) => host.to_string(),
193        _ => "localhost".to_string(),
194    }
195}
196
197fn budget() -> Duration {
198    let base = crate::pack_budget_ms()
199        .or_else(|| {
200            std::env::var("PACKSET_TIMEOUT_MS")
201                .ok()
202                .and_then(|v| v.trim().parse::<u64>().ok())
203                .filter(|ms| *ms > 0)
204        })
205        .map_or(Duration::from_secs(30), Duration::from_millis);
206    match crate::wall_left() {
207        Some(left) => base.min(left).max(Duration::from_millis(1)),
208        None => base,
209    }
210}
211
212struct Clock {
213    deadline: Instant,
214    depth: u32,
215}
216
217thread_local! {
218    /// This thread's pack budget. A nested call keeps the earlier deadline.
219    /// Another thread has its own: one search must not spend a read that
220    /// is running beside it.
221    static CLOCK: std::cell::RefCell<Option<Clock>> = const { std::cell::RefCell::new(None) };
222}
223
224/// Bound every pack socket call on this thread until this guard drops.
225/// A nested guard on the same thread keeps the earlier deadline, so a
226/// hook stays under 2s and an interactive command under 5s across every
227/// socket of the call. A call on another thread keeps the budget it armed.
228pub struct PackClock {
229    /// Drop has to run on the thread that entered, where the clock lives.
230    _thread: std::marker::PhantomData<*const ()>,
231}
232
233impl PackClock {
234    #[must_use]
235    pub fn enter(total: Duration) -> Self {
236        CLOCK.with(|slot| {
237            let mut slot = slot.borrow_mut();
238            match slot.as_mut() {
239                Some(clock) => clock.depth = clock.depth.saturating_add(1),
240                None => {
241                    *slot = Some(Clock {
242                        deadline: Instant::now() + total,
243                        depth: 1,
244                    });
245                }
246            }
247        });
248        Self {
249            _thread: std::marker::PhantomData,
250        }
251    }
252}
253
254impl Drop for PackClock {
255    fn drop(&mut self) {
256        CLOCK.with(|slot| {
257            let mut slot = slot.borrow_mut();
258            if let Some(clock) = slot.as_mut() {
259                clock.depth = clock.depth.saturating_sub(1);
260                if clock.depth == 0 {
261                    *slot = None;
262                }
263            }
264        });
265    }
266}
267
268fn clock_deadline() -> Option<Instant> {
269    CLOCK.with(|slot| slot.borrow().as_ref().map(|clock| clock.deadline))
270}
271
272pub(crate) fn clock_armed() -> bool {
273    clock_deadline().is_some()
274}
275
276/// Time left on the armed pack clock. `None` when no clock is armed.
277pub(crate) fn clock_left() -> Option<Duration> {
278    clock_deadline().map(|deadline| deadline.saturating_duration_since(Instant::now()))
279}
280
281/// A search or island read in this clock already went quiet, or the clock
282/// is spent. Later reads must not wait it out again.
283pub(crate) fn search_stalled() -> bool {
284    pack_quiet() || clock_deadline().is_some_and(|deadline| Instant::now() >= deadline)
285}
286
287pub(crate) fn error_is_timeout(err: &Error) -> bool {
288    match err {
289        Error::Io(io) => {
290            io_is_quiet(io)
291                || io.to_string().contains("the pack did not answer")
292                || io.to_string().contains("packsetd timed out")
293        }
294        _ => false,
295    }
296}
297
298/// Whether every socket listening on the loopback port of `url` belongs to
299/// another user. Unknown is not foreign.
300///
301/// A hook keeps its first answer for each url. The socket tables are read
302/// in full each time, a hook connects more than once, and it ends within
303/// the runner's cut-off. Elsewhere a search keeps its first answer until
304/// it ends ([`OwnerOnce`]), and any other call reads them again. A writer
305/// that stops leaves its port to whoever binds it next. A long-lived
306/// process that kept its first answer would go on carrying the token to
307/// another user's listener, or never to its own.
308fn foreign(url: &str) -> bool {
309    if std::env::var_os("LJOS_IN_HOOK").is_none() {
310        return SEARCH_OWNERS.with(|kept| match kept.borrow_mut().as_mut() {
311            Some(kept) => match kept.iter().find(|(u, _)| u == url) {
312                Some((_, known)) => *known,
313                None => {
314                    let answer = foreign_read(url);
315                    kept.push((url.to_string(), answer));
316                    answer
317                }
318            },
319            None => foreign_read(url),
320        });
321    }
322    static KNOWN: std::sync::LazyLock<Mutex<Vec<(String, bool)>>> =
323        std::sync::LazyLock::new(|| Mutex::new(Vec::new()));
324    if let Some((_, known)) = KNOWN
325        .lock()
326        .unwrap_or_else(|p| p.into_inner())
327        .iter()
328        .find(|(u, _)| u == url)
329    {
330        return *known;
331    }
332    let answer = foreign_read(url);
333    KNOWN
334        .lock()
335        .unwrap_or_else(|p| p.into_inner())
336        .push((url.to_string(), answer));
337    answer
338}
339
340thread_local! {
341    /// [`foreign`]'s answers for the search on this thread, by url, for
342    /// as long as an [`OwnerOnce`] lasts.
343    static SEARCH_OWNERS: std::cell::RefCell<Option<Vec<(String, bool)>>> =
344        const { std::cell::RefCell::new(None) };
345}
346
347/// Read the pack port's owner once for the requests of one search on this
348/// thread, until the guard drops. Without it, each request of the search
349/// that carries the token reads both tables. A guard inside another keeps
350/// the outer one's answers.
351pub(crate) struct OwnerOnce {
352    outer: bool,
353}
354
355impl OwnerOnce {
356    #[must_use]
357    pub(crate) fn enter() -> Self {
358        let outer = SEARCH_OWNERS.with(|kept| {
359            let mut kept = kept.borrow_mut();
360            let outer = kept.is_none();
361            if outer {
362                *kept = Some(Vec::new());
363            }
364            outer
365        });
366        Self { outer }
367    }
368}
369
370impl Drop for OwnerOnce {
371    fn drop(&mut self) {
372        if self.outer {
373            SEARCH_OWNERS.with(|kept| *kept.borrow_mut() = None);
374        }
375    }
376}
377
378#[cfg(test)]
379thread_local! {
380    /// How many times this thread read the socket tables.
381    static TABLE_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
382}
383
384fn foreign_read(url: &str) -> bool {
385    #[cfg(test)]
386    TABLE_READS.with(|reads| reads.set(reads.get() + 1));
387    #[cfg(target_os = "linux")]
388    {
389        use std::os::unix::fs::MetadataExt;
390        let Some(port) = loopback_port(url) else {
391            return false;
392        };
393        let Ok(me) = std::fs::metadata("/proc/self").map(|m| m.uid()) else {
394            return false;
395        };
396        let owners: Vec<u32> = ["/proc/net/tcp", "/proc/net/tcp6"]
397            .iter()
398            .filter_map(|table| std::fs::read_to_string(table).ok())
399            .flat_map(|text| listen_uids(&text, port))
400            .collect();
401        !owners.is_empty() && owners.iter().all(|uid| *uid != me)
402    }
403    #[cfg(not(target_os = "linux"))]
404    {
405        let _ = url;
406        false
407    }
408}
409
410#[cfg(target_os = "linux")]
411fn loopback_port(url: &str) -> Option<u16> {
412    let rest = url
413        .strip_prefix("http://")
414        .or_else(|| url.strip_prefix("https://"))?;
415    let authority = rest.split(['/', '?', '#']).next()?;
416    let (host, port) = match authority.rsplit_once(':') {
417        Some((host, port)) if !port.contains(']') => (host, port.parse().ok()?),
418        _ => (authority, 80),
419    };
420    matches!(host, "127.0.0.1" | "localhost" | "[::1]").then_some(port)
421}
422
423#[cfg(target_os = "linux")]
424fn listen_uids(table: &str, port: u16) -> Vec<u32> {
425    table
426        .lines()
427        .skip(1)
428        .filter_map(|line| {
429            let fields: Vec<&str> = line.split_whitespace().collect();
430            if fields.get(3) != Some(&"0A") {
431                return None;
432            }
433            let hex = fields.get(1)?.rsplit(':').next()?;
434            if u16::from_str_radix(hex, 16).ok()? != port {
435                return None;
436            }
437            fields.get(7)?.parse().ok()
438        })
439        .collect()
440}
441
442fn target(path: &str, query: &[(&str, &str)]) -> String {
443    if query.is_empty() {
444        return path.to_string();
445    }
446    let mut ser = url::form_urlencoded::Serializer::new(String::new());
447    for (key, value) in query {
448        ser.append_pair(key, value);
449    }
450    format!("{path}?{}", ser.finish())
451}
452
453struct Raw {
454    status: u16,
455    body: String,
456    keep: bool,
457    retry_after: Option<Duration>,
458}
459
460/// A sitting or MCP pack read. Longer reads keep their own budgets.
461const SHORT_READ: Duration = Duration::from_secs(1);
462
463/// How long one MCP pack read may take.
464///
465/// The usual read is [`SHORT_READ`], so a silent writer cannot spend the
466/// whole tool budget. A seat that raises `LJOS_MCP_TIMEOUT` above its
467/// thirty-second default and sets `PACKSET_TIMEOUT_MS` is waiting on one
468/// long read. That read uses the shorter of the two.
469fn mcp_read_cap() -> Duration {
470    let tool = crate::tool_bound::budget();
471    if tool > crate::tool_bound::DEFAULT {
472        if let Some(ms) = std::env::var("PACKSET_TIMEOUT_MS")
473            .ok()
474            .and_then(|value| value.trim().parse::<u64>().ok())
475            .filter(|ms| *ms > 0)
476        {
477            return Duration::from_millis(ms).min(tool);
478        }
479    }
480    SHORT_READ
481}
482const HEALTH_BUDGET: Duration = Duration::from_secs(2);
483const STATUS_BUDGET: Duration = Duration::from_secs(2);
484
485thread_local! {
486    /// The command's admission deadline, as a wall time. A nested guard
487    /// keeps the sooner of the two, so a search inside a longer command
488    /// stays short. The socket clock is a separate bound.
489    static COMMAND_DEADLINE: std::cell::RefCell<Option<SystemTime>> = const { std::cell::RefCell::new(None) };
490    /// The `X-Packset-Deadline` for the request in flight, frozen when the
491    /// call starts so a retry does not push the deadline out.
492    static HELD_DEADLINE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
493}
494
495/// How long this command is willing to wait for the pack. Search and
496/// recall pass a few seconds. Remember passes twenty. Import passes a minute.
497pub struct CommandDeadline {
498    prev: Option<SystemTime>,
499}
500
501impl CommandDeadline {
502    #[must_use]
503    pub fn enter(budget: Duration) -> Self {
504        let prev = COMMAND_DEADLINE.with(|cell| *cell.borrow());
505        let proposed = SystemTime::now() + budget;
506        let next = match prev {
507            Some(existing) if existing <= proposed => existing,
508            _ => proposed,
509        };
510        COMMAND_DEADLINE.with(|cell| *cell.borrow_mut() = Some(next));
511        Self { prev }
512    }
513}
514
515impl Drop for CommandDeadline {
516    fn drop(&mut self) {
517        let prev = self.prev.take();
518        COMMAND_DEADLINE.with(|cell| *cell.borrow_mut() = prev);
519    }
520}
521
522struct HeldDeadline {
523    prev: Option<String>,
524    active: bool,
525}
526
527impl HeldDeadline {
528    fn enter(budget: Duration) -> Self {
529        let prev = HELD_DEADLINE.with(|cell| cell.borrow().clone());
530        if prev.is_some() {
531            return Self {
532                prev,
533                active: false,
534            };
535        }
536        let stamp = deadline_stamp(budget);
537        HELD_DEADLINE.with(|cell| *cell.borrow_mut() = Some(stamp));
538        Self { prev, active: true }
539    }
540}
541
542impl Drop for HeldDeadline {
543    fn drop(&mut self) {
544        if self.active {
545            let prev = self.prev.clone();
546            HELD_DEADLINE.with(|cell| *cell.borrow_mut() = prev);
547        }
548    }
549}
550
551fn is_write(method: &str) -> bool {
552    !matches!(method, "GET" | "HEAD")
553}
554
555fn command_remaining() -> Option<Duration> {
556    let when = COMMAND_DEADLINE.with(|cell| *cell.borrow())?;
557    Some(
558        when.duration_since(SystemTime::now())
559            .unwrap_or(Duration::ZERO),
560    )
561}
562
563/// How long this call may run, and the offset on `X-Packset-Deadline`.
564/// The caller's budget wins. A thread timeout, `PACKSET_TIMEOUT_MS`, the
565/// wall clock, and a command or pack clock shorten it. An MCP read is
566/// about a second unless the seat raised the tool bound and the pack
567/// timeout. A sitting read is about a second because its clock is.
568fn tightened(method: &str, requested: Duration) -> Duration {
569    let mut budget = requested;
570    if let Some(ms) = crate::pack_budget_ms() {
571        budget = budget.min(Duration::from_millis(ms.max(1)));
572    } else if let Some(ms) = std::env::var("PACKSET_TIMEOUT_MS")
573        .ok()
574        .and_then(|v| v.trim().parse::<u64>().ok())
575        .filter(|ms| *ms > 0)
576    {
577        budget = budget.min(Duration::from_millis(ms));
578    }
579    if let Some(left) = crate::wall_left() {
580        budget = budget.min(left);
581    }
582    if !is_write(method) && crate::tool_bound::armed() {
583        budget = budget.min(mcp_read_cap());
584    }
585    if let Some(left) = clock_left() {
586        budget = budget.min(left);
587    }
588    if let Some(left) = command_remaining() {
589        budget = budget.min(left);
590    }
591    budget.max(Duration::from_millis(1))
592}
593
594/// How long one call may take. [`tightened`].
595pub(crate) fn admission_retry_budget(method: &str, per_try: Duration) -> Duration {
596    tightened(method, per_try)
597}
598
599fn deadline_stamp(budget: Duration) -> String {
600    let proposed = SystemTime::now() + budget;
601    let when = match COMMAND_DEADLINE.with(|cell| *cell.borrow()) {
602        Some(command) if command < proposed => command,
603        _ => proposed,
604    };
605    unix_ms(when).to_string()
606}
607
608fn unix_ms(when: SystemTime) -> u64 {
609    when.duration_since(std::time::UNIX_EPOCH)
610        .map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
611        .unwrap_or(0)
612}
613
614fn current_deadline(method: &str) -> String {
615    HELD_DEADLINE
616        .with(|cell| cell.borrow().clone())
617        .unwrap_or_else(|| deadline_stamp(admission_retry_budget(method, budget())))
618}
619
620fn retry_after_of(header: Option<&str>) -> Option<Duration> {
621    let raw = header?.trim();
622    if raw.is_empty() {
623        return None;
624    }
625    raw.parse::<u64>().ok().map(Duration::from_secs)
626}
627
628/// Read or write shed, or a deadline that passed. The line is what the
629/// command prints.
630#[derive(Clone, Copy, Debug, PartialEq, Eq)]
631pub(crate) enum ShedKind {
632    Read,
633    Write,
634    Deadline,
635}
636
637thread_local! {
638    static SHED: std::cell::RefCell<Vec<(String, ShedKind)>> =
639        const { std::cell::RefCell::new(Vec::new()) };
640}
641
642fn note_shed(line: &str, kind: ShedKind) {
643    SHED.with(|cell| {
644        let mut cell = cell.borrow_mut();
645        cell.retain(|(text, _)| text != line);
646        cell.push((line.to_string(), kind));
647        if cell.len() > 32 {
648            cell.remove(0);
649        }
650    });
651}
652
653pub(crate) fn shed_kind(line: &str) -> Option<ShedKind> {
654    SHED.with(|cell| {
655        cell.borrow()
656            .iter()
657            .rev()
658            .find(|(text, _)| text == line)
659            .map(|(_, kind)| *kind)
660    })
661}
662
663/// Time left until the frozen `X-Packset-Deadline`. `None` when no call
664/// is in flight.
665fn held_remaining() -> Option<Duration> {
666    let stamp = HELD_DEADLINE.with(|cell| cell.borrow().clone())?;
667    let ms: u64 = stamp.parse().ok()?;
668    let now = unix_ms(SystemTime::now());
669    Some(Duration::from_millis(ms.saturating_sub(now)))
670}
671
672fn deadline_passed() -> bool {
673    held_remaining().is_some_and(|left| left.is_zero())
674}
675
676/// `wait` finishes before the frozen deadline, so the resend still has time.
677fn retry_room(wait: Duration) -> bool {
678    held_remaining().is_some_and(|left| wait < left)
679}
680
681/// The JSON `error` field, as one line. A deadline with no field uses the
682/// deadline sentence. Anything else with no field is `degraded`.
683fn fault_line(body: &str, deadline: bool) -> String {
684    let parsed = serde_json::from_str::<Value>(body).ok().and_then(|value| {
685        value
686            .get("error")
687            .and_then(Value::as_str)
688            .map(str::to_string)
689    });
690    if let Some(text) = parsed {
691        let text = text.trim();
692        if let Some(line) = text.lines().next().map(str::trim).filter(|s| !s.is_empty()) {
693            return line.to_string();
694        }
695    }
696    if deadline {
697        return super::PACK_DEADLINE_LINE.to_string();
698    }
699    let raw = body.trim();
700    if !raw.is_empty() && !raw.contains('\n') && raw.len() <= 160 && !raw.starts_with('{') {
701        return raw.to_string();
702    }
703    "degraded".to_string()
704}
705
706fn degrade(body: &str, method: &str, deadline: bool) -> Error {
707    let line = fault_line(body, deadline);
708    let kind = if deadline {
709        ShedKind::Deadline
710    } else if is_write(method) {
711        ShedKind::Write
712    } else {
713        ShedKind::Read
714    };
715    note_shed(&line, kind);
716    Error::Unavailable(line)
717}
718
719/// The pack accepted a connection and then stayed silent, or the connect
720/// itself never finished. While a [`QuietPack`] guard is alive, the first
721/// such read still waits out its budget and a later read returns before it
722/// opens another socket. A consensus reading asks several times. Without
723/// the stop, each ask waited the whole budget and the command sat for the
724/// sum. Dropping the guard clears it, so a later call can try the pack again.
725static PACK_QUIET: AtomicBool = AtomicBool::new(false);
726static QUIET_HELD: AtomicUsize = AtomicUsize::new(0);
727
728/// Remember one quiet pack for the reads this guard covers.
729pub struct QuietPack;
730
731impl QuietPack {
732    /// Start remembering a silent pack. The first guard of a pile drops a
733    /// quiet read already seen, so the pile starts with a fresh try.
734    #[must_use]
735    pub fn enter() -> Self {
736        if QUIET_HELD.fetch_add(1, Ordering::AcqRel) == 0 {
737            PACK_QUIET.store(false, Ordering::Relaxed);
738        }
739        Self
740    }
741}
742
743impl Drop for QuietPack {
744    fn drop(&mut self) {
745        if QUIET_HELD.fetch_sub(1, Ordering::AcqRel) == 1 {
746            PACK_QUIET.store(false, Ordering::Relaxed);
747        }
748    }
749}
750
751fn holding_quiet() -> bool {
752    QUIET_HELD.load(Ordering::Relaxed) > 0
753}
754
755fn pack_quiet() -> bool {
756    holding_quiet() && PACK_QUIET.load(Ordering::Relaxed)
757}
758
759fn mark_pack_quiet() {
760    if holding_quiet() {
761        PACK_QUIET.store(true, Ordering::Relaxed);
762    }
763}
764
765/// The tool call's own bound is spent. A shorter read, one atom inside
766/// its own budget, times out while the call still has time and the caller
767/// carries on. That read is not the call failing.
768fn tool_bound_spent() -> bool {
769    crate::tool_bound::remaining().is_some_and(|left| left <= Duration::from_millis(50))
770}
771
772fn pack_io(err: std::io::Error) -> std::io::Error {
773    let quiet = io_is_quiet(&err) || err.to_string().contains("the pack did not answer");
774    if quiet && tool_bound_spent() {
775        crate::tool_bound::note("packsetd");
776        crate::tool_bound::mark_failed("packsetd timed out");
777        return std::io::Error::new(std::io::ErrorKind::TimedOut, "packsetd timed out");
778    }
779    err
780}
781
782fn quiet_io() -> std::io::Error {
783    pack_io(std::io::Error::new(
784        std::io::ErrorKind::TimedOut,
785        "the pack did not answer",
786    ))
787}
788
789/// A read or a connect that ended because the socket stayed silent.
790/// A refused connect is not this: the writer may still be starting.
791fn io_is_quiet(err: &std::io::Error) -> bool {
792    matches!(
793        err.kind(),
794        std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
795    )
796}
797
798fn connect(addr: SocketAddr, timeout: Duration) -> std::io::Result<TcpStream> {
799    if pack_quiet() {
800        return Err(quiet_io());
801    }
802    if side_budget_spent() {
803        return Err(budget_spent());
804    }
805    let started = Instant::now();
806    let result = connect_once(addr, timeout);
807    // A connect that returns a socket is the pack accepting. Time spent
808    // there is not a quiet wait. A connect that fails spends the budget.
809    // A connect that never finishes is remembered, so the next read does
810    // not wait the budget out again.
811    if let Err(err) = &result {
812        if io_is_quiet(err) {
813            mark_pack_quiet();
814        }
815        super::charge_side_wait(started.elapsed());
816    }
817    result
818}
819
820/// A side event's pack budget is gone. Opening a socket for a one
821/// millisecond timer still blocks, and a prompt makes several of those
822/// reads, so the call returns before the connect.
823fn side_budget_spent() -> bool {
824    super::side_remaining_ms() == Some(0)
825}
826
827fn budget_spent() -> std::io::Error {
828    std::io::Error::new(
829        std::io::ErrorKind::TimedOut,
830        "the side event's pack budget is spent",
831    )
832}
833
834fn connect_once(addr: SocketAddr, timeout: Duration) -> std::io::Result<TcpStream> {
835    let wait = bounded_wait(timeout, true)?;
836    let stream = TcpStream::connect_timeout(&addr, wait)?;
837    let io_wait = bounded_wait(timeout, false)?;
838    stream.set_read_timeout(Some(io_wait))?;
839    stream.set_write_timeout(Some(io_wait))?;
840    let _ = stream.set_nodelay(true);
841    Ok(stream)
842}
843
844/// The wait a socket call may use. With a [`PackClock`], a connect is
845/// capped by [`super::pack_connect_budget`] and every call stops at the
846/// clock. Past the clock the call returns before it opens a socket.
847fn bounded_wait(requested: Duration, connect: bool) -> std::io::Result<Duration> {
848    let mut wait = capped_wait(requested);
849    if let Some(deadline) = clock_deadline() {
850        let left = deadline.saturating_duration_since(Instant::now());
851        if left.is_zero() {
852            return Err(pack_io(std::io::Error::new(
853                std::io::ErrorKind::TimedOut,
854                "the pack did not answer",
855            )));
856        }
857        wait = wait.min(left);
858        if connect {
859            wait = wait.min(super::pack_connect_budget());
860        }
861        if wait.is_zero() {
862            return Err(pack_io(std::io::Error::new(
863                std::io::ErrorKind::TimedOut,
864                "the pack did not answer",
865            )));
866        }
867    }
868    Ok(wait)
869}
870
871/// A side-event bound shrinks this wait. A zero bound is one millisecond,
872/// so a later read fails at once instead of taking the default.
873fn capped_wait(timeout: Duration) -> Duration {
874    let wait = super::cap_pack_wait(timeout);
875    if wait.is_zero() {
876        Duration::from_millis(1)
877    } else {
878        wait
879    }
880}
881
882fn write_all(stream: &mut TcpStream, bytes: &[u8]) -> std::io::Result<()> {
883    let mut sent = 0;
884    while sent < bytes.len() {
885        match stream.write(&bytes[sent..]) {
886            Ok(0) => {
887                return Err(std::io::Error::new(
888                    std::io::ErrorKind::WriteZero,
889                    "the pack closed the socket",
890                ))
891            }
892            Ok(n) => sent += n,
893            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
894            Err(err) => return Err(pack_io(err)),
895        }
896    }
897    stream.flush()
898}
899
900fn read_response(stream: &mut TcpStream, deadline: Instant) -> std::io::Result<Raw> {
901    let mut buf = Vec::new();
902    let mut tmp = [0u8; 4096];
903    let head_end = loop {
904        if buf.len() > 64 * 1024 {
905            return Err(std::io::Error::new(
906                std::io::ErrorKind::InvalidData,
907                "the pack's headers are too long",
908            ));
909        }
910        arm_read(stream, deadline)?;
911        let n = read_some(stream, &mut tmp)?;
912        buf.extend_from_slice(&tmp[..n]);
913        if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
914            break at;
915        }
916    };
917    let header = String::from_utf8_lossy(&buf[..head_end]).into_owned();
918    let status = header
919        .lines()
920        .next()
921        .and_then(|line| line.split_whitespace().nth(1))
922        .and_then(|code| code.parse().ok())
923        .ok_or_else(|| {
924            std::io::Error::new(
925                std::io::ErrorKind::InvalidData,
926                "the pack's status line is not HTTP",
927            )
928        })?;
929    let mut length = None;
930    let mut chunked = false;
931    let mut close = header.starts_with("HTTP/1.0");
932    let mut retry_after = None;
933    for line in header.lines().skip(1) {
934        let Some((name, value)) = line.split_once(':') else {
935            continue;
936        };
937        if name.eq_ignore_ascii_case("content-length") {
938            length = value.trim().parse().ok();
939        } else if name.eq_ignore_ascii_case("transfer-encoding") {
940            chunked = value
941                .split(',')
942                .any(|coding| coding.trim().eq_ignore_ascii_case("chunked"));
943        } else if name.eq_ignore_ascii_case("retry-after") {
944            retry_after = retry_after_of(Some(value));
945        } else if name.eq_ignore_ascii_case("connection")
946            && value.trim().eq_ignore_ascii_case("close")
947        {
948            close = true;
949        }
950    }
951    let mut body = buf.split_off(head_end + 4);
952    // packsetd 0.17.9 streams a listing in chunks. The chunk sizes are not
953    // the body: read as JSON they fail with "trailing characters".
954    if chunked {
955        let mut chunks = Chunks::default();
956        let mut raw = std::mem::take(&mut body);
957        while !chunks.feed(&raw, &mut body)? {
958            arm_read(stream, deadline)?;
959            let n = read_some(stream, &mut tmp)?;
960            raw.extend_from_slice(&tmp[..n]);
961        }
962    } else if let Some(length) = length {
963        while body.len() < length {
964            arm_read(stream, deadline)?;
965            let n = read_some(stream, &mut tmp)?;
966            body.extend_from_slice(&tmp[..n]);
967            if body.len() > length.saturating_add(64 * 1024) {
968                break;
969            }
970        }
971        body.truncate(length);
972    } else {
973        loop {
974            if deadline.saturating_duration_since(Instant::now()).is_zero() {
975                break;
976            }
977            let _ = arm_read(stream, deadline);
978            match stream.read(&mut tmp) {
979                Ok(0) => break,
980                Ok(n) => body.extend_from_slice(&tmp[..n]),
981                Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
982                Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
983                Err(err) if err.kind() == std::io::ErrorKind::TimedOut => break,
984                Err(_) => break,
985            }
986        }
987        close = true;
988    }
989    // A redirect is the answer. The token must not follow it.
990    Ok(Raw {
991        status,
992        body: String::from_utf8_lossy(&body).into_owned(),
993        keep: !close && !(300..400).contains(&status),
994        retry_after,
995    })
996}
997
998/// Where a chunked body has been read up to.
999#[derive(Default)]
1000struct Chunks {
1001    /// The first byte of `raw` not yet decoded.
1002    at: usize,
1003}
1004
1005impl Chunks {
1006    /// Decode the whole chunks in `raw` past the last call into `out`.
1007    /// True once the last chunk and its trailers are in.
1008    fn feed(&mut self, raw: &[u8], out: &mut Vec<u8>) -> std::io::Result<bool> {
1009        loop {
1010            let rest = &raw[self.at..];
1011            let Some(eol) = rest.windows(2).position(|w| w == b"\r\n") else {
1012                return Ok(false);
1013            };
1014            let line = String::from_utf8_lossy(&rest[..eol]);
1015            let digits = line.split(';').next().unwrap_or("").trim();
1016            let size = usize::from_str_radix(digits, 16).map_err(|_| {
1017                std::io::Error::new(
1018                    std::io::ErrorKind::InvalidData,
1019                    format!("the pack's chunk size {digits:?} is not hex"),
1020                )
1021            })?;
1022            let start = eol + 2;
1023            if size == 0 {
1024                // Trailers, if any, end with an empty line.
1025                let tail = &rest[eol..];
1026                return Ok(tail.windows(4).any(|w| w == b"\r\n\r\n"));
1027            }
1028            let Some(end) = start.checked_add(size) else {
1029                return Err(std::io::Error::new(
1030                    std::io::ErrorKind::InvalidData,
1031                    "the pack's chunk size is too big",
1032                ));
1033            };
1034            let Some(framed) = end.checked_add(2) else {
1035                return Err(std::io::Error::new(
1036                    std::io::ErrorKind::InvalidData,
1037                    "the pack's chunk size is too big",
1038                ));
1039            };
1040            if rest.len() < framed {
1041                return Ok(false);
1042            }
1043            if &rest[end..framed] != b"\r\n" {
1044                return Err(std::io::Error::new(
1045                    std::io::ErrorKind::InvalidData,
1046                    "the pack's chunk does not end where its size says",
1047                ));
1048            }
1049            out.extend_from_slice(&rest[start..end]);
1050            self.at = self.at.checked_add(framed).ok_or_else(|| {
1051                std::io::Error::new(
1052                    std::io::ErrorKind::InvalidData,
1053                    "the pack's chunk size is too big",
1054                )
1055            })?;
1056        }
1057    }
1058}
1059
1060fn arm_read(stream: &TcpStream, deadline: Instant) -> std::io::Result<()> {
1061    let remain = deadline.saturating_duration_since(Instant::now());
1062    if remain.is_zero() {
1063        return Err(pack_io(std::io::Error::new(
1064            std::io::ErrorKind::TimedOut,
1065            "the pack did not answer",
1066        )));
1067    }
1068    stream.set_read_timeout(Some(remain))?;
1069    Ok(())
1070}
1071
1072fn read_some(stream: &mut TcpStream, tmp: &mut [u8]) -> std::io::Result<usize> {
1073    loop {
1074        match stream.read(tmp) {
1075            Ok(0) => {
1076                return Err(std::io::Error::new(
1077                    std::io::ErrorKind::UnexpectedEof,
1078                    "the pack closed the socket",
1079                ))
1080            }
1081            Ok(n) => return Ok(n),
1082            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
1083            Err(err) => return Err(pack_io(err)),
1084        }
1085    }
1086}
1087
1088fn render(
1089    method: &str,
1090    host: &str,
1091    path: &str,
1092    token: Option<&str>,
1093    body: Option<&[u8]>,
1094) -> Vec<u8> {
1095    let deadline = current_deadline(method);
1096    let mut head = format!(
1097        "{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: keep-alive\r\nX-Packset-Deadline: {deadline}\r\n"
1098    );
1099    if let Some(token) = token {
1100        head.push_str("Authorization: Bearer ");
1101        head.push_str(token);
1102        head.push_str("\r\n");
1103    }
1104    let mut out = Vec::new();
1105    if let Some(body) = body {
1106        head.push_str("Content-Type: application/json\r\n");
1107        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
1108        out.extend(head.into_bytes());
1109        out.extend_from_slice(body);
1110    } else {
1111        head.push_str("\r\n");
1112        out.extend(head.into_bytes());
1113    }
1114    out
1115}
1116
1117struct Wire<'a> {
1118    method: &'a str,
1119    path: &'a str,
1120    query: &'a [(&'a str, &'a str)],
1121    body: Option<&'a [u8]>,
1122    token: Option<&'a str>,
1123}
1124
1125enum Gate {
1126    /// The proof held. The call may carry the token.
1127    Send,
1128    /// The listener did not ask for the token.
1129    Bare,
1130    /// The writer is busy. The call is not sent.
1131    Busy(Raw),
1132}
1133
1134impl PacksetClient {
1135    pub fn new(base: impl Into<String>) -> Self {
1136        let mut base = base.into();
1137        while base.ends_with('/') {
1138            base.pop();
1139        }
1140        let origin = Url::parse(&base).unwrap_or_else(|_| Url::parse("http://127.0.0.1/").unwrap());
1141        let addr = Url::parse(&base).ok().as_ref().and_then(pin);
1142        Self {
1143            base,
1144            origin,
1145            addr,
1146            workspace: None,
1147            token: Mutex::new(load_token()),
1148        }
1149    }
1150
1151    /// The writer the seat talks to. `PACKSET_URL=off` is no pack.
1152    pub fn from_env() -> Result<Self, Error> {
1153        packset_client::load_seat_env();
1154        let url = std::env::var("PACKSET_URL")
1155            .or_else(|_| std::env::var("INSIDE_MEMORY_URL"))
1156            .ok()
1157            .filter(|url| !url.is_empty());
1158        match url {
1159            Some(url) if url == "off" => Err(Error::NoUrl),
1160            Some(url) => Ok(Self::new(url)),
1161            None => Ok(Self::new(format!(
1162                "http://127.0.0.1:{}",
1163                packset_client::default_port()
1164            ))),
1165        }
1166    }
1167
1168    #[must_use]
1169    pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
1170        let workspace = workspace.into();
1171        self.workspace = (!workspace.is_empty()).then_some(workspace);
1172        self
1173    }
1174
1175    pub fn base(&self) -> &str {
1176        &self.base
1177    }
1178
1179    /// Whether this seat has a token to show. The token itself stays here.
1180    #[must_use]
1181    pub(crate) fn has_token(&self) -> bool {
1182        load_token().is_some()
1183    }
1184
1185    /// Whether a call may carry the token: the seat has one, and another
1186    /// user does not hold the loopback port.
1187    #[must_use]
1188    pub(crate) fn may_carry(&self) -> bool {
1189        load_token().is_some() && !foreign(&self.base)
1190    }
1191
1192    /// Whether the last answer on this socket carried the token. A closed
1193    /// socket does not keep this for the next call: that call proves again.
1194    #[must_use]
1195    pub(crate) fn carried(&self) -> bool {
1196        let Some(addr) = self.addr else {
1197            return false;
1198        };
1199        let map = slots().lock().unwrap_or_else(|p| p.into_inner());
1200        map.get(&addr)
1201            .is_some_and(|slot| slot.lock().unwrap_or_else(|p| p.into_inner()).carried)
1202    }
1203
1204    pub fn workspace(&self) -> String {
1205        if let Some(workspace) = &self.workspace {
1206            return workspace.clone();
1207        }
1208        if let Ok(workspace) = std::env::var("PACKSET_WORKSPACE") {
1209            if !workspace.is_empty() {
1210                return workspace;
1211            }
1212        }
1213        let cwd = std::env::var("HOSTPACK_WORKSPACE")
1214            .ok()
1215            .map(|s| s.trim().to_string())
1216            .filter(|s| !s.is_empty())
1217            .map(std::path::PathBuf::from)
1218            .or_else(|| std::env::current_dir().ok())
1219            .unwrap_or_else(|| std::path::PathBuf::from("."));
1220        let abs = cwd.canonicalize().unwrap_or(cwd);
1221        let shown = abs.to_string_lossy().into_owned();
1222        if let Ok(answer) = self.roundtrip(
1223            "GET",
1224            "/v1/identity",
1225            &[("cwd", shown.as_str())],
1226            None,
1227            true,
1228            STATUS_BUDGET,
1229        ) {
1230            if answer.status == 200 {
1231                if let Ok(val) = serde_json::from_str::<Value>(&answer.body) {
1232                    if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
1233                        if !ws.is_empty() {
1234                            return ws.to_string();
1235                        }
1236                    }
1237                }
1238            }
1239        }
1240        format!("dir:{}", abs.display())
1241    }
1242
1243    /// One call. `bearer` asks to carry the token; it is left off when
1244    /// the proof does not hold or another user owns the port. A 3xx is
1245    /// returned as the answer and not followed. A 503 waits `Retry-After`
1246    /// once and is sent again with the same deadline. A second 503, or a
1247    /// deadline that has passed, is the JSON `error` field and stops.
1248    pub(crate) fn roundtrip(
1249        &self,
1250        method: &str,
1251        path: &str,
1252        query: &[(&str, &str)],
1253        body: Option<&[u8]>,
1254        bearer: bool,
1255        timeout: Duration,
1256    ) -> Result<Answer, Error> {
1257        // A write changes what a listing would say: the memo this process
1258        // keeps and the gate's freshness mark both go.
1259        if method != "GET" {
1260            crate::forget_atom_listings();
1261            crate::forget_gate_fresh();
1262            forget_kept_answers();
1263        }
1264        let memo_key = answer_memo_key(method, path, query, bearer);
1265        if let Some(answer) = memo_key.as_deref().and_then(kept_answer) {
1266            return Ok(answer);
1267        }
1268        let Some(addr) = self.addr else {
1269            return Err(Error::Bad(format!(
1270                "{} is not a socket this seat can pin",
1271                self.base
1272            )));
1273        };
1274        let token = load_token();
1275        *self.token.lock().unwrap_or_else(|p| p.into_inner()) = token.clone();
1276        let allow = bearer && token.is_some() && !foreign(&self.base);
1277        if crate::tool_bound::armed() {
1278            crate::tool_bound::note("packsetd");
1279        }
1280        let budget = tightened(method, timeout);
1281        let _held = HeldDeadline::enter(budget);
1282        let _clock = PackClock::enter(budget);
1283        let call = Wire {
1284            method,
1285            path,
1286            query,
1287            body,
1288            token: token.as_deref(),
1289        };
1290        let answer = self.exchange(addr, &call, allow, budget)?;
1291        let answer = match answer.status {
1292            408 => return Err(degrade(&answer.body, method, true)),
1293            503 => match answer
1294                .retry_after
1295                .filter(|wait| !deadline_passed() && retry_room(*wait))
1296            {
1297                Some(wait) => {
1298                    std::thread::sleep(wait);
1299                    if deadline_passed() {
1300                        return Err(degrade(&answer.body, method, true));
1301                    }
1302                    self.exchange(addr, &call, allow, budget)?
1303                }
1304                None => return Err(degrade(&answer.body, method, deadline_passed())),
1305            },
1306            _ => answer,
1307        };
1308        if answer.status == 408 {
1309            return Err(degrade(&answer.body, method, true));
1310        }
1311        if answer.status == 503 {
1312            return Err(degrade(&answer.body, method, false));
1313        }
1314        if deadline_passed() && !(200..300).contains(&answer.status) {
1315            return Err(degrade(&answer.body, method, true));
1316        }
1317        if let Some(key) = &memo_key {
1318            if (200..300).contains(&answer.status) {
1319                keep_answer(key.clone(), &answer);
1320            }
1321        }
1322        Ok(answer)
1323    }
1324
1325    fn exchange(
1326        &self,
1327        addr: SocketAddr,
1328        call: &Wire<'_>,
1329        allow: bool,
1330        timeout: Duration,
1331    ) -> Result<Answer, Error> {
1332        // The proof and the call share this budget. A nested guard keeps
1333        // the command's earlier deadline, so a hook stays under 2s and an
1334        // interactive command under 5s across every socket of the call.
1335        // A restart does not start the budget over.
1336        let _budget = PackClock::enter(timeout);
1337        let slot = slot_for(addr);
1338        let mut live = slot.lock().unwrap_or_else(|p| p.into_inner());
1339        let tag = token_tag(call.token);
1340        if live.token_tag != tag {
1341            live.stream = None;
1342            live.proven = false;
1343            live.token_tag = tag;
1344        }
1345        let carried = if allow {
1346            match self.gate(&mut live, addr, timeout)? {
1347                Gate::Bare => None,
1348                Gate::Send => call.token,
1349                Gate::Busy(raw) => {
1350                    live.carried = false;
1351                    live.proven = false;
1352                    return Ok(Answer {
1353                        status: raw.status,
1354                        body: raw.body,
1355                        trusted: false,
1356                        retry_after: raw.retry_after,
1357                    });
1358                }
1359            }
1360        } else {
1361            None
1362        };
1363        if live.stream.is_none() {
1364            match connect(addr, timeout) {
1365                Ok(stream) => live.stream = Some(stream),
1366                Err(err) => {
1367                    live.proven = false;
1368                    live.carried = false;
1369                    return Err(Error::Io(err));
1370                }
1371            }
1372            // The probes ran on sockets the writer closed. This socket is
1373            // the one the token rides, the third step of the same proof,
1374            // not a later call.
1375            live.proven = false;
1376        }
1377        let raw = match send(
1378            &mut live,
1379            &self.origin,
1380            &Wire {
1381                token: carried,
1382                ..*call
1383            },
1384            timeout,
1385        ) {
1386            Ok(raw) => raw,
1387            Err(err) => {
1388                live.stream = None;
1389                live.proven = false;
1390                live.carried = false;
1391                return Err(Error::Io(err));
1392            }
1393        };
1394        let trusted = carried.is_some();
1395        live.carried = trusted;
1396        if trusted && live.stream.is_some() {
1397            live.proven = true;
1398        } else if live.stream.is_none() {
1399            live.proven = false;
1400        }
1401        Ok(Answer {
1402            status: raw.status,
1403            body: raw.body,
1404            trusted,
1405            retry_after: raw.retry_after,
1406        })
1407    }
1408
1409    /// Two refusals on this socket, then the call may carry the token.
1410    /// A busy writer (503) is returned as that answer; the call is not sent.
1411    fn gate(&self, live: &mut Slot, addr: SocketAddr, timeout: Duration) -> Result<Gate, Error> {
1412        if live.proven && live.stream.is_some() {
1413            return Ok(Gate::Send);
1414        }
1415        live.proven = false;
1416        let (Some(id), Some(bogus)) = (nonce(), nonce()) else {
1417            return Err(Error::Io(std::io::Error::other(
1418                "no randomness for the pack proof",
1419            )));
1420        };
1421        let workspace = self.proof_workspace();
1422        let path = format!("/v1/atoms/{}", &id[..32]);
1423        let query = [("workspace", workspace.as_str())];
1424        let mut stage = 0u8;
1425        let mut restarts = 0u8;
1426        loop {
1427            if live.stream.is_none() {
1428                live.stream = Some(connect(addr, timeout).map_err(Error::Io)?);
1429                live.proven = false;
1430            }
1431            let shown = if stage == 0 {
1432                None
1433            } else {
1434                Some(bogus.as_str())
1435            };
1436            let raw = match send(
1437                live,
1438                &self.origin,
1439                &Wire {
1440                    method: "GET",
1441                    path: &path,
1442                    query: &query,
1443                    body: None,
1444                    token: shown,
1445                },
1446                timeout,
1447            ) {
1448                Ok(raw) => raw,
1449                Err(err) => {
1450                    live.stream = None;
1451                    live.proven = false;
1452                    // A silent writer is the answer. Another socket would
1453                    // wait the budget out again, and three of those is a
1454                    // minute and a half. A closed socket may be tried
1455                    // again, inside the same deadline.
1456                    if io_is_quiet(&err) {
1457                        return Err(Error::Io(err));
1458                    }
1459                    restarts += 1;
1460                    let spent = clock_deadline().is_some_and(|deadline| Instant::now() >= deadline);
1461                    if spent || restarts > 2 {
1462                        return Err(Error::Io(err));
1463                    }
1464                    stage = 0;
1465                    continue;
1466                }
1467            };
1468            // A shed or a passed deadline is the answer. The call is not sent.
1469            if raw.status == 503 || raw.status == 408 {
1470                return Ok(Gate::Busy(raw));
1471            }
1472            if raw.status != 401 {
1473                return Ok(Gate::Bare);
1474            }
1475            if stage == 0 {
1476                stage = 1;
1477                continue;
1478            }
1479            return Ok(Gate::Send);
1480        }
1481    }
1482
1483    fn proof_workspace(&self) -> String {
1484        if let Some(workspace) = &self.workspace {
1485            return workspace.clone();
1486        }
1487        std::env::var("PACKSET_WORKSPACE")
1488            .ok()
1489            .filter(|w| !w.is_empty())
1490            .unwrap_or_else(|| "seat".to_string())
1491    }
1492
1493    /// [`Self::roundtrip`] with the token when the proof holds.
1494    fn tried(
1495        &self,
1496        method: &str,
1497        path: &str,
1498        query: &[(&str, &str)],
1499        body: Option<&[u8]>,
1500        timeout: Duration,
1501    ) -> Result<Answer, Error> {
1502        self.roundtrip(method, path, query, body, true, timeout)
1503    }
1504
1505    fn json_ok<T: DeserializeOwned>(&self, path: &str, answer: Answer) -> Result<T, Error> {
1506        if !(200..300).contains(&answer.status) {
1507            return Err(refused(
1508                &format!("{}{path}", self.base),
1509                answer.status,
1510                &answer.body,
1511            ));
1512        }
1513        serde_json::from_str(&answer.body).map_err(Error::from)
1514    }
1515
1516    /// The `line` field of `GET /stats`: `ok`, or `busy, retrying` when
1517    /// the writer is shedding. One try, so doctor does not spend the
1518    /// read budget on a writer that has no `/stats`. `None` when the
1519    /// writer does not answer, or the body has no line.
1520    #[must_use]
1521    pub fn admission_line(&self) -> Option<String> {
1522        let timeout =
1523            Duration::from_millis(300).min(clock_left().unwrap_or(Duration::from_secs(1)));
1524        let answer = self
1525            .exchange_direct("GET", "/stats", &[], None, true, timeout)
1526            .ok()?;
1527        if answer.status == 503 {
1528            return Some("busy, retrying".to_string());
1529        }
1530        if !(200..300).contains(&answer.status) {
1531            return None;
1532        }
1533        let value: Value = serde_json::from_str(&answer.body).ok()?;
1534        value
1535            .get("line")
1536            .and_then(Value::as_str)
1537            .filter(|line| !line.is_empty())
1538            .map(str::to_string)
1539    }
1540
1541    /// One exchange, with the deadline header and no retry. `/stats` uses
1542    /// this so a busy writer is one answer, not a wait.
1543    fn exchange_direct(
1544        &self,
1545        method: &str,
1546        path: &str,
1547        query: &[(&str, &str)],
1548        body: Option<&[u8]>,
1549        bearer: bool,
1550        timeout: Duration,
1551    ) -> Result<Answer, Error> {
1552        let Some(addr) = self.addr else {
1553            return Err(Error::Bad(format!(
1554                "{} is not a socket this seat can pin",
1555                self.base
1556            )));
1557        };
1558        let token = load_token();
1559        let allow = bearer && token.is_some() && !foreign(&self.base);
1560        if crate::tool_bound::armed() {
1561            crate::tool_bound::note("packsetd");
1562        }
1563        let budget = admission_retry_budget(method, timeout);
1564        let _held = HeldDeadline::enter(budget);
1565        self.exchange(
1566            addr,
1567            &Wire {
1568                method,
1569                path,
1570                query,
1571                body,
1572                token: token.as_deref(),
1573            },
1574            allow,
1575            timeout,
1576        )
1577    }
1578
1579    pub fn health(&self) -> Result<String, Error> {
1580        let answer = self.tried("GET", "/health", &[], None, HEALTH_BUDGET)?;
1581        if !(200..300).contains(&answer.status) {
1582            return Err(refused(
1583                &format!("{}/health", self.base),
1584                answer.status,
1585                &answer.body,
1586            ));
1587        }
1588        Ok(answer.body)
1589    }
1590
1591    pub fn status(&self, workspace: Option<&str>) -> Result<Value, Error> {
1592        let query: Vec<(&str, &str)> = workspace
1593            .map(|workspace| vec![("workspace", workspace)])
1594            .unwrap_or_default();
1595        let answer = self.tried("GET", "/v1/status", &query, None, STATUS_BUDGET)?;
1596        self.json_ok("/v1/status", answer)
1597    }
1598
1599    pub fn post_atom(&self, atom: &Value) -> Result<Value, Error> {
1600        let bytes = serde_json::to_vec(atom)?;
1601        let answer = self.tried(
1602            "POST",
1603            "/v1/atoms",
1604            &[],
1605            Some(&bytes),
1606            super::REMEMBER_BUDGET,
1607        )?;
1608        self.json_ok("/v1/atoms", answer)
1609    }
1610
1611    pub fn delete_atom(
1612        &self,
1613        workspace: &str,
1614        id: &str,
1615        why: Option<&str>,
1616    ) -> Result<Value, Error> {
1617        let mut body = serde_json::json!({"workspace": workspace, "id": id});
1618        if let Some(accession) = why {
1619            body["why"] = Value::String(accession.to_string());
1620        }
1621        let bytes = serde_json::to_vec(&body)?;
1622        let answer = self.tried(
1623            "POST",
1624            "/v1/atoms/delete",
1625            &[],
1626            Some(&bytes),
1627            super::REMEMBER_BUDGET,
1628        )?;
1629        if answer.status == 404 {
1630            return Err(Error::Bad(format!("no atom {id}")));
1631        }
1632        self.json_ok("/v1/atoms/delete", answer)
1633    }
1634
1635    pub fn atoms_of_kind(&self, workspace: &str, kind: &str) -> Result<Vec<Value>, Error> {
1636        let answer = self.tried(
1637            "GET",
1638            "/v1/atoms",
1639            &[("workspace", workspace), ("kind", kind)],
1640            None,
1641            super::SEARCH_PACK_BUDGET,
1642        )?;
1643        let body: Value = self.json_ok("/v1/atoms", answer)?;
1644        Ok(body
1645            .get("atoms")
1646            .and_then(Value::as_array)
1647            .cloned()
1648            .unwrap_or_default())
1649    }
1650
1651    pub fn atoms_in_set(&self, workspace: &str, set: &str) -> Result<Vec<Value>, Error> {
1652        let answer = self.tried(
1653            "GET",
1654            "/v1/pack",
1655            &[("workspace", workspace), ("set", set)],
1656            None,
1657            super::SEARCH_PACK_BUDGET,
1658        )?;
1659        let body: Value = self.json_ok("/v1/pack", answer)?;
1660        Ok(body
1661            .get("atoms")
1662            .and_then(Value::as_array)
1663            .cloned()
1664            .unwrap_or_default())
1665    }
1666
1667    pub fn sweep(&self, workspace: &str) -> Result<Value, Error> {
1668        let bytes = serde_json::to_vec(&serde_json::json!({"workspace": workspace}))?;
1669        let answer = self.tried(
1670            "POST",
1671            "/v1/sweep",
1672            &[],
1673            Some(&bytes),
1674            super::REMEMBER_BUDGET,
1675        )?;
1676        self.json_ok("/v1/sweep", answer)
1677    }
1678
1679    pub fn grade(&self, workspace: &str, id: &str, recalled: bool) -> Result<Value, Error> {
1680        let bytes = serde_json::to_vec(&serde_json::json!({
1681            "workspace": workspace,
1682            "id": id,
1683            "recalled": recalled,
1684        }))?;
1685        let answer = self.tried(
1686            "POST",
1687            "/v1/grade",
1688            &[],
1689            Some(&bytes),
1690            super::REMEMBER_BUDGET,
1691        )?;
1692        self.json_ok("/v1/grade", answer)
1693    }
1694
1695    /// Merge `fields` into one current atom, under its id.
1696    pub fn update_atom(&self, workspace: &str, id: &str, fields: &Value) -> Result<Value, Error> {
1697        let bytes = serde_json::to_vec(&serde_json::json!({
1698            "workspace": workspace,
1699            "id": id,
1700            "fields": fields,
1701        }))?;
1702        let answer = self.tried(
1703            "POST",
1704            "/v1/atoms/update",
1705            &[],
1706            Some(&bytes),
1707            super::REMEMBER_BUDGET,
1708        )?;
1709        self.json_ok("/v1/atoms/update", answer)
1710    }
1711
1712    pub fn activate_as(
1713        &self,
1714        workspace: &str,
1715        q: &str,
1716        limit: u32,
1717        fire: bool,
1718        lens: Option<&str>,
1719    ) -> Result<Value, Error> {
1720        let limit = limit.to_string();
1721        let fire = if fire { "1" } else { "0" };
1722        let mut query = vec![
1723            ("workspace", workspace),
1724            ("q", q),
1725            ("limit", limit.as_str()),
1726            ("fire", fire),
1727        ];
1728        if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
1729            query.push(("as", name));
1730        }
1731        let answer = self.tried(
1732            "GET",
1733            "/v1/activate",
1734            &query,
1735            None,
1736            super::SEARCH_PACK_BUDGET,
1737        )?;
1738        self.json_ok("/v1/activate", answer)
1739    }
1740
1741    pub fn hubs(&self, workspace: &str, limit: usize) -> Result<Value, Error> {
1742        let limit = limit.to_string();
1743        let answer = self.tried(
1744            "GET",
1745            "/v1/hubs",
1746            &[("workspace", workspace), ("limit", limit.as_str())],
1747            None,
1748            super::SEARCH_PACK_BUDGET,
1749        )?;
1750        self.json_ok("/v1/hubs", answer)
1751    }
1752
1753    pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<Value, Error> {
1754        let bytes =
1755            serde_json::to_vec(&serde_json::json!({"workspace": workspace, "apply": apply}))?;
1756        let answer = self.tried(
1757            "POST",
1758            "/v1/consolidate",
1759            &[],
1760            Some(&bytes),
1761            super::REMEMBER_BUDGET,
1762        )?;
1763        self.json_ok("/v1/consolidate", answer)
1764    }
1765
1766    pub fn search_opts(
1767        &self,
1768        workspace: &str,
1769        q: &str,
1770        limit: u32,
1771        as_of: Option<&str>,
1772        rerank: bool,
1773    ) -> Result<Vec<Hit>, Error> {
1774        Ok(self.search_fetched(workspace, q, limit, as_of, rerank)?.0)
1775    }
1776
1777    pub(crate) fn search_fetched(
1778        &self,
1779        workspace: &str,
1780        q: &str,
1781        limit: u32,
1782        as_of: Option<&str>,
1783        rerank: bool,
1784    ) -> Result<(Vec<Hit>, bool), Error> {
1785        let limit_s = limit.to_string();
1786        let mut query = vec![
1787            ("workspace", workspace),
1788            ("q", q),
1789            ("limit", limit_s.as_str()),
1790        ];
1791        if let Some(at) = as_of {
1792            query.push(("as_of", at));
1793        }
1794        if rerank {
1795            query.push(("rerank", "1"));
1796        }
1797        // A rerank outside a clock, a hook, or an MCP call may load the
1798        // model, so it gets a minute. A sitting clock and an MCP read stay
1799        // near one second, unless the seat raised the tool bound and the
1800        // pack timeout. Any other search is the short search budget.
1801        let timeout = if rerank
1802            && !clock_armed()
1803            && std::env::var_os("LJOS_IN_HOOK").is_none()
1804            && !crate::tool_bound::armed()
1805        {
1806            Duration::from_secs(60)
1807        } else if crate::tool_bound::armed() {
1808            mcp_read_cap()
1809        } else {
1810            super::SEARCH_PACK_BUDGET
1811        };
1812        let answer = self.tried("GET", "/v1/search", &query, None, timeout)?;
1813        let trusted = answer.trusted;
1814        let body: Value = self.json_ok("/v1/search", answer)?;
1815        let hits = body.get("hits").cloned().unwrap_or(Value::Array(vec![]));
1816        Ok((serde_json::from_value(hits)?, trusted))
1817    }
1818
1819    pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<Value, Error> {
1820        let bytes = serde_json::to_vec(&serde_json::json!({
1821            "workspace": workspace,
1822            "ids": ids,
1823            "as": "",
1824        }))?;
1825        let answer = self.tried(
1826            "POST",
1827            "/v1/fire",
1828            &[],
1829            Some(&bytes),
1830            super::REMEMBER_BUDGET,
1831        )?;
1832        self.json_ok("/v1/fire", answer)
1833    }
1834}
1835
1836fn refused(url: &str, code: u16, body: &str) -> Error {
1837    let reason = serde_json::from_str::<Value>(body)
1838        .ok()
1839        .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
1840        .unwrap_or_else(|| body.to_string());
1841    let reason = reason.trim();
1842    if code == 401 {
1843        return Error::Bad(format!(
1844            "{url}: 401: {reason}; this seat's token does not match the writer's, \
1845             so it is another user's writer or PACKSET_HOME names another pack"
1846        ));
1847    }
1848    if reason.is_empty() {
1849        Error::Bad(format!("{url}: status code {code}"))
1850    } else {
1851        Error::Bad(format!("{url}: {code}: {reason}"))
1852    }
1853}
1854
1855fn send(live: &mut Slot, origin: &Url, call: &Wire<'_>, timeout: Duration) -> std::io::Result<Raw> {
1856    if pack_quiet() {
1857        return Err(quiet_io());
1858    }
1859    if side_budget_spent() {
1860        return Err(budget_spent());
1861    }
1862    let started = Instant::now();
1863    let result = send_once(live, origin, call, timeout);
1864    // An answer is not charged. A read that ends without one is a quiet
1865    // socket and spends the budget, so the next read does not open another.
1866    // Outside a hook the side budget is not armed. The quiet flag is that
1867    // same stop for a command, which asks the pack more than once.
1868    if let Err(err) = &result {
1869        if io_is_quiet(err) {
1870            mark_pack_quiet();
1871        }
1872        super::charge_side_wait(started.elapsed());
1873    }
1874    result
1875}
1876
1877fn send_once(
1878    live: &mut Slot,
1879    origin: &Url,
1880    call: &Wire<'_>,
1881    timeout: Duration,
1882) -> std::io::Result<Raw> {
1883    let stream = live
1884        .stream
1885        .as_mut()
1886        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotConnected, "no pack socket"))?;
1887    let wait = bounded_wait(timeout, false)?;
1888    let deadline = Instant::now() + wait;
1889    stream.set_read_timeout(Some(wait))?;
1890    stream.set_write_timeout(Some(wait))?;
1891    let bytes = render(
1892        call.method,
1893        &host_header(origin),
1894        &target(call.path, call.query),
1895        call.token,
1896        call.body,
1897    );
1898    if let Err(err) = write_all(stream, &bytes) {
1899        live.stream = None;
1900        live.proven = false;
1901        return Err(err);
1902    }
1903    // Mail, the lean listing, search, status, health and the other pack
1904    // calls all read the answer here.
1905    match read_response(stream, deadline) {
1906        Ok(raw) => {
1907            if !raw.keep {
1908                live.stream = None;
1909                live.proven = false;
1910            }
1911            Ok(raw)
1912        }
1913        Err(err) => {
1914            live.stream = None;
1915            live.proven = false;
1916            Err(err)
1917        }
1918    }
1919}
1920
1921#[cfg(test)]
1922mod tests {
1923    use super::{foreign, OwnerOnce, TABLE_READS};
1924
1925    fn reads() -> usize {
1926        TABLE_READS.with(std::cell::Cell::get)
1927    }
1928
1929    /// The requests of one search share one read of the socket tables for
1930    /// each url. A guard inside it keeps that read, and a call after the
1931    /// search reads them again.
1932    #[test]
1933    fn a_search_reads_the_port_owner_once_for_each_url() {
1934        let _g = crate::tests::env_guard();
1935        // SAFETY: under the lock every environment-reading test takes.
1936        unsafe { std::env::remove_var("LJOS_IN_HOOK") };
1937        let (url, other) = ("http://127.0.0.1:9", "http://127.0.0.1:10");
1938        let start = reads();
1939        foreign(url);
1940        foreign(url);
1941        assert_eq!(reads() - start, 2, "outside a search each call reads");
1942        {
1943            let _search = OwnerOnce::enter();
1944            let first = foreign(url);
1945            {
1946                let _inner = OwnerOnce::enter();
1947                assert_eq!(foreign(url), first);
1948            }
1949            assert_eq!(foreign(url), first);
1950            foreign(other);
1951            foreign(other);
1952            assert_eq!(reads() - start, 4, "one search reads once a url");
1953        }
1954        foreign(url);
1955        assert_eq!(reads() - start, 5, "a call after the search reads again");
1956    }
1957
1958    /// packsetd 0.17.9 streams `/v1/atoms` in 16 KiB chunks. The client
1959    /// hands the caller the JSON, not the chunk sizes around it.
1960    #[test]
1961    fn a_chunked_listing_reads_as_its_json() {
1962        use std::io::Write;
1963        let atom = format!(r#"{{"id":"a","text":"{}"}}"#, "x".repeat(20 * 1024));
1964        let json = format!(r#"{{"atoms":[{atom},{atom}]}}"#);
1965        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1966        let addr = listener.local_addr().unwrap();
1967        let sent = json.clone();
1968        std::thread::spawn(move || {
1969            let (mut sock, _) = listener.accept().unwrap();
1970            let head = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
1971                        Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n";
1972            sock.write_all(head.as_bytes()).unwrap();
1973            for piece in sent.as_bytes().chunks(16 * 1024) {
1974                let mut chunk = format!("{:x}\r\n", piece.len()).into_bytes();
1975                chunk.extend_from_slice(piece);
1976                chunk.extend_from_slice(b"\r\n");
1977                // Split each chunk across writes, so a read ends mid-chunk.
1978                let (one, two) = chunk.split_at(chunk.len() / 3);
1979                sock.write_all(one).unwrap();
1980                sock.flush().unwrap();
1981                sock.write_all(two).unwrap();
1982            }
1983            sock.write_all(b"0\r\n\r\n").unwrap();
1984        });
1985        let mut stream = std::net::TcpStream::connect(addr).unwrap();
1986        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1987        let raw = super::read_response(&mut stream, deadline).unwrap();
1988        assert_eq!(raw.status, 200);
1989        assert_eq!(raw.body, json);
1990        let parsed: serde_json::Value = serde_json::from_str(&raw.body).unwrap();
1991        assert_eq!(parsed["atoms"].as_array().map(Vec::len), Some(2));
1992    }
1993
1994    #[test]
1995    fn a_chunk_size_that_is_not_hex_is_refused() {
1996        let mut out = Vec::new();
1997        let err = super::Chunks::default()
1998            .feed(b"zz\r\nhello\r\n0\r\n\r\n", &mut out)
1999            .unwrap_err();
2000        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2001        let mut out = Vec::new();
2002        let done = super::Chunks::default()
2003            .feed(b"5;ext=1\r\nhello\r\n0\r\nX-T: 1\r\n\r\n", &mut out)
2004            .unwrap();
2005        assert!(done);
2006        assert_eq!(out, b"hello");
2007    }
2008
2009    /// A chunk may end in the middle of a read. The next read continues
2010    /// that chunk, and bytes already taken stay taken.
2011    #[test]
2012    fn a_chunk_that_arrives_in_pieces_is_one_body() {
2013        let mut chunks = super::Chunks::default();
2014        let mut out = Vec::new();
2015        let mut raw = b"3\r\nabc\r\n4\r\n".to_vec();
2016        assert!(!chunks.feed(&raw, &mut out).unwrap());
2017        assert_eq!(out, b"abc");
2018        raw.extend_from_slice(b"defg\r\n0\r\n\r\n");
2019        assert!(chunks.feed(&raw, &mut out).unwrap());
2020        assert_eq!(out, b"abcdefg");
2021    }
2022
2023    /// The bytes packsetd writes for a streamed body: at most 16 KiB in a
2024    /// chunk, then a zero chunk. `write_chunked` in packsetd is this.
2025    fn packsetd_chunks(body: &[u8]) -> Vec<u8> {
2026        let mut out = Vec::new();
2027        let mut buf = Vec::new();
2028        for piece in body.chunks(4096) {
2029            buf.extend_from_slice(piece);
2030            if buf.len() >= 16 * 1024 {
2031                push_chunk(&mut out, &buf);
2032                buf.clear();
2033            }
2034        }
2035        if !buf.is_empty() {
2036            push_chunk(&mut out, &buf);
2037        }
2038        out.extend_from_slice(b"0\r\n\r\n");
2039        out
2040    }
2041
2042    fn push_chunk(out: &mut Vec<u8>, buf: &[u8]) {
2043        out.extend_from_slice(format!("{:x}\r\n", buf.len()).as_bytes());
2044        out.extend_from_slice(buf);
2045        out.extend_from_slice(b"\r\n");
2046    }
2047
2048    /// Answers each request with a chunked body, split across two writes.
2049    fn serve_chunked(on: impl Fn(&str) -> String + Send + 'static) -> super::PacksetClient {
2050        use std::io::{Read, Write};
2051        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2052        let addr = listener.local_addr().unwrap();
2053        std::thread::spawn(move || {
2054            for stream in listener.incoming() {
2055                let Ok(mut sock) = stream else { break };
2056                let _ = sock.set_read_timeout(Some(std::time::Duration::from_secs(2)));
2057                let mut buf = Vec::new();
2058                let mut tmp = [0u8; 2048];
2059                loop {
2060                    match sock.read(&mut tmp) {
2061                        Ok(0) | Err(_) => break,
2062                        Ok(n) => {
2063                            buf.extend_from_slice(&tmp[..n]);
2064                            if buf.windows(4).any(|w| w == b"\r\n\r\n") {
2065                                break;
2066                            }
2067                        }
2068                    }
2069                }
2070                let req = String::from_utf8_lossy(&buf);
2071                let body = on(&req);
2072                let head = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
2073                            Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n";
2074                if sock.write_all(head.as_bytes()).is_err() {
2075                    continue;
2076                }
2077                let framed = packsetd_chunks(body.as_bytes());
2078                let mid = framed.len() / 2;
2079                if sock.write_all(&framed[..mid]).is_err() {
2080                    continue;
2081                }
2082                let _ = sock.flush();
2083                let _ = sock.write_all(&framed[mid..]);
2084            }
2085        });
2086        super::PacksetClient::new(format!("http://{addr}"))
2087    }
2088
2089    /// Mail, the lean listing, search and status are not separate readers.
2090    /// A chunked answer has to come back as JSON on each of them.
2091    #[test]
2092    fn mail_listing_search_and_status_read_a_chunked_body() {
2093        let _g = crate::tests::env_guard();
2094        let _home = quiet_pack();
2095        let text = "x".repeat(20 * 1024);
2096        let listing = format!(r#"{{"atoms":[{{"id":"a","kind":"lesson","text":"{text}"}}]}}"#);
2097        let client = serve_chunked(move |req| {
2098            if req.contains("/v1/status") {
2099                r#"{"version":"0.17.9"}"#.to_string()
2100            } else if req.contains("/v1/search") {
2101                r#"{"hits":[{"text":"kept"}]}"#.to_string()
2102            } else if req.contains("kind=message") {
2103                r#"{"atoms":[{"id":"m","kind":"message","text":"a letter for this seat"}]}"#
2104                    .to_string()
2105            } else if req.contains("kind=receipt") || req.contains("kind=group") {
2106                r#"{"atoms":[]}"#.to_string()
2107            } else if req.contains("/health") {
2108                "ok".to_string()
2109            } else if req.contains("/v1/atoms") {
2110                listing.clone()
2111            } else {
2112                r#"{}"#.to_string()
2113            }
2114        });
2115        let client = client.with_workspace("seat");
2116        let atoms = crate::atoms_lean(&client, "seat").unwrap();
2117        assert_eq!(atoms.len(), 1);
2118        assert_eq!(atoms[0]["text"].as_str().map(str::len), Some(text.len()));
2119        let letters = crate::atoms_mail(&client, "seat").unwrap();
2120        assert_eq!(letters.len(), 1);
2121        assert_eq!(letters[0]["kind"], "message");
2122        let status = client.status(Some("seat")).unwrap();
2123        assert_eq!(status["version"], "0.17.9");
2124        let (hits, _) = client
2125            .search_fetched("seat", "kept", 1, None, false)
2126            .unwrap();
2127        assert_eq!(hits.len(), 1);
2128        assert_eq!(hits[0].text, "kept");
2129        assert_eq!(client.health().unwrap(), "ok");
2130    }
2131
2132    /// The whole answer on the socket, headers included.
2133    fn read_raw(port: u16, path: &str) -> Vec<u8> {
2134        use std::io::{Read, Write};
2135        let mut sock = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
2136        sock.set_read_timeout(Some(std::time::Duration::from_secs(5)))
2137            .unwrap();
2138        sock.set_write_timeout(Some(std::time::Duration::from_secs(5)))
2139            .unwrap();
2140        let req = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
2141        sock.write_all(req.as_bytes()).unwrap();
2142        let mut buf = Vec::new();
2143        let mut tmp = [0u8; 4096];
2144        loop {
2145            match sock.read(&mut tmp) {
2146                Ok(0) => break,
2147                Ok(n) => buf.extend_from_slice(&tmp[..n]),
2148                Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
2149                Err(err)
2150                    if err.kind() == std::io::ErrorKind::WouldBlock
2151                        || err.kind() == std::io::ErrorKind::TimedOut =>
2152                {
2153                    break;
2154                }
2155                Err(err) => panic!("reading the pack: {err}"),
2156            }
2157        }
2158        buf
2159    }
2160
2161    /// The decoded body when `raw` is a chunked answer, or nothing when
2162    /// the writer still sends a length.
2163    fn chunked_json(raw: &[u8]) -> Option<String> {
2164        let at = raw.windows(4).position(|w| w == b"\r\n\r\n")?;
2165        let header = String::from_utf8_lossy(&raw[..at]);
2166        let chunked = header.lines().skip(1).any(|line| {
2167            line.split_once(':').is_some_and(|(name, value)| {
2168                name.eq_ignore_ascii_case("transfer-encoding")
2169                    && value
2170                        .split(',')
2171                        .any(|coding| coding.trim().eq_ignore_ascii_case("chunked"))
2172            })
2173        });
2174        if !chunked {
2175            return None;
2176        }
2177        let mut out = Vec::new();
2178        let done = super::Chunks::default()
2179            .feed(&raw[at + 4..], &mut out)
2180            .expect("the writer's chunks");
2181        assert!(done, "the writer's chunked listing did not finish");
2182        Some(String::from_utf8(out).expect("the listing is utf-8"))
2183    }
2184
2185    /// The packsetd a test starts. A writer that streams `/v1/atoms`
2186    /// (0.17.9) is checked as chunks. An older writer still has to answer
2187    /// with JSON through the same reader.
2188    #[test]
2189    fn a_live_packsetd_listing_is_json() {
2190        if !ljos_test_boot::Packsetd::on_path() {
2191            eprintln!("skipped: packsetd is not installed");
2192            return;
2193        }
2194        let _g = crate::tests::env_guard();
2195        let _home = quiet_pack();
2196        let daemon = ljos_test_boot::Packsetd::open();
2197        let raw = read_raw(daemon.port(), "/v1/atoms?workspace=seat&embedding=omit");
2198        let client = super::PacksetClient::new(daemon.url()).with_workspace("seat");
2199        let atoms = crate::atoms_lean(&client, "seat")
2200            .unwrap_or_else(|err| panic!("lean listing: {err}\n{}", daemon.log()));
2201        let letters = crate::atoms_mail(&client, "seat")
2202            .unwrap_or_else(|err| panic!("mail listing: {err}\n{}", daemon.log()));
2203        assert!(letters.iter().all(serde_json::Value::is_object));
2204        let status = client
2205            .status(Some("seat"))
2206            .unwrap_or_else(|err| panic!("status: {err}\n{}", daemon.log()));
2207        assert!(status.is_object(), "{status}");
2208        let health = client
2209            .health()
2210            .unwrap_or_else(|err| panic!("health: {err}\n{}", daemon.log()));
2211        assert!(!health.is_empty(), "{health}");
2212        let search = client
2213            .roundtrip(
2214                "GET",
2215                "/v1/search",
2216                &[("workspace", "seat"), ("q", "seat"), ("limit", "1")],
2217                None,
2218                false,
2219                std::time::Duration::from_secs(5),
2220            )
2221            .unwrap_or_else(|err| panic!("search: {err}\n{}", daemon.log()));
2222        assert!(
2223            (200..300).contains(&search.status),
2224            "search {}\n{}\n{}",
2225            search.status,
2226            search.body,
2227            daemon.log()
2228        );
2229        serde_json::from_str::<serde_json::Value>(&search.body).expect("search answer is JSON");
2230        if let Some(json) = chunked_json(&raw) {
2231            let parsed: serde_json::Value =
2232                serde_json::from_str(&json).expect("chunked listing is JSON");
2233            assert_eq!(
2234                parsed["atoms"].as_array().map(Vec::len),
2235                Some(atoms.len()),
2236                "the client dropped atoms from the chunked listing"
2237            );
2238            let body = &raw[raw.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4..];
2239            assert!(
2240                serde_json::from_slice::<serde_json::Value>(body).is_err(),
2241                "a chunked listing was already bare JSON"
2242            );
2243        }
2244    }
2245
2246    fn quiet_pack() -> tempfile::TempDir {
2247        let home = tempfile::tempdir().unwrap();
2248        unsafe {
2249            std::env::remove_var("PACKSET_TOKEN");
2250            std::env::remove_var("PACKSET_TOKEN_FILE");
2251            std::env::remove_var("PACKSET_TIMEOUT_MS");
2252            std::env::remove_var("LJOS_IN_HOOK");
2253            std::env::set_var("PACKSET_HOME", home.path());
2254        }
2255        home
2256    }
2257
2258    /// `on` sees the request text and how many requests came before this one.
2259    /// It answers with a status, a body, and `Retry-After` seconds when set.
2260    fn serve(
2261        on: impl Fn(usize, &str) -> (u16, String, Option<u64>) + Send + 'static,
2262    ) -> (
2263        super::PacksetClient,
2264        std::sync::Arc<std::sync::Mutex<Vec<String>>>,
2265    ) {
2266        use std::io::{Read, Write};
2267        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2268        let addr = listener.local_addr().unwrap();
2269        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2270        let log = std::sync::Arc::clone(&seen);
2271        std::thread::spawn(move || {
2272            for stream in listener.incoming() {
2273                let Ok(mut sock) = stream else { break };
2274                let _ = sock.set_read_timeout(Some(std::time::Duration::from_secs(2)));
2275                let mut buf = Vec::new();
2276                let mut tmp = [0u8; 2048];
2277                loop {
2278                    match sock.read(&mut tmp) {
2279                        Ok(0) | Err(_) => break,
2280                        Ok(n) => {
2281                            buf.extend_from_slice(&tmp[..n]);
2282                            if buf.windows(4).any(|w| w == b"\r\n\r\n") {
2283                                break;
2284                            }
2285                        }
2286                    }
2287                }
2288                let req = String::from_utf8_lossy(&buf).into_owned();
2289                let n = {
2290                    let mut log = log.lock().unwrap();
2291                    log.push(req.clone());
2292                    log.len() - 1
2293                };
2294                let (code, body, retry) = on(n, &req);
2295                let reason = match code {
2296                    200 => "OK",
2297                    408 => "Request Timeout",
2298                    503 => "Service Unavailable",
2299                    _ => "Error",
2300                };
2301                let retry = retry
2302                    .map(|secs| format!("Retry-After: {secs}\r\n"))
2303                    .unwrap_or_default();
2304                let resp = format!(
2305                    "HTTP/1.1 {code} {reason}\r\n{retry}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2306                    body.len()
2307                );
2308                let _ = sock.write_all(resp.as_bytes());
2309            }
2310        });
2311        (super::PacksetClient::new(format!("http://{addr}")), seen)
2312    }
2313
2314    fn deadline_of(req: &str) -> u64 {
2315        req.lines()
2316            .find_map(|line| {
2317                let (name, value) = line.split_once(':')?;
2318                name.eq_ignore_ascii_case("x-packset-deadline")
2319                    .then(|| value.trim().parse().ok())
2320                    .flatten()
2321            })
2322            .unwrap_or(0)
2323    }
2324
2325    fn ahead_ms(req: &str) -> u64 {
2326        let now = super::unix_ms(std::time::SystemTime::now());
2327        deadline_of(req).saturating_sub(now)
2328    }
2329
2330    #[test]
2331    fn an_mcp_read_deadline_is_about_a_second() {
2332        let _g = crate::tests::env_guard();
2333        let _home = quiet_pack();
2334        let _bound = crate::tool_bound::arm(std::time::Duration::from_secs(30));
2335        let (client, seen) = serve(|_, _| (200, "ok".into(), None));
2336        client.health().unwrap();
2337        let ahead = ahead_ms(&seen.lock().unwrap()[0]);
2338        assert!(
2339            (200..1_500).contains(&ahead),
2340            "mcp read deadline is {ahead} ms ahead"
2341        );
2342    }
2343
2344    /// A seat that raises the tool bound and the pack timeout is waiting
2345    /// on one long read. The deadline is that pack timeout, not a second.
2346    #[test]
2347    fn a_raised_mcp_bound_keeps_the_pack_timeout_on_a_read() {
2348        let _g = crate::tests::env_guard();
2349        let _home = quiet_pack();
2350        unsafe {
2351            std::env::set_var("PACKSET_TIMEOUT_MS", "45000");
2352            std::env::set_var("LJOS_MCP_TIMEOUT", "60");
2353        }
2354        let _bound = crate::tool_bound::arm(std::time::Duration::from_secs(60));
2355        let (client, seen) = serve(|_, _| (200, r#"{"hits":[]}"#.into(), None));
2356        client
2357            .search_fetched("seat", "review set", 1, None, false)
2358            .unwrap();
2359        let ahead = ahead_ms(&seen.lock().unwrap()[0]);
2360        assert!(
2361            (40_000..50_000).contains(&ahead),
2362            "raised read deadline is {ahead} ms ahead"
2363        );
2364    }
2365
2366    #[test]
2367    fn a_one_second_clock_caps_the_read_deadline() {
2368        let _g = crate::tests::env_guard();
2369        let _home = quiet_pack();
2370        let _clock = super::PackClock::enter(std::time::Duration::from_secs(1));
2371        let (client, seen) = serve(|_, _| (200, "ok".into(), None));
2372        client.health().unwrap();
2373        let ahead = ahead_ms(&seen.lock().unwrap()[0]);
2374        assert!(
2375            (200..1_500).contains(&ahead),
2376            "clocked read deadline is {ahead} ms ahead"
2377        );
2378    }
2379
2380    #[test]
2381    fn a_200_carries_the_call_deadline() {
2382        let _g = crate::tests::env_guard();
2383        let _home = quiet_pack();
2384        let (client, seen) = serve(|_, req| {
2385            assert!(deadline_of(req) > 0, "{req}");
2386            (200, "ok".into(), None)
2387        });
2388        let body = client.health().unwrap();
2389        assert_eq!(body, "ok");
2390        assert_eq!(seen.lock().unwrap().len(), 1);
2391        let now = super::unix_ms(std::time::SystemTime::now());
2392        let stamp = deadline_of(&seen.lock().unwrap()[0]);
2393        assert!(stamp > now, "deadline {stamp} is not after {now}");
2394        assert!(
2395            stamp < now + 3_000,
2396            "health deadline {stamp} is not about two seconds past {now}"
2397        );
2398    }
2399
2400    #[test]
2401    fn a_503_waits_retry_after_once_and_then_reads() {
2402        let _g = crate::tests::env_guard();
2403        let _home = quiet_pack();
2404        let (client, seen) = serve(|n, req| {
2405            assert!(deadline_of(req) > 0, "{req}");
2406            if n == 0 {
2407                (
2408                    503,
2409                    r#"{"error":"packsetd is busy: the read queue is full"}"#.into(),
2410                    Some(1),
2411                )
2412            } else {
2413                (200, "ok".into(), None)
2414            }
2415        });
2416        let started = std::time::Instant::now();
2417        let body = client.health().unwrap();
2418        let took = started.elapsed();
2419        assert_eq!(body, "ok");
2420        let got = seen.lock().unwrap().clone();
2421        assert_eq!(got.len(), 2);
2422        assert_eq!(deadline_of(&got[0]), deadline_of(&got[1]));
2423        assert!(
2424            took >= std::time::Duration::from_millis(900),
2425            "Retry-After was not waited: {took:?}"
2426        );
2427        assert!(took < std::time::Duration::from_secs(3), "{took:?}");
2428    }
2429
2430    #[test]
2431    fn a_second_503_is_the_error_line_and_stops() {
2432        let _g = crate::tests::env_guard();
2433        let _home = quiet_pack();
2434        let (client, seen) = serve(|_, _| {
2435            (
2436                503,
2437                r#"{"error":"packsetd is busy: the read queue is full"}"#.into(),
2438                Some(0),
2439            )
2440        });
2441        let started = std::time::Instant::now();
2442        let err = client.health().unwrap_err();
2443        let took = started.elapsed();
2444        assert_eq!(
2445            err.to_string(),
2446            "packsetd is busy: the read queue is full",
2447            "{err}"
2448        );
2449        let got = seen.lock().unwrap().clone();
2450        assert_eq!(got.len(), 2, "a 503 is resent once");
2451        assert_eq!(deadline_of(&got[0]), deadline_of(&got[1]));
2452        assert!(took < std::time::Duration::from_millis(500), "{took:?}");
2453    }
2454
2455    #[test]
2456    fn an_expired_deadline_is_the_error_line_and_is_not_retried() {
2457        let _g = crate::tests::env_guard();
2458        let _home = quiet_pack();
2459        let (client, seen) = serve(|_, req| {
2460            assert!(deadline_of(req) > 0, "{req}");
2461            (
2462                408,
2463                r#"{"error":"the deadline passed before this request ran"}"#.into(),
2464                None,
2465            )
2466        });
2467        let err = client.health().unwrap_err();
2468        assert_eq!(
2469            err.to_string(),
2470            "the deadline passed before this request ran",
2471            "{err}"
2472        );
2473        assert_eq!(seen.lock().unwrap().len(), 1);
2474    }
2475
2476    #[test]
2477    fn a_retry_after_past_the_deadline_does_not_wait() {
2478        let _g = crate::tests::env_guard();
2479        let _home = quiet_pack();
2480        let (client, seen) = serve(|_, _| (503, r#"{"error":"busy"}"#.into(), Some(30)));
2481        let started = std::time::Instant::now();
2482        let err = client.health().unwrap_err();
2483        let took = started.elapsed();
2484        assert_eq!(err.to_string(), "busy", "{err}");
2485        assert_eq!(seen.lock().unwrap().len(), 1, "the long wait was taken");
2486        assert!(took < std::time::Duration::from_millis(500), "{took:?}");
2487    }
2488
2489    #[test]
2490    fn stats_line_is_the_admission_line() {
2491        let _g = crate::tests::env_guard();
2492        let _home = quiet_pack();
2493        let (client, seen) = serve(|_, req| {
2494            let line = req.lines().next().unwrap_or("");
2495            if line.contains("GET /stats") {
2496                (200, r#"{"ok":true,"line":"busy, retrying"}"#.into(), None)
2497            } else {
2498                (200, r#"{"line":"ok"}"#.into(), None)
2499            }
2500        });
2501        assert_eq!(client.admission_line().as_deref(), Some("busy, retrying"));
2502        assert_eq!(seen.lock().unwrap().len(), 1);
2503        let (client, seen) = serve(|_, _| (503, r#"{"error":"busy"}"#.into(), Some(1)));
2504        assert_eq!(client.admission_line().as_deref(), Some("busy, retrying"));
2505        assert_eq!(
2506            seen.lock().unwrap().len(),
2507            1,
2508            "stats does not wait out Retry-After"
2509        );
2510    }
2511}