1use serde::{Deserialize, Serialize};
7use std::env;
8use std::error::Error as StdError;
9use std::path::PathBuf;
10use std::time::Duration;
11
12fn timeout() -> Duration {
17 std::env::var("PACKSET_TIMEOUT_MS")
18 .ok()
19 .and_then(|v| v.trim().parse::<u64>().ok())
20 .filter(|ms| *ms > 0)
21 .map_or(Duration::from_secs(30), Duration::from_millis)
22}
23
24const BUSY_WAIT: Duration = Duration::from_millis(50);
27const BUSY_GROWTH: u32 = 3;
29const BUSY_WAIT_CAP: Duration = Duration::from_secs(5);
31
32fn ask_again(waited: Duration, wait: Duration, budget: Duration) -> bool {
38 waited.saturating_add(wait) < budget
39}
40
41#[cfg(test)]
45fn attempts_in(budget: Duration) -> u32 {
46 let mut waited = Duration::ZERO;
47 let mut wait = BUSY_WAIT;
48 let mut attempts = 1u32;
49 while ask_again(waited, wait, budget) {
50 waited = waited.saturating_add(wait);
51 wait = (wait * BUSY_GROWTH).min(BUSY_WAIT_CAP);
52 attempts = attempts.saturating_add(1);
53 }
54 attempts
55}
56
57enum Attempt {
59 Plain(String),
61 Ureq(Box<ureq::Error>),
63}
64
65struct Pause {
70 sleep: Duration,
71 charge: Duration,
72}
73
74fn planned_wait(backoff: Duration, retry_after: Option<Duration>) -> Duration {
75 match retry_after {
76 Some(after) if after > backoff => after,
77 _ => backoff,
78 }
79}
80
81fn jittered(wait: Duration, salt: u64) -> Duration {
83 let ms = u64::try_from(wait.as_millis()).unwrap_or(u64::MAX).max(1);
84 let half = ms / 2;
85 let extra = if half == 0 { 0 } else { salt % (half + 1) };
86 Duration::from_millis(half + extra)
87}
88
89fn pause_for(
91 backoff: Duration,
92 retry_after: Option<Duration>,
93 waited: Duration,
94 budget: Duration,
95 salt: u64,
96) -> Option<Pause> {
97 let charge = planned_wait(backoff, retry_after);
98 if !ask_again(waited, charge, budget) {
99 return None;
100 }
101 let room = budget.saturating_sub(waited).max(Duration::from_millis(1));
102 Some(Pause {
103 sleep: jittered(charge, salt)
104 .min(room)
105 .max(Duration::from_millis(1)),
106 charge,
107 })
108}
109
110fn retry_after_of(header: Option<&str>) -> Option<Duration> {
111 let raw = header?.trim();
112 if raw.is_empty() {
113 return None;
114 }
115 raw.parse::<u64>().ok().map(Duration::from_secs)
116}
117
118fn jitter_salt(asked: u32) -> u64 {
119 let nanos = std::time::SystemTime::now()
120 .duration_since(std::time::UNIX_EPOCH)
121 .map(|elapsed| u64::from(elapsed.subsec_nanos()))
122 .unwrap_or(0);
123 nanos ^ u64::from(asked)
124}
125
126fn send(
136 req: ureq::Request,
137 body: Option<&serde_json::Value>,
138 budget: Duration,
139) -> Result<ureq::Response, Attempt> {
140 let deadline = std::time::SystemTime::now() + budget;
141 let deadline_ms = deadline
142 .duration_since(std::time::UNIX_EPOCH)
143 .map(|elapsed| elapsed.as_millis().to_string())
144 .unwrap_or_default();
145 let mut waited = Duration::ZERO;
146 let mut wait = BUSY_WAIT;
147 let mut asked = 0u32;
148 let mut started_daemon = false;
149 loop {
150 asked = asked.saturating_add(1);
151 let left = budget.saturating_sub(waited).max(Duration::from_millis(1));
152 let mut attempt = req.clone().timeout(left);
153 if !deadline_ms.is_empty() {
154 attempt = attempt.set("X-Packset-Deadline", &deadline_ms);
155 }
156 if started_daemon {
157 if let Some(token) = token() {
158 attempt = attempt.set("Authorization", &format!("Bearer {token}"));
159 }
160 }
161 let answered = match body {
162 Some(json) => attempt.send_json(json),
163 None => attempt.call(),
164 };
165 match answered {
166 Err(ureq::Error::Status(code, response)) if code == 503 || code == 429 => {
167 let after = retry_after_of(response.header("Retry-After"));
168 match pause_for(wait, after, waited, budget, jitter_salt(asked)) {
169 Some(pause) => {
170 std::thread::sleep(pause.sleep);
171 waited = waited.saturating_add(pause.charge);
172 wait = (wait * BUSY_GROWTH).min(BUSY_WAIT_CAP);
173 }
174 None => return Err(Attempt::Plain(busy_line(response))),
175 }
176 }
177 Err(err) if connect_refused(&err) => {
178 match pause_for(wait, None, waited, budget, jitter_salt(asked)) {
179 Some(pause) => {
180 if !started_daemon {
181 started_daemon = autostart(req.url());
182 }
183 std::thread::sleep(pause.sleep);
184 waited = waited.saturating_add(pause.charge);
185 wait = (wait * BUSY_GROWTH).min(BUSY_WAIT_CAP);
186 }
187 None => {
188 return Err(Attempt::Plain("packsetd is not running.".to_string()));
189 }
190 }
191 }
192 other => return other.map_err(|err| Attempt::Ureq(Box::new(err))),
193 }
194 }
195}
196
197fn busy_line(response: ureq::Response) -> String {
199 let text = response.into_string().unwrap_or_default();
200 let reason = serde_json::from_str::<serde_json::Value>(&text)
201 .ok()
202 .and_then(|value| {
203 value
204 .get("error")
205 .and_then(|reason| reason.as_str())
206 .map(str::to_string)
207 })
208 .unwrap_or(text);
209 let reason = reason.trim();
210 if reason.is_empty() || reason.eq_ignore_ascii_case("busy") {
211 "packsetd is busy.".to_string()
212 } else if reason.ends_with('.') {
213 format!("packsetd is busy: {reason}")
214 } else {
215 format!("packsetd is busy: {reason}.")
216 }
217}
218
219fn connect_refused(err: &ureq::Error) -> bool {
221 let ureq::Error::Transport(transport) = err else {
222 return false;
223 };
224 if transport
225 .source()
226 .and_then(|source| source.downcast_ref::<std::io::Error>())
227 .is_some_and(|io| io.kind() == std::io::ErrorKind::ConnectionRefused)
228 {
229 return true;
230 }
231 err.to_string().contains("Connection refused")
232}
233
234fn into_error(url: &str, err: Attempt) -> Error {
235 match err {
236 Attempt::Plain(text) => Error::Unavailable(text),
237 Attempt::Ureq(err) => refused(url, err),
238 }
239}
240
241fn path_seg(id: &str) -> String {
242 let mut out = String::with_capacity(id.len());
243 for b in id.bytes() {
244 match b {
245 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
246 out.push(b as char)
247 }
248 _ => out.push_str(&format!("%{b:02X}")),
249 }
250 }
251 out
252}
253
254pub const DEFAULT_PORT: u16 = 8761;
257
258pub fn load_seat_env() {
261 let Some(home) = env::var_os("HOME") else {
262 return;
263 };
264 let path = PathBuf::from(home).join(".config/ljos/env");
265 let Ok(text) = std::fs::read_to_string(path) else {
266 return;
267 };
268 for line in text.lines() {
269 let line = line.trim();
270 if line.is_empty() || line.starts_with('#') {
271 continue;
272 }
273 let Some((k, v)) = line.split_once('=') else {
274 continue;
275 };
276 let k = k.trim();
277 if k.is_empty() || env::var_os(k).is_some() {
278 continue;
279 }
280 env::set_var(k, v.trim());
281 }
282}
283
284#[must_use]
289pub fn resolved_workspace() -> String {
290 load_seat_env();
291 env::var("PACKSET_WORKSPACE")
292 .ok()
293 .map(|w| w.trim().to_string())
294 .filter(|w| !w.is_empty())
295 .unwrap_or_else(|| "seat".to_string())
296}
297
298#[must_use]
300pub fn default_port() -> u16 {
301 env::var("PACKSET_PORT")
302 .or_else(|_| env::var("LJOS_MEM_PORT"))
303 .ok()
304 .and_then(|raw| raw.trim().parse().ok())
305 .unwrap_or(DEFAULT_PORT)
306}
307
308#[must_use]
314pub fn token_path() -> Option<PathBuf> {
315 if let Some(file) = env::var_os("PACKSET_TOKEN_FILE").filter(|v| !v.is_empty()) {
316 return Some(PathBuf::from(file));
317 }
318 packset_core::home::resolve().map(|home| home.join(TOKEN_FILE))
319}
320
321pub const TOKEN_FILE: &str = "token";
323
324#[must_use]
328pub fn token() -> Option<String> {
329 if let Ok(t) = env::var("PACKSET_TOKEN") {
330 let t = t.trim().to_string();
331 if !t.is_empty() {
332 return Some(t);
333 }
334 }
335 let text = std::fs::read_to_string(token_path()?).ok()?;
336 let t = text.lines().next()?.trim().to_string();
337 (!t.is_empty()).then_some(t)
338}
339
340fn autostart(url: &str) -> bool {
345 use std::sync::atomic::{AtomicBool, Ordering};
346 static STARTED: AtomicBool = AtomicBool::new(false);
347 if STARTED.load(Ordering::Acquire) {
348 return false;
349 }
350 let Some(port) = loopback_port(url) else {
351 return false;
352 };
353 load_seat_env();
354 let Some(home) = packset_core::home::named_home() else {
355 return false;
356 };
357 let Some(bin) = daemon_binary() else {
358 return false;
359 };
360 if STARTED.swap(true, Ordering::AcqRel) {
361 return false;
362 }
363 spawn_daemon(&bin, &home, port)
364}
365
366fn daemon_binary() -> Option<PathBuf> {
368 if let Some(named) = env::var_os("PACKSET_BIN").filter(|value| !value.is_empty()) {
369 let path = PathBuf::from(named);
370 if path.is_file() {
371 return Some(path);
372 }
373 }
374 if let Some(beside) = env::current_exe()
375 .ok()
376 .and_then(|exe| exe.parent().map(|dir| dir.join("packsetd")))
377 .filter(|path| path.is_file())
378 {
379 return Some(beside);
380 }
381 let paths = env::var_os("PATH")?;
382 env::split_paths(&paths)
383 .map(|dir| dir.join("packsetd"))
384 .find(|path| path.is_file())
385}
386
387fn spawn_daemon(bin: &std::path::Path, home: &std::path::Path, port: u16) -> bool {
390 use std::process::{Command, Stdio};
391 let mut cmd = Command::new(bin);
392 cmd.arg("--home")
393 .arg(home)
394 .arg("--port")
395 .arg(port.to_string())
396 .stdin(Stdio::null());
397 if let Ok(log) = std::fs::OpenOptions::new()
398 .create(true)
399 .append(true)
400 .open(home.join("packsetd.log"))
401 {
402 if let Ok(errs) = log.try_clone() {
403 cmd.stdout(Stdio::from(log)).stderr(Stdio::from(errs));
404 }
405 } else {
406 cmd.stdout(Stdio::null()).stderr(Stdio::null());
407 }
408 #[cfg(unix)]
409 {
410 use std::os::unix::process::CommandExt;
411 unsafe {
414 cmd.pre_exec(|| {
415 extern "C" {
416 fn setsid() -> i32;
417 }
418 if setsid() == -1 {
419 return Err(std::io::Error::last_os_error());
420 }
421 Ok(())
422 });
423 }
424 }
425 cmd.spawn().is_ok()
426}
427
428pub fn authorize(request: ureq::Request) -> ureq::Request {
435 if foreign_listener(request.url()) {
436 return request;
437 }
438 match token() {
439 Some(t) => request.set("Authorization", &format!("Bearer {t}")),
440 None => request,
441 }
442}
443
444fn loopback_port(url: &str) -> Option<u16> {
449 let rest = url
450 .strip_prefix("http://")
451 .or_else(|| url.strip_prefix("https://"))?;
452 let authority = rest.split(['/', '?', '#']).next()?;
453 let (host, port) = match authority.rsplit_once(':') {
454 Some((host, port)) if !port.contains(']') => (host, port.parse().ok()?),
455 _ => (authority, 80),
456 };
457 matches!(host, "127.0.0.1" | "localhost" | "[::1]").then_some(port)
458}
459
460#[cfg(target_os = "linux")]
464fn listen_uids(table: &str, port: u16) -> Vec<u32> {
465 table
466 .lines()
467 .skip(1)
468 .filter_map(|line| {
469 let fields: Vec<&str> = line.split_whitespace().collect();
470 let local = fields.get(1)?;
471 if fields.get(3) != Some(&"0A") {
472 return None;
473 }
474 let hex = local.rsplit(':').next()?;
475 if u16::from_str_radix(hex, 16).ok()? != port {
476 return None;
477 }
478 fields.get(7)?.parse().ok()
479 })
480 .collect()
481}
482
483fn foreign_listener(url: &str) -> bool {
487 #[cfg(target_os = "linux")]
488 {
489 use std::os::unix::fs::MetadataExt;
490 let Some(port) = loopback_port(url) else {
491 return false;
492 };
493 let Ok(me) = std::fs::metadata("/proc/self").map(|m| m.uid()) else {
494 return false;
495 };
496 let owners: Vec<u32> = ["/proc/net/tcp", "/proc/net/tcp6"]
497 .iter()
498 .filter_map(|t| std::fs::read_to_string(t).ok())
499 .flat_map(|text| listen_uids(&text, port))
500 .collect();
501 !owners.is_empty() && owners.iter().all(|uid| *uid != me)
502 }
503 #[cfg(not(target_os = "linux"))]
504 {
505 let _ = url;
506 false
507 }
508}
509
510#[derive(Debug, thiserror::Error)]
511pub enum Error {
512 #[error("packset url missing")]
513 NoUrl,
514 #[error("http: {0}")]
515 Http(#[from] Box<ureq::Error>),
516 #[error("io: {0}")]
517 Io(#[from] std::io::Error),
518 #[error("json: {0}")]
519 Json(#[from] serde_json::Error),
520 #[error("bad response: {0}")]
521 Bad(String),
522 #[error("{0}")]
524 Unavailable(String),
525}
526
527#[derive(Debug, Clone)]
528pub struct PacksetClient {
529 base: String,
530 workspace: Option<String>,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct Hit {
537 pub id: Option<String>,
538 pub text: String,
539 #[serde(default)]
540 pub score: f64,
541 #[serde(default)]
542 pub kind: String,
543 #[serde(default)]
546 pub ts: Option<String>,
547 #[serde(default)]
552 pub entities: Vec<String>,
553 #[serde(default)]
554 pub ballots: Option<u32>,
555 #[serde(default)]
556 pub of: Option<u32>,
557 #[serde(default)]
560 pub rerank_score: Option<f64>,
561 #[serde(default)]
564 pub labels: Vec<String>,
565}
566
567fn refused(url: &str, e: Box<ureq::Error>) -> Error {
569 match *e {
570 ureq::Error::Status(code, response) => {
571 let text = response.into_string().unwrap_or_default();
572 let reason = serde_json::from_str::<serde_json::Value>(&text)
573 .ok()
574 .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
575 .unwrap_or(text);
576 let reason = reason.trim();
577 if code == 401 {
578 return Error::Bad(format!(
579 "{url}: 401: {reason}; this seat's token does not match the writer's, \
580 so it is another user's writer or PACKSET_HOME names another pack"
581 ));
582 }
583 if reason.is_empty() {
584 Error::Bad(format!("{url}: status code {code}"))
585 } else {
586 Error::Bad(format!("{url}: {code}: {reason}"))
587 }
588 }
589 other => Error::Http(Box::new(other)),
590 }
591}
592
593impl PacksetClient {
594 pub fn new(base: impl Into<String>) -> Self {
595 let mut base = base.into();
596 while base.ends_with('/') {
597 base.pop();
598 }
599 Self {
600 base,
601 workspace: None,
602 }
603 }
604
605 #[must_use]
609 pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
610 let workspace = workspace.into();
611 self.workspace = (!workspace.is_empty()).then_some(workspace);
612 self
613 }
614
615 pub fn from_env() -> Result<Self, Error> {
620 load_seat_env();
621 let url = env::var("PACKSET_URL")
622 .or_else(|_| env::var("INSIDE_MEMORY_URL"))
623 .ok()
624 .filter(|url| !url.is_empty());
625 match url {
626 Some(url) if url == "off" => Err(Error::NoUrl),
627 Some(url) => Ok(Self::new(url)),
628 None => Ok(Self::new(format!("http://127.0.0.1:{}", default_port()))),
629 }
630 }
631
632 pub fn base(&self) -> &str {
633 &self.base
634 }
635
636 pub fn workspace(&self) -> String {
637 if let Some(w) = &self.workspace {
638 return w.clone();
639 }
640 if let Ok(w) = env::var("PACKSET_WORKSPACE") {
641 if !w.is_empty() {
642 return w;
643 }
644 }
645 let cwd = env::var("HOSTSEAT_WORKSPACE")
646 .ok()
647 .map(|s| s.trim().to_string())
648 .filter(|s| !s.is_empty())
649 .map(std::path::PathBuf::from)
650 .or_else(|| env::current_dir().ok())
651 .unwrap_or_else(|| std::path::PathBuf::from("."));
652 self.workspace_for_cwd(&cwd)
653 }
654
655 pub fn workspace_for_cwd(&self, cwd: &std::path::Path) -> String {
657 let abs = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
658 let url = format!("{}/v1/identity", self.base);
659 let body = send(
660 authorize(ureq::get(&url)).query("cwd", abs.to_string_lossy().as_ref()),
661 None,
662 timeout(),
663 )
664 .ok()
665 .and_then(|r| r.into_string().ok());
666 if let Some(body) = body {
667 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
668 if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
669 if !ws.is_empty() {
670 return ws.to_string();
671 }
672 }
673 }
674 }
675 format!("dir:{}", abs.display())
676 }
677
678 pub fn health(&self) -> Result<String, Error> {
679 let url = format!("{}/health", self.base);
680 let body = send(authorize(ureq::get(&url)), None, timeout())
681 .map_err(|e| into_error(&url, e))?
682 .into_string()?;
683 Ok(body)
684 }
685
686 pub fn stats(&self) -> Result<serde_json::Value, Error> {
693 let url = format!("{}/stats", self.base);
694 let body: serde_json::Value = send(authorize(ureq::get(&url)), None, timeout())
695 .map_err(|e| into_error(&url, e))?
696 .into_json()?;
697 Ok(body)
698 }
699
700 pub fn get_atom(&self, workspace: &str, id: &str) -> Result<serde_json::Value, Error> {
701 let encoded = path_seg(id);
702 let url = format!("{}/v1/atoms/{encoded}", self.base);
703 let resp = match send(
704 authorize(ureq::get(&url)).query("workspace", workspace),
705 None,
706 timeout(),
707 ) {
708 Ok(resp) => resp,
709 Err(Attempt::Ureq(e)) if matches!(*e, ureq::Error::Status(404, _)) => {
710 return Err(Error::Bad(format!("no atom {id}")));
711 }
712 Err(e) => return Err(into_error(&url, e)),
713 };
714 Ok(resp.into_json()?)
715 }
716
717 pub fn list_atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
718 self.atoms_as_of(workspace, None)
719 }
720
721 pub fn atoms_as_of(
727 &self,
728 workspace: &str,
729 as_of: Option<&str>,
730 ) -> Result<Vec<serde_json::Value>, Error> {
731 let url = format!("{}/v1/atoms", self.base);
732 let mut req = authorize(ureq::get(&url)).query("workspace", workspace);
733 if let Some(at) = as_of {
734 req = req.query("as_of", at);
735 }
736 let mut body: serde_json::Value = send(req, None, timeout())
737 .map_err(|e| into_error(&url, e))?
738 .into_json()?;
739 let atoms = body
740 .get_mut("atoms")
741 .map(serde_json::Value::take)
742 .unwrap_or(serde_json::Value::Array(vec![]));
743 Ok(serde_json::from_value(atoms)?)
744 }
745
746 pub fn atoms_without_vectors(
754 &self,
755 workspace: &str,
756 as_of: Option<&str>,
757 ) -> Result<Vec<serde_json::Value>, Error> {
758 let url = format!("{}/v1/atoms", self.base);
759 let mut req = authorize(ureq::get(&url))
760 .query("workspace", workspace)
761 .query("embedding", "omit");
762 if let Some(at) = as_of {
763 req = req.query("as_of", at);
764 }
765 let mut body: serde_json::Value = send(req, None, timeout())
766 .map_err(|e| into_error(&url, e))?
767 .into_json()?;
768 let atoms = body
769 .get_mut("atoms")
770 .map(serde_json::Value::take)
771 .unwrap_or(serde_json::Value::Array(vec![]));
772 Ok(serde_json::from_value(atoms)?)
773 }
774
775 pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
776 self.search_as_of(workspace, q, limit, None)
777 }
778
779 pub fn search_as_of(
785 &self,
786 workspace: &str,
787 q: &str,
788 limit: u32,
789 as_of: Option<&str>,
790 ) -> Result<Vec<Hit>, Error> {
791 self.search_opts(workspace, q, limit, as_of, false)
792 }
793
794 pub fn search_opts(
804 &self,
805 workspace: &str,
806 q: &str,
807 limit: u32,
808 as_of: Option<&str>,
809 rerank: bool,
810 ) -> Result<Vec<Hit>, Error> {
811 self.search_ranked(workspace, q, limit, as_of, rerank, None)
812 }
813
814 pub fn search_ranked(
825 &self,
826 workspace: &str,
827 q: &str,
828 limit: u32,
829 as_of: Option<&str>,
830 rerank: bool,
831 rank: Option<&str>,
832 ) -> Result<Vec<Hit>, Error> {
833 let url = format!("{}/v1/search", self.base);
834 let budget = if rerank {
835 Duration::from_secs(60).max(timeout())
836 } else {
837 timeout()
838 };
839 let mut req = authorize(ureq::get(&url))
840 .query("workspace", workspace)
841 .query("q", q)
842 .query("limit", &limit.to_string());
843 if let Some(at) = as_of {
844 req = req.query("as_of", at);
845 }
846 if rerank {
847 req = req.query("rerank", "1");
848 }
849 if let Some(rank) = rank.map(str::trim).filter(|s| !s.is_empty()) {
850 req = req.query("rank", rank);
851 }
852 let body: serde_json::Value = send(req, None, budget)
853 .map_err(|e| into_error(&url, e))?
854 .into_json()?;
855 let hits = body
856 .get("hits")
857 .cloned()
858 .unwrap_or(serde_json::Value::Array(vec![]));
859 Ok(serde_json::from_value(hits)?)
860 }
861
862 pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
868 let url = format!("{}/v1/status", self.base);
869 let mut req = authorize(ureq::get(&url));
870 if let Some(workspace) = workspace {
871 req = req.query("workspace", workspace);
872 }
873 Ok(send(req, None, timeout())
874 .map_err(|e| into_error(&url, e))?
875 .into_json()?)
876 }
877
878 pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
884 let url = format!("{}/v1/pin", self.base);
885 Ok(send(
886 authorize(ureq::get(&url)).query("workspace", workspace),
887 None,
888 timeout(),
889 )
890 .map_err(|e| into_error(&url, e))?
891 .into_json()?)
892 }
893
894 pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
900 let url = format!("{}/v1/pin", self.base);
901 Ok(send(
902 authorize(ureq::put(&url)),
903 Some(&serde_json::json!({ "workspace": workspace, "name": name })),
904 timeout(),
905 )
906 .map_err(|e| into_error(&url, e))?
907 .into_json()?)
908 }
909
910 pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
919 let url = format!("{}/v1/accessions", self.base);
920 let body: serde_json::Value = send(
921 authorize(ureq::get(&url)).query("workspace", workspace),
922 None,
923 timeout(),
924 )
925 .map_err(|e| into_error(&url, e))?
926 .into_json()?;
927 let found = body
928 .get("accessions")
929 .cloned()
930 .unwrap_or(serde_json::Value::Array(vec![]));
931 Ok(serde_json::from_value(found)?)
932 }
933 pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
942 self.atoms_as_of(workspace, None)
943 }
944
945 pub fn atoms_of_kind(
952 &self,
953 workspace: &str,
954 kind: &str,
955 ) -> Result<Vec<serde_json::Value>, Error> {
956 let url = format!("{}/v1/atoms", self.base);
957 let body: serde_json::Value = send(
958 authorize(ureq::get(&url))
959 .query("workspace", workspace)
960 .query("kind", kind),
961 None,
962 timeout(),
963 )
964 .map_err(|e| into_error(&url, e))?
965 .into_json()?;
966 Ok(body
967 .get("atoms")
968 .and_then(serde_json::Value::as_array)
969 .cloned()
970 .unwrap_or_default())
971 }
972
973 pub fn citers(
979 &self,
980 workspace: &str,
981 accession: &str,
982 ) -> Result<Vec<serde_json::Value>, Error> {
983 let url = format!("{}/v1/citers", self.base);
984 let body: serde_json::Value = send(
985 authorize(ureq::get(&url))
986 .query("workspace", workspace)
987 .query("accession", accession),
988 None,
989 timeout(),
990 )
991 .map_err(|e| into_error(&url, e))?
992 .into_json()?;
993 let found = body
994 .get("atoms")
995 .cloned()
996 .unwrap_or(serde_json::Value::Array(vec![]));
997 Ok(serde_json::from_value(found)?)
998 }
999
1000 pub fn delete_atom(
1015 &self,
1016 workspace: &str,
1017 id: &str,
1018 why: Option<&str>,
1019 ) -> Result<serde_json::Value, Error> {
1020 let url = format!("{}/v1/atoms/delete", self.base);
1021 let mut body = serde_json::json!({
1022 "workspace": workspace,
1023 "id": id,
1024 });
1025 if let Some(accession) = why {
1026 body["why"] = serde_json::Value::String(accession.to_string());
1027 }
1028 let resp = match send(authorize(ureq::post(&url)), Some(&body), timeout()) {
1029 Ok(resp) => resp,
1030 Err(Attempt::Ureq(e)) if matches!(*e, ureq::Error::Status(404, _)) => {
1031 return Err(Error::Bad(format!("no atom {id}")));
1032 }
1033 Err(e) => return Err(into_error(&url, e)),
1034 };
1035 Ok(resp.into_json()?)
1036 }
1037
1038 pub fn grade(
1040 &self,
1041 workspace: &str,
1042 id: &str,
1043 recalled: bool,
1044 ) -> Result<serde_json::Value, Error> {
1045 let url = format!("{}/v1/grade", self.base);
1046 let body: serde_json::Value = send(
1047 authorize(ureq::post(&url)),
1048 Some(&serde_json::json!({
1049 "workspace": workspace,
1050 "id": id,
1051 "recalled": recalled,
1052 })),
1053 timeout(),
1054 )
1055 .map_err(|e| into_error(&url, e))?
1056 .into_json()?;
1057 Ok(body)
1058 }
1059
1060 pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
1063 let url = format!("{}/v1/hubs", self.base);
1064 let body: serde_json::Value = send(
1065 authorize(ureq::get(&url))
1066 .query("workspace", workspace)
1067 .query("limit", &limit.to_string()),
1068 None,
1069 timeout(),
1070 )
1071 .map_err(|e| into_error(&url, e))?
1072 .into_json()?;
1073 Ok(body)
1074 }
1075
1076 pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
1077 let url = format!("{}/v1/islands", self.base);
1078 let body: serde_json::Value = send(
1079 authorize(ureq::get(&url)).query("workspace", workspace),
1080 None,
1081 timeout(),
1082 )
1083 .map_err(|e| into_error(&url, e))?
1084 .into_json()?;
1085 Ok(body)
1086 }
1087
1088 pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
1090 self.fire_as(workspace, ids, None)
1091 }
1092
1093 pub fn fire_as(
1100 &self,
1101 workspace: &str,
1102 ids: &[String],
1103 lens: Option<&str>,
1104 ) -> Result<serde_json::Value, Error> {
1105 let url = format!("{}/v1/fire", self.base);
1106 let body: serde_json::Value = send(
1107 authorize(ureq::post(&url)),
1108 Some(
1109 &serde_json::json!({"workspace": workspace, "ids": ids, "as": lens.unwrap_or("")}),
1110 ),
1111 timeout(),
1112 )
1113 .map_err(|e| into_error(&url, e))?
1114 .into_json()?;
1115 Ok(body)
1116 }
1117
1118 pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
1122 let url = format!("{}/v1/consolidate", self.base);
1123 let body: serde_json::Value = send(
1124 authorize(ureq::post(&url)),
1125 Some(&serde_json::json!({"workspace": workspace, "apply": apply})),
1126 timeout(),
1127 )
1128 .map_err(|e| into_error(&url, e))?
1129 .into_json()?;
1130 Ok(body)
1131 }
1132
1133 pub fn sweep(&self, workspace: &str) -> Result<serde_json::Value, Error> {
1140 let url = format!("{}/v1/sweep", self.base);
1141 let body: serde_json::Value = send(
1142 authorize(ureq::post(&url)),
1143 Some(&serde_json::json!({"workspace": workspace})),
1144 timeout(),
1145 )
1146 .map_err(|e| into_error(&url, e))?
1147 .into_json()?;
1148 Ok(body)
1149 }
1150
1151 pub fn forget_workspace(&self, workspace: &str) -> Result<serde_json::Value, Error> {
1159 let url = format!("{}/v1/forget", self.base);
1160 let body: serde_json::Value = send(
1161 authorize(ureq::post(&url)),
1162 Some(&serde_json::json!({"workspace": workspace})),
1163 timeout(),
1164 )
1165 .map_err(|e| into_error(&url, e))?
1166 .into_json()?;
1167 Ok(body)
1168 }
1169
1170 pub fn activate(
1173 &self,
1174 workspace: &str,
1175 q: &str,
1176 limit: u32,
1177 fire: bool,
1178 ) -> Result<serde_json::Value, Error> {
1179 self.activate_as(workspace, q, limit, fire, None)
1180 }
1181
1182 pub fn activate_as(
1189 &self,
1190 workspace: &str,
1191 q: &str,
1192 limit: u32,
1193 fire: bool,
1194 lens: Option<&str>,
1195 ) -> Result<serde_json::Value, Error> {
1196 let url = format!("{}/v1/activate", self.base);
1197 let mut req = authorize(ureq::get(&url))
1198 .query("workspace", workspace)
1199 .query("q", q)
1200 .query("limit", &limit.to_string())
1201 .query("fire", if fire { "1" } else { "0" });
1202 if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
1203 req = req.query("as", name);
1204 }
1205 let body: serde_json::Value = send(req, None, timeout())
1206 .map_err(|e| into_error(&url, e))?
1207 .into_json()?;
1208 Ok(body)
1209 }
1210
1211 pub fn atoms_in_set(
1218 &self,
1219 workspace: &str,
1220 set: &str,
1221 ) -> Result<Vec<serde_json::Value>, Error> {
1222 let url = format!("{}/v1/pack", self.base);
1223 let body: serde_json::Value = send(
1224 authorize(ureq::get(&url))
1225 .query("workspace", workspace)
1226 .query("set", set),
1227 None,
1228 timeout(),
1229 )
1230 .map_err(|e| into_error(&url, e))?
1231 .into_json()?;
1232 Ok(body
1233 .get("atoms")
1234 .and_then(serde_json::Value::as_array)
1235 .cloned()
1236 .unwrap_or_default())
1237 }
1238
1239 pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
1240 let url = format!("{}/v1/atoms", self.base);
1241 let body: serde_json::Value = send(authorize(ureq::post(&url)), Some(atom), timeout())
1242 .map_err(|e| into_error(&url, e))?
1243 .into_json()?;
1244 Ok(body)
1245 }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250 use super::*;
1251
1252 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1257
1258 #[test]
1259 fn only_a_loopback_url_has_a_port_to_check() {
1260 assert_eq!(loopback_port("http://127.0.0.1:8761/health"), Some(8761));
1261 assert_eq!(loopback_port("http://localhost:18462"), Some(18462));
1262 assert_eq!(loopback_port("http://[::1]:8761/v1/search?q=a"), Some(8761));
1263 assert_eq!(loopback_port("http://127.0.0.1/health"), Some(80));
1264 assert_eq!(loopback_port("http://example.org:8761/"), None);
1265 assert_eq!(loopback_port("http://[::1]/"), Some(80));
1266 }
1267
1268 #[cfg(target_os = "linux")]
1269 #[test]
1270 fn the_owner_of_a_listening_port_is_read_off_the_table() {
1271 let table = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
1272 0: 0100007F:223A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001 0 4242 1 0 100 0 0 10 0
1273 1: 0100007F:223A 0100007F:9C40 01 00000000:00000000 00:00000000 00000000 1000 0 4343 1 0 20 4 30 10 -1
1274 2: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 4444 1 0 100 0 0 10 0
1275";
1276 assert_eq!(listen_uids(table, 8762), vec![1001], "only the listener");
1277 assert_eq!(listen_uids(table, 8080), vec![1000]);
1278 assert!(listen_uids(table, 9).is_empty());
1279 }
1280
1281 #[cfg(target_os = "linux")]
1282 #[test]
1283 fn a_port_this_user_holds_is_not_foreign() {
1284 let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1285 let port = held.local_addr().unwrap().port();
1286 assert!(!foreign_listener(&format!(
1287 "http://127.0.0.1:{port}/health"
1288 )));
1289 drop(held);
1290 assert!(
1291 !foreign_listener(&format!("http://127.0.0.1:{port}/health")),
1292 "nobody listening is not foreign"
1293 );
1294 }
1295
1296 #[test]
1297 fn resolved_workspace_reads_ljos_env_not_default() {
1298 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1299 let dir = std::env::temp_dir().join(format!("packset-ljos-env-{}", std::process::id()));
1300 std::fs::create_dir_all(dir.join(".config/ljos")).unwrap();
1301 std::fs::write(
1302 dir.join(".config/ljos/env"),
1303 "PACKSET_WORKSPACE=git:example.com/seat/notes\n",
1304 )
1305 .unwrap();
1306 let old_home = env::var("HOME").ok();
1307 let old_ws = env::var("PACKSET_WORKSPACE").ok();
1308 unsafe {
1309 env::remove_var("PACKSET_WORKSPACE");
1310 env::set_var("HOME", &dir);
1311 }
1312 let got = resolved_workspace();
1313 unsafe {
1314 match old_home {
1315 Some(h) => env::set_var("HOME", h),
1316 None => env::remove_var("HOME"),
1317 }
1318 match old_ws {
1319 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1320 None => env::remove_var("PACKSET_WORKSPACE"),
1321 }
1322 }
1323 assert_eq!(got, "git:example.com/seat/notes");
1324 }
1325
1326 #[test]
1327 fn resolved_workspace_without_env_is_seat_not_default() {
1328 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1329 let dir = std::env::temp_dir().join(format!("packset-no-ljos-env-{}", std::process::id()));
1330 std::fs::create_dir_all(&dir).unwrap();
1331 let old_home = env::var("HOME").ok();
1332 let old_ws = env::var("PACKSET_WORKSPACE").ok();
1333 unsafe {
1334 env::remove_var("PACKSET_WORKSPACE");
1335 env::set_var("HOME", &dir);
1336 }
1337 let got = resolved_workspace();
1338 unsafe {
1339 match old_home {
1340 Some(h) => env::set_var("HOME", h),
1341 None => env::remove_var("HOME"),
1342 }
1343 match old_ws {
1344 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1345 None => env::remove_var("PACKSET_WORKSPACE"),
1346 }
1347 }
1348 assert_eq!(got, "seat");
1349 assert_ne!(got, "default");
1350 }
1351
1352 fn busy_for(
1356 busy: usize,
1357 retry_after: Option<u64>,
1358 ) -> (
1359 PacksetClient,
1360 std::sync::Arc<std::sync::atomic::AtomicUsize>,
1361 ) {
1362 use std::io::{BufRead, BufReader, Read, Write};
1363 use std::sync::atomic::Ordering;
1364 let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
1365 let client = PacksetClient::new(format!("http://{}", listener.local_addr().unwrap()));
1366 let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1367 let counted = std::sync::Arc::clone(&seen);
1368 std::thread::spawn(move || {
1369 for stream in listener.incoming().flatten() {
1370 let mut reader = BufReader::new(&stream);
1371 let mut length = 0;
1372 let mut line = String::new();
1373 while reader.read_line(&mut line).is_ok_and(|n| n > 2) {
1374 if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
1375 length = v.trim().parse().unwrap_or(0);
1376 }
1377 line.clear();
1378 }
1379 let mut body = vec![0u8; length];
1380 let _ = reader.read_exact(&mut body);
1381 let (status, text) = if counted.fetch_add(1, Ordering::SeqCst) < busy {
1382 ("503 Service Unavailable", r#"{"error":"busy"}"#)
1383 } else {
1384 ("200 OK", r#"{"ok":true}"#)
1385 };
1386 let retry = retry_after
1387 .map(|secs| format!("Retry-After: {secs}\r\n"))
1388 .unwrap_or_default();
1389 let _ = (&stream).write_all(
1390 format!(
1391 "HTTP/1.1 {status}\r\n{retry}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
1392 text.len()
1393 )
1394 .as_bytes(),
1395 );
1396 }
1397 });
1398 (client, seen)
1399 }
1400
1401 #[test]
1402 fn a_busy_writer_is_asked_again_for_a_read_and_a_write() {
1403 use std::sync::atomic::Ordering;
1404 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1405 let (client, seen) = busy_for(2, None);
1406 assert_eq!(client.status(None).unwrap()["ok"], true);
1407 assert_eq!(seen.load(Ordering::SeqCst), 3);
1408 let (client, seen) = busy_for(1, None);
1409 let stored = client
1410 .post_atom(&serde_json::json!({"text": "one write", "workspace": "w"}))
1411 .unwrap();
1412 assert_eq!(stored["ok"], true);
1413 assert_eq!(
1414 seen.load(Ordering::SeqCst),
1415 2,
1416 "the write was not sent again"
1417 );
1418 }
1419
1420 #[test]
1421 fn a_writer_that_stays_busy_fails_in_one_sentence() {
1422 use std::sync::atomic::Ordering;
1423 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1424 let old = env::var_os("PACKSET_TIMEOUT_MS");
1425 unsafe { env::set_var("PACKSET_TIMEOUT_MS", "700") };
1426 let (client, seen) = busy_for(usize::MAX, None);
1427 let err = client.status(None).unwrap_err().to_string();
1428 unsafe {
1429 match old {
1430 Some(value) => env::set_var("PACKSET_TIMEOUT_MS", value),
1431 None => env::remove_var("PACKSET_TIMEOUT_MS"),
1432 }
1433 }
1434 assert_eq!(err, "packsetd is busy.", "{err}");
1435 assert!(
1436 !err.contains("os error") && !err.contains("status code"),
1437 "{err}"
1438 );
1439 assert_eq!(
1442 seen.load(Ordering::SeqCst),
1443 usize::try_from(attempts_in(Duration::from_millis(700))).unwrap(),
1444 "{err}"
1445 );
1446 }
1447
1448 #[test]
1449 fn a_refused_connection_is_retried_and_then_one_sentence() {
1450 use std::time::Instant;
1451 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1452 let dir = std::env::temp_dir().join(format!("packset-down-{}", std::process::id()));
1453 std::fs::create_dir_all(&dir).unwrap();
1454 let saved = save_env(&[
1455 "HOME",
1456 "PACKSET_HOME",
1457 "LJOS_HOME",
1458 "LJOS_INSIDE_MEMORY_HOME",
1459 "PACKSET_BIN",
1460 "PACKSET_TIMEOUT_MS",
1461 ]);
1462 unsafe {
1463 env::set_var("HOME", &dir);
1464 env::remove_var("PACKSET_HOME");
1465 env::remove_var("LJOS_HOME");
1466 env::remove_var("LJOS_INSIDE_MEMORY_HOME");
1467 env::remove_var("PACKSET_BIN");
1468 env::set_var("PACKSET_TIMEOUT_MS", "250");
1469 }
1470 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1471 let port = listener.local_addr().unwrap().port();
1472 drop(listener);
1473 let client = PacksetClient::new(format!("http://127.0.0.1:{port}"));
1474 let started = Instant::now();
1475 let err = client.status(None).unwrap_err().to_string();
1476 let took = started.elapsed();
1477 restore_env(saved);
1478 let _ = std::fs::remove_dir_all(&dir);
1479 assert_eq!(err, "packsetd is not running.", "{err}");
1480 assert!(took >= Duration::from_millis(40), "no backoff: {took:?}");
1481 assert!(took < Duration::from_secs(2), "{took:?}");
1482 }
1483
1484 #[test]
1485 fn a_down_local_writer_is_started_once() {
1486 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1487 let dir = std::env::temp_dir().join(format!(
1488 "packset-autostart-{}-{}",
1489 std::process::id(),
1490 std::time::SystemTime::now()
1491 .duration_since(std::time::UNIX_EPOCH)
1492 .unwrap()
1493 .as_nanos()
1494 ));
1495 let home = dir.join("home");
1496 std::fs::create_dir_all(&home).unwrap();
1497 let log = dir.join("starts");
1498 let stub = dir.join("packsetd-stub");
1499 std::fs::write(
1500 &stub,
1501 format!(
1502 "#!/bin/sh\necho start >> \"{}\"\necho pid $$ >> \"{}\"\nexec python3 - \"$@\" << 'PY'\nimport sys\nargs = sys.argv[1:]\nport = int(args[args.index('--port') + 1])\nhome = args[args.index('--home') + 1]\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nclass Handler(BaseHTTPRequestHandler):\n def do_GET(self):\n body = b'{{\"ok\":true}}'\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.send_header('Content-Length', str(len(body)))\n self.end_headers()\n self.wfile.write(body)\n def log_message(self, *args):\n pass\nHTTPServer(('127.0.0.1', port), Handler).serve_forever()\nPY\n",
1503 log.display(),
1504 log.display(),
1505 ),
1506 )
1507 .unwrap();
1508 #[cfg(unix)]
1509 {
1510 use std::os::unix::fs::PermissionsExt;
1511 let mut mode = std::fs::metadata(&stub).unwrap().permissions();
1512 mode.set_mode(0o755);
1513 std::fs::set_permissions(&stub, mode).unwrap();
1514 }
1515 let saved = save_env(&[
1516 "HOME",
1517 "PACKSET_HOME",
1518 "LJOS_HOME",
1519 "LJOS_INSIDE_MEMORY_HOME",
1520 "PACKSET_BIN",
1521 "PACKSET_TIMEOUT_MS",
1522 "PACKSET_TOKEN",
1523 ]);
1524 unsafe {
1525 env::set_var("HOME", &dir);
1526 env::set_var("PACKSET_HOME", &home);
1527 env::remove_var("LJOS_HOME");
1528 env::remove_var("LJOS_INSIDE_MEMORY_HOME");
1529 env::remove_var("PACKSET_TOKEN");
1530 env::set_var("PACKSET_BIN", &stub);
1531 env::set_var("PACKSET_TIMEOUT_MS", "5000");
1532 }
1533 let reap = Reap(log.clone());
1534 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1535 let port = listener.local_addr().unwrap().port();
1536 drop(listener);
1537 let client = PacksetClient::new(format!("http://127.0.0.1:{port}"));
1538 let body = client.status(None).expect("the stub should answer");
1539 let text = std::fs::read_to_string(&log).unwrap_or_default();
1540 drop(reap);
1541 restore_env(saved);
1542 let _ = std::fs::remove_dir_all(&dir);
1543 assert_eq!(body["ok"], true, "{body}");
1544 assert_eq!(
1545 text.lines().filter(|line| *line == "start").count(),
1546 1,
1547 "{text}"
1548 );
1549 }
1550
1551 fn save_env(keys: &[&str]) -> Vec<(String, Option<std::ffi::OsString>)> {
1552 keys.iter()
1553 .map(|key| ((*key).to_string(), env::var_os(key)))
1554 .collect()
1555 }
1556
1557 fn restore_env(saved: Vec<(String, Option<std::ffi::OsString>)>) {
1558 for (key, value) in saved {
1559 unsafe {
1560 match value {
1561 Some(value) => env::set_var(key, value),
1562 None => env::remove_var(key),
1563 }
1564 }
1565 }
1566 }
1567
1568 struct Reap(std::path::PathBuf);
1570
1571 impl Drop for Reap {
1572 fn drop(&mut self) {
1573 let Ok(text) = std::fs::read_to_string(&self.0) else {
1574 return;
1575 };
1576 for line in text.lines() {
1577 let Some(pid) = line.strip_prefix("pid ") else {
1578 continue;
1579 };
1580 let _ = std::process::Command::new("kill")
1581 .args(["-9", pid.trim()])
1582 .status();
1583 }
1584 }
1585 }
1586
1587 const BUDGET: Duration = Duration::from_millis(100);
1590
1591 #[test]
1594 fn the_retries_stop_where_the_budget_does() {
1595 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1596 let old = env::var_os("PACKSET_TIMEOUT_MS");
1597 unsafe { env::set_var("PACKSET_TIMEOUT_MS", BUDGET.as_millis().to_string()) };
1598 let budget = timeout();
1599 unsafe {
1600 match old {
1601 Some(v) => env::set_var("PACKSET_TIMEOUT_MS", v),
1602 None => env::remove_var("PACKSET_TIMEOUT_MS"),
1603 }
1604 }
1605 assert_eq!(budget, BUDGET);
1606 let second = BUSY_WAIT * BUSY_GROWTH;
1607 assert!(
1608 ask_again(Duration::ZERO, BUSY_WAIT, budget),
1609 "the first wait did not fit"
1610 );
1611 assert!(!ask_again(BUSY_WAIT, second, budget), "the second wait fit");
1612 assert!(
1613 !ask_again(budget - BUSY_WAIT, BUSY_WAIT, budget),
1614 "a first round trip that spent the room was followed by a wait"
1615 );
1616 }
1617
1618 #[test]
1621 fn the_attempt_count_is_the_waits_that_fit_the_budget() {
1622 assert_eq!(attempts_in(Duration::from_millis(50)), 1);
1623 assert_eq!(attempts_in(Duration::from_millis(51)), 2);
1624 assert_eq!(attempts_in(Duration::from_millis(100)), 2);
1625 assert_eq!(attempts_in(Duration::from_millis(700)), 4);
1626 assert_eq!(attempts_in(Duration::from_secs(30)), 10);
1627 }
1628
1629 #[test]
1632 fn a_retry_after_past_the_budget_is_not_waited() {
1633 let budget = Duration::from_millis(700);
1634 assert!(pause_for(
1635 BUSY_WAIT,
1636 Some(Duration::from_secs(1)),
1637 Duration::ZERO,
1638 budget,
1639 0
1640 )
1641 .is_none());
1642 let inside = pause_for(BUSY_WAIT, None, Duration::ZERO, budget, 0).unwrap();
1643 assert_eq!(inside.charge, BUSY_WAIT);
1644 assert!(inside.sleep >= BUSY_WAIT / 2 && inside.sleep <= BUSY_WAIT);
1645 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1646 let old = env::var_os("PACKSET_TIMEOUT_MS");
1647 unsafe { env::set_var("PACKSET_TIMEOUT_MS", "700") };
1648 let (client, seen) = busy_for(usize::MAX, Some(1));
1649 let err = client.status(None).unwrap_err().to_string();
1650 unsafe {
1651 match old {
1652 Some(value) => env::set_var("PACKSET_TIMEOUT_MS", value),
1653 None => env::remove_var("PACKSET_TIMEOUT_MS"),
1654 }
1655 }
1656 assert_eq!(err, "packsetd is busy.", "{err}");
1657 assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "{err}");
1658 }
1659
1660 #[test]
1663 fn a_retry_after_inside_the_budget_is_waited() {
1664 use std::time::Instant;
1665 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1666 let old = env::var_os("PACKSET_TIMEOUT_MS");
1667 unsafe { env::set_var("PACKSET_TIMEOUT_MS", "3000") };
1668 let (client, seen) = busy_for(1, Some(1));
1669 let started = Instant::now();
1670 let body = client.status(None).unwrap();
1671 let took = started.elapsed();
1672 unsafe {
1673 match old {
1674 Some(value) => env::set_var("PACKSET_TIMEOUT_MS", value),
1675 None => env::remove_var("PACKSET_TIMEOUT_MS"),
1676 }
1677 }
1678 assert_eq!(body["ok"], true, "{body}");
1679 assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
1680 assert!(
1681 took >= Duration::from_millis(400),
1682 "Retry-After was not waited: {took:?}"
1683 );
1684 assert!(took < Duration::from_secs(3), "{took:?}");
1685 }
1686
1687 #[test]
1688 fn a_request_carries_the_end_of_its_budget() {
1689 use std::io::{BufRead, BufReader, Write};
1690 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1691 let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
1692 let client = PacksetClient::new(format!("http://{}", listener.local_addr().unwrap()));
1693 let (tx, rx) = std::sync::mpsc::channel();
1694 std::thread::spawn(move || {
1695 let (stream, _) = listener.accept().unwrap();
1696 let mut reader = BufReader::new(&stream);
1697 let mut deadline = String::new();
1698 let mut line = String::new();
1699 while reader.read_line(&mut line).is_ok_and(|n| n > 2) {
1700 let lower = line.to_ascii_lowercase();
1701 if let Some(value) = lower.strip_prefix("x-packset-deadline:") {
1702 deadline = value.trim().to_string();
1703 }
1704 line.clear();
1705 }
1706 let _ = tx.send(deadline);
1707 let text = r#"{"ok":true}"#;
1708 let _ = (&stream).write_all(
1709 format!(
1710 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
1711 text.len()
1712 )
1713 .as_bytes(),
1714 );
1715 });
1716 let before = std::time::SystemTime::now()
1717 .duration_since(std::time::UNIX_EPOCH)
1718 .unwrap()
1719 .as_millis();
1720 let body = client.status(None).unwrap();
1721 let header = rx.recv_timeout(Duration::from_secs(2)).unwrap();
1722 let stamp: u128 = header.parse().unwrap();
1723 assert_eq!(body["ok"], true, "{body}");
1724 assert!(stamp > before, "{stamp} <= {before}");
1725 assert!(stamp < before + 60_000, "{stamp} is not this budget");
1726 }
1727}