1use 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
23pub 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#[derive(Debug, Clone)]
47pub(crate) struct Answer {
48 pub status: u16,
49 pub body: String,
50 pub trusted: bool,
51 pub retry_after: Option<Duration>,
53}
54
55type 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
96pub(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 proven: bool,
108 token_tag: u64,
109 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 static CLOCK: std::cell::RefCell<Option<Clock>> = const { std::cell::RefCell::new(None) };
222}
223
224pub struct PackClock {
229 _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
276pub(crate) fn clock_left() -> Option<Duration> {
278 clock_deadline().map(|deadline| deadline.saturating_duration_since(Instant::now()))
279}
280
281pub(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
298fn 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 static SEARCH_OWNERS: std::cell::RefCell<Option<Vec<(String, bool)>>> =
344 const { std::cell::RefCell::new(None) };
345}
346
347pub(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 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
460const SHORT_READ: Duration = Duration::from_secs(1);
462
463fn 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 static COMMAND_DEADLINE: std::cell::RefCell<Option<SystemTime>> = const { std::cell::RefCell::new(None) };
490 static HELD_DEADLINE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
493}
494
495pub 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
563fn 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
594pub(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#[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
663fn 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
676fn retry_room(wait: Duration) -> bool {
678 held_remaining().is_some_and(|left| wait < left)
679}
680
681fn 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
719static PACK_QUIET: AtomicBool = AtomicBool::new(false);
726static QUIET_HELD: AtomicUsize = AtomicUsize::new(0);
727
728pub struct QuietPack;
730
731impl QuietPack {
732 #[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
765fn 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
789fn 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 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
820fn 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
844fn 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
871fn 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 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 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#[derive(Default)]
1000struct Chunks {
1001 at: usize,
1003}
1004
1005impl Chunks {
1006 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 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 Send,
1128 Bare,
1130 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 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 #[must_use]
1181 pub(crate) fn has_token(&self) -> bool {
1182 load_token().is_some()
1183 }
1184
1185 #[must_use]
1188 pub(crate) fn may_carry(&self) -> bool {
1189 load_token().is_some() && !foreign(&self.base)
1190 }
1191
1192 #[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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 #[test]
1933 fn a_search_reads_the_port_owner_once_for_each_url() {
1934 let _g = crate::tests::env_guard();
1935 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 #[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 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 #[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 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 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 #[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 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 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 #[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 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 #[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}