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 {
15 std::env::var("PACKSET_TIMEOUT_MS")
16 .ok()
17 .and_then(|v| v.trim().parse::<u64>().ok())
18 .filter(|ms| *ms > 0)
19 .map_or(Duration::from_secs(30), Duration::from_millis)
20}
21
22const BUSY_WAIT: Duration = Duration::from_millis(50);
25const BUSY_GROWTH: u32 = 3;
27const BUSY_WAIT_CAP: Duration = Duration::from_secs(5);
29
30fn ask_again(waited: Duration, wait: Duration, budget: Duration) -> bool {
36 waited.saturating_add(wait) < budget
37}
38
39fn attempts_in(budget: Duration) -> u32 {
43 let mut waited = Duration::ZERO;
44 let mut wait = BUSY_WAIT;
45 let mut attempts = 1u32;
46 while ask_again(waited, wait, budget) {
47 waited = waited.saturating_add(wait);
48 wait = (wait * BUSY_GROWTH).min(BUSY_WAIT_CAP);
49 attempts = attempts.saturating_add(1);
50 }
51 attempts
52}
53
54enum Attempt {
56 Plain(String),
58 Ureq(Box<ureq::Error>),
60}
61
62fn send(
70 req: ureq::Request,
71 body: Option<&serde_json::Value>,
72 budget: Duration,
73) -> Result<ureq::Response, Attempt> {
74 let attempts = attempts_in(budget);
75 let mut waited = Duration::ZERO;
76 let mut wait = BUSY_WAIT;
77 let mut asked = 0u32;
78 let mut started_daemon = false;
79 loop {
80 asked = asked.saturating_add(1);
81 let left = budget.saturating_sub(waited).max(Duration::from_millis(1));
82 let mut attempt = req.clone().timeout(left);
83 if started_daemon {
84 if let Some(token) = token() {
85 attempt = attempt.set("Authorization", &format!("Bearer {token}"));
86 }
87 }
88 let answered = match body {
89 Some(json) => attempt.send_json(json),
90 None => attempt.call(),
91 };
92 let again = asked < attempts;
93 match answered {
94 Err(ureq::Error::Status(code, _)) if (code == 503 || code == 429) && again => {
95 std::thread::sleep(wait);
96 waited = waited.saturating_add(wait);
97 wait = (wait * BUSY_GROWTH).min(BUSY_WAIT_CAP);
98 }
99 Err(ureq::Error::Status(code, response)) if code == 503 || code == 429 => {
100 return Err(Attempt::Plain(busy_line(response)));
101 }
102 Err(err) if connect_refused(&err) && again => {
103 if !started_daemon {
104 started_daemon = autostart(req.url());
105 }
106 std::thread::sleep(wait);
107 waited = waited.saturating_add(wait);
108 wait = (wait * BUSY_GROWTH).min(BUSY_WAIT_CAP);
109 }
110 Err(err) if connect_refused(&err) => {
111 return Err(Attempt::Plain("packsetd is not running.".to_string()));
112 }
113 other => return other.map_err(|err| Attempt::Ureq(Box::new(err))),
114 }
115 }
116}
117
118fn busy_line(response: ureq::Response) -> String {
120 let text = response.into_string().unwrap_or_default();
121 let reason = serde_json::from_str::<serde_json::Value>(&text)
122 .ok()
123 .and_then(|value| {
124 value
125 .get("error")
126 .and_then(|reason| reason.as_str())
127 .map(str::to_string)
128 })
129 .unwrap_or(text);
130 let reason = reason.trim();
131 if reason.is_empty() || reason.eq_ignore_ascii_case("busy") {
132 "packsetd is busy.".to_string()
133 } else if reason.ends_with('.') {
134 format!("packsetd is busy: {reason}")
135 } else {
136 format!("packsetd is busy: {reason}.")
137 }
138}
139
140fn connect_refused(err: &ureq::Error) -> bool {
142 let ureq::Error::Transport(transport) = err else {
143 return false;
144 };
145 if transport
146 .source()
147 .and_then(|source| source.downcast_ref::<std::io::Error>())
148 .is_some_and(|io| io.kind() == std::io::ErrorKind::ConnectionRefused)
149 {
150 return true;
151 }
152 err.to_string().contains("Connection refused")
153}
154
155fn into_error(url: &str, err: Attempt) -> Error {
156 match err {
157 Attempt::Plain(text) => Error::Unavailable(text),
158 Attempt::Ureq(err) => refused(url, err),
159 }
160}
161
162fn path_seg(id: &str) -> String {
163 let mut out = String::with_capacity(id.len());
164 for b in id.bytes() {
165 match b {
166 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
167 out.push(b as char)
168 }
169 _ => out.push_str(&format!("%{b:02X}")),
170 }
171 }
172 out
173}
174
175pub const DEFAULT_PORT: u16 = 8761;
178
179pub fn load_seat_env() {
182 let Some(home) = env::var_os("HOME") else {
183 return;
184 };
185 let path = PathBuf::from(home).join(".config/ljos/env");
186 let Ok(text) = std::fs::read_to_string(path) else {
187 return;
188 };
189 for line in text.lines() {
190 let line = line.trim();
191 if line.is_empty() || line.starts_with('#') {
192 continue;
193 }
194 let Some((k, v)) = line.split_once('=') else {
195 continue;
196 };
197 let k = k.trim();
198 if k.is_empty() || env::var_os(k).is_some() {
199 continue;
200 }
201 env::set_var(k, v.trim());
202 }
203}
204
205#[must_use]
210pub fn resolved_workspace() -> String {
211 load_seat_env();
212 env::var("PACKSET_WORKSPACE")
213 .ok()
214 .map(|w| w.trim().to_string())
215 .filter(|w| !w.is_empty())
216 .unwrap_or_else(|| "seat".to_string())
217}
218
219#[must_use]
221pub fn default_port() -> u16 {
222 env::var("PACKSET_PORT")
223 .or_else(|_| env::var("LJOS_MEM_PORT"))
224 .ok()
225 .and_then(|raw| raw.trim().parse().ok())
226 .unwrap_or(DEFAULT_PORT)
227}
228
229#[must_use]
235pub fn token_path() -> Option<PathBuf> {
236 if let Some(file) = env::var_os("PACKSET_TOKEN_FILE").filter(|v| !v.is_empty()) {
237 return Some(PathBuf::from(file));
238 }
239 packset_core::home::resolve().map(|home| home.join(TOKEN_FILE))
240}
241
242pub const TOKEN_FILE: &str = "token";
244
245#[must_use]
249pub fn token() -> Option<String> {
250 if let Ok(t) = env::var("PACKSET_TOKEN") {
251 let t = t.trim().to_string();
252 if !t.is_empty() {
253 return Some(t);
254 }
255 }
256 let text = std::fs::read_to_string(token_path()?).ok()?;
257 let t = text.lines().next()?.trim().to_string();
258 (!t.is_empty()).then_some(t)
259}
260
261fn autostart(url: &str) -> bool {
266 use std::sync::atomic::{AtomicBool, Ordering};
267 static STARTED: AtomicBool = AtomicBool::new(false);
268 if STARTED.load(Ordering::Acquire) {
269 return false;
270 }
271 let Some(port) = loopback_port(url) else {
272 return false;
273 };
274 load_seat_env();
275 let Some(home) = packset_core::home::named_home() else {
276 return false;
277 };
278 let Some(bin) = daemon_binary() else {
279 return false;
280 };
281 if STARTED.swap(true, Ordering::AcqRel) {
282 return false;
283 }
284 spawn_daemon(&bin, &home, port)
285}
286
287fn daemon_binary() -> Option<PathBuf> {
289 if let Some(named) = env::var_os("PACKSET_BIN").filter(|value| !value.is_empty()) {
290 let path = PathBuf::from(named);
291 if path.is_file() {
292 return Some(path);
293 }
294 }
295 if let Some(beside) = env::current_exe()
296 .ok()
297 .and_then(|exe| exe.parent().map(|dir| dir.join("packsetd")))
298 .filter(|path| path.is_file())
299 {
300 return Some(beside);
301 }
302 let paths = env::var_os("PATH")?;
303 env::split_paths(&paths)
304 .map(|dir| dir.join("packsetd"))
305 .find(|path| path.is_file())
306}
307
308fn spawn_daemon(bin: &std::path::Path, home: &std::path::Path, port: u16) -> bool {
311 use std::process::{Command, Stdio};
312 let mut cmd = Command::new(bin);
313 cmd.arg("--home")
314 .arg(home)
315 .arg("--port")
316 .arg(port.to_string())
317 .stdin(Stdio::null());
318 if let Ok(log) = std::fs::OpenOptions::new()
319 .create(true)
320 .append(true)
321 .open(home.join("packsetd.log"))
322 {
323 if let Ok(errs) = log.try_clone() {
324 cmd.stdout(Stdio::from(log)).stderr(Stdio::from(errs));
325 }
326 } else {
327 cmd.stdout(Stdio::null()).stderr(Stdio::null());
328 }
329 #[cfg(unix)]
330 {
331 use std::os::unix::process::CommandExt;
332 unsafe {
335 cmd.pre_exec(|| {
336 extern "C" {
337 fn setsid() -> i32;
338 }
339 if setsid() == -1 {
340 return Err(std::io::Error::last_os_error());
341 }
342 Ok(())
343 });
344 }
345 }
346 cmd.spawn().is_ok()
347}
348
349pub fn authorize(request: ureq::Request) -> ureq::Request {
356 if foreign_listener(request.url()) {
357 return request;
358 }
359 match token() {
360 Some(t) => request.set("Authorization", &format!("Bearer {t}")),
361 None => request,
362 }
363}
364
365fn loopback_port(url: &str) -> Option<u16> {
370 let rest = url
371 .strip_prefix("http://")
372 .or_else(|| url.strip_prefix("https://"))?;
373 let authority = rest.split(['/', '?', '#']).next()?;
374 let (host, port) = match authority.rsplit_once(':') {
375 Some((host, port)) if !port.contains(']') => (host, port.parse().ok()?),
376 _ => (authority, 80),
377 };
378 matches!(host, "127.0.0.1" | "localhost" | "[::1]").then_some(port)
379}
380
381#[cfg(target_os = "linux")]
385fn listen_uids(table: &str, port: u16) -> Vec<u32> {
386 table
387 .lines()
388 .skip(1)
389 .filter_map(|line| {
390 let fields: Vec<&str> = line.split_whitespace().collect();
391 let local = fields.get(1)?;
392 if fields.get(3) != Some(&"0A") {
393 return None;
394 }
395 let hex = local.rsplit(':').next()?;
396 if u16::from_str_radix(hex, 16).ok()? != port {
397 return None;
398 }
399 fields.get(7)?.parse().ok()
400 })
401 .collect()
402}
403
404fn foreign_listener(url: &str) -> bool {
408 #[cfg(target_os = "linux")]
409 {
410 use std::os::unix::fs::MetadataExt;
411 let Some(port) = loopback_port(url) else {
412 return false;
413 };
414 let Ok(me) = std::fs::metadata("/proc/self").map(|m| m.uid()) else {
415 return false;
416 };
417 let owners: Vec<u32> = ["/proc/net/tcp", "/proc/net/tcp6"]
418 .iter()
419 .filter_map(|t| std::fs::read_to_string(t).ok())
420 .flat_map(|text| listen_uids(&text, port))
421 .collect();
422 !owners.is_empty() && owners.iter().all(|uid| *uid != me)
423 }
424 #[cfg(not(target_os = "linux"))]
425 {
426 let _ = url;
427 false
428 }
429}
430
431#[derive(Debug, thiserror::Error)]
432pub enum Error {
433 #[error("packset url missing")]
434 NoUrl,
435 #[error("http: {0}")]
436 Http(#[from] Box<ureq::Error>),
437 #[error("io: {0}")]
438 Io(#[from] std::io::Error),
439 #[error("json: {0}")]
440 Json(#[from] serde_json::Error),
441 #[error("bad response: {0}")]
442 Bad(String),
443 #[error("{0}")]
445 Unavailable(String),
446}
447
448#[derive(Debug, Clone)]
449pub struct PacksetClient {
450 base: String,
451 workspace: Option<String>,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct Hit {
458 pub id: Option<String>,
459 pub text: String,
460 #[serde(default)]
461 pub score: f64,
462 #[serde(default)]
463 pub kind: String,
464 #[serde(default)]
467 pub ts: Option<String>,
468 #[serde(default)]
473 pub entities: Vec<String>,
474 #[serde(default)]
475 pub ballots: Option<u32>,
476 #[serde(default)]
477 pub of: Option<u32>,
478 #[serde(default)]
481 pub rerank_score: Option<f64>,
482 #[serde(default)]
485 pub labels: Vec<String>,
486}
487
488fn refused(url: &str, e: Box<ureq::Error>) -> Error {
490 match *e {
491 ureq::Error::Status(code, response) => {
492 let text = response.into_string().unwrap_or_default();
493 let reason = serde_json::from_str::<serde_json::Value>(&text)
494 .ok()
495 .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
496 .unwrap_or(text);
497 let reason = reason.trim();
498 if code == 401 {
499 return Error::Bad(format!(
500 "{url}: 401: {reason}; this seat's token does not match the writer's, \
501 so it is another user's writer or PACKSET_HOME names another pack"
502 ));
503 }
504 if reason.is_empty() {
505 Error::Bad(format!("{url}: status code {code}"))
506 } else {
507 Error::Bad(format!("{url}: {code}: {reason}"))
508 }
509 }
510 other => Error::Http(Box::new(other)),
511 }
512}
513
514impl PacksetClient {
515 pub fn new(base: impl Into<String>) -> Self {
516 let mut base = base.into();
517 while base.ends_with('/') {
518 base.pop();
519 }
520 Self {
521 base,
522 workspace: None,
523 }
524 }
525
526 #[must_use]
530 pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
531 let workspace = workspace.into();
532 self.workspace = (!workspace.is_empty()).then_some(workspace);
533 self
534 }
535
536 pub fn from_env() -> Result<Self, Error> {
541 load_seat_env();
542 let url = env::var("PACKSET_URL")
543 .or_else(|_| env::var("INSIDE_MEMORY_URL"))
544 .ok()
545 .filter(|url| !url.is_empty());
546 match url {
547 Some(url) if url == "off" => Err(Error::NoUrl),
548 Some(url) => Ok(Self::new(url)),
549 None => Ok(Self::new(format!("http://127.0.0.1:{}", default_port()))),
550 }
551 }
552
553 pub fn base(&self) -> &str {
554 &self.base
555 }
556
557 pub fn workspace(&self) -> String {
558 if let Some(w) = &self.workspace {
559 return w.clone();
560 }
561 if let Ok(w) = env::var("PACKSET_WORKSPACE") {
562 if !w.is_empty() {
563 return w;
564 }
565 }
566 let cwd = env::var("HOSTSEAT_WORKSPACE")
567 .ok()
568 .map(|s| s.trim().to_string())
569 .filter(|s| !s.is_empty())
570 .map(std::path::PathBuf::from)
571 .or_else(|| env::current_dir().ok())
572 .unwrap_or_else(|| std::path::PathBuf::from("."));
573 self.workspace_for_cwd(&cwd)
574 }
575
576 pub fn workspace_for_cwd(&self, cwd: &std::path::Path) -> String {
578 let abs = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
579 let url = format!("{}/v1/identity", self.base);
580 let body = send(
581 authorize(ureq::get(&url)).query("cwd", abs.to_string_lossy().as_ref()),
582 None,
583 timeout(),
584 )
585 .ok()
586 .and_then(|r| r.into_string().ok());
587 if let Some(body) = body {
588 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
589 if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
590 if !ws.is_empty() {
591 return ws.to_string();
592 }
593 }
594 }
595 }
596 format!("dir:{}", abs.display())
597 }
598
599 pub fn health(&self) -> Result<String, Error> {
600 let url = format!("{}/health", self.base);
601 let body = send(authorize(ureq::get(&url)), None, timeout())
602 .map_err(|e| into_error(&url, e))?
603 .into_string()?;
604 Ok(body)
605 }
606
607 pub fn get_atom(&self, workspace: &str, id: &str) -> Result<serde_json::Value, Error> {
608 let encoded = path_seg(id);
609 let url = format!("{}/v1/atoms/{encoded}", self.base);
610 let resp = match send(
611 authorize(ureq::get(&url)).query("workspace", workspace),
612 None,
613 timeout(),
614 ) {
615 Ok(resp) => resp,
616 Err(Attempt::Ureq(e)) if matches!(*e, ureq::Error::Status(404, _)) => {
617 return Err(Error::Bad(format!("no atom {id}")));
618 }
619 Err(e) => return Err(into_error(&url, e)),
620 };
621 Ok(resp.into_json()?)
622 }
623
624 pub fn list_atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
625 self.atoms_as_of(workspace, None)
626 }
627
628 pub fn atoms_as_of(
634 &self,
635 workspace: &str,
636 as_of: Option<&str>,
637 ) -> Result<Vec<serde_json::Value>, Error> {
638 let url = format!("{}/v1/atoms", self.base);
639 let mut req = authorize(ureq::get(&url)).query("workspace", workspace);
640 if let Some(at) = as_of {
641 req = req.query("as_of", at);
642 }
643 let mut body: serde_json::Value = send(req, None, timeout())
644 .map_err(|e| into_error(&url, e))?
645 .into_json()?;
646 let atoms = body
647 .get_mut("atoms")
648 .map(serde_json::Value::take)
649 .unwrap_or(serde_json::Value::Array(vec![]));
650 Ok(serde_json::from_value(atoms)?)
651 }
652
653 pub fn atoms_without_vectors(
661 &self,
662 workspace: &str,
663 as_of: Option<&str>,
664 ) -> Result<Vec<serde_json::Value>, Error> {
665 let url = format!("{}/v1/atoms", self.base);
666 let mut req = authorize(ureq::get(&url))
667 .query("workspace", workspace)
668 .query("embedding", "omit");
669 if let Some(at) = as_of {
670 req = req.query("as_of", at);
671 }
672 let mut body: serde_json::Value = send(req, None, timeout())
673 .map_err(|e| into_error(&url, e))?
674 .into_json()?;
675 let atoms = body
676 .get_mut("atoms")
677 .map(serde_json::Value::take)
678 .unwrap_or(serde_json::Value::Array(vec![]));
679 Ok(serde_json::from_value(atoms)?)
680 }
681
682 pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
683 self.search_as_of(workspace, q, limit, None)
684 }
685
686 pub fn search_as_of(
692 &self,
693 workspace: &str,
694 q: &str,
695 limit: u32,
696 as_of: Option<&str>,
697 ) -> Result<Vec<Hit>, Error> {
698 self.search_opts(workspace, q, limit, as_of, false)
699 }
700
701 pub fn search_opts(
711 &self,
712 workspace: &str,
713 q: &str,
714 limit: u32,
715 as_of: Option<&str>,
716 rerank: bool,
717 ) -> Result<Vec<Hit>, Error> {
718 self.search_ranked(workspace, q, limit, as_of, rerank, None)
719 }
720
721 pub fn search_ranked(
732 &self,
733 workspace: &str,
734 q: &str,
735 limit: u32,
736 as_of: Option<&str>,
737 rerank: bool,
738 rank: Option<&str>,
739 ) -> Result<Vec<Hit>, Error> {
740 let url = format!("{}/v1/search", self.base);
741 let budget = if rerank {
742 Duration::from_secs(60).max(timeout())
743 } else {
744 timeout()
745 };
746 let mut req = authorize(ureq::get(&url))
747 .query("workspace", workspace)
748 .query("q", q)
749 .query("limit", &limit.to_string());
750 if let Some(at) = as_of {
751 req = req.query("as_of", at);
752 }
753 if rerank {
754 req = req.query("rerank", "1");
755 }
756 if let Some(rank) = rank.map(str::trim).filter(|s| !s.is_empty()) {
757 req = req.query("rank", rank);
758 }
759 let body: serde_json::Value = send(req, None, budget)
760 .map_err(|e| into_error(&url, e))?
761 .into_json()?;
762 let hits = body
763 .get("hits")
764 .cloned()
765 .unwrap_or(serde_json::Value::Array(vec![]));
766 Ok(serde_json::from_value(hits)?)
767 }
768
769 pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
775 let url = format!("{}/v1/status", self.base);
776 let mut req = authorize(ureq::get(&url));
777 if let Some(workspace) = workspace {
778 req = req.query("workspace", workspace);
779 }
780 Ok(send(req, None, timeout())
781 .map_err(|e| into_error(&url, e))?
782 .into_json()?)
783 }
784
785 pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
791 let url = format!("{}/v1/pin", self.base);
792 Ok(send(
793 authorize(ureq::get(&url)).query("workspace", workspace),
794 None,
795 timeout(),
796 )
797 .map_err(|e| into_error(&url, e))?
798 .into_json()?)
799 }
800
801 pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
807 let url = format!("{}/v1/pin", self.base);
808 Ok(send(
809 authorize(ureq::put(&url)),
810 Some(&serde_json::json!({ "workspace": workspace, "name": name })),
811 timeout(),
812 )
813 .map_err(|e| into_error(&url, e))?
814 .into_json()?)
815 }
816
817 pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
826 let url = format!("{}/v1/accessions", self.base);
827 let body: serde_json::Value = send(
828 authorize(ureq::get(&url)).query("workspace", workspace),
829 None,
830 timeout(),
831 )
832 .map_err(|e| into_error(&url, e))?
833 .into_json()?;
834 let found = body
835 .get("accessions")
836 .cloned()
837 .unwrap_or(serde_json::Value::Array(vec![]));
838 Ok(serde_json::from_value(found)?)
839 }
840 pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
849 self.atoms_as_of(workspace, None)
850 }
851
852 pub fn atoms_of_kind(
859 &self,
860 workspace: &str,
861 kind: &str,
862 ) -> Result<Vec<serde_json::Value>, Error> {
863 let url = format!("{}/v1/atoms", self.base);
864 let body: serde_json::Value = send(
865 authorize(ureq::get(&url))
866 .query("workspace", workspace)
867 .query("kind", kind),
868 None,
869 timeout(),
870 )
871 .map_err(|e| into_error(&url, e))?
872 .into_json()?;
873 Ok(body
874 .get("atoms")
875 .and_then(serde_json::Value::as_array)
876 .cloned()
877 .unwrap_or_default())
878 }
879
880 pub fn citers(
886 &self,
887 workspace: &str,
888 accession: &str,
889 ) -> Result<Vec<serde_json::Value>, Error> {
890 let url = format!("{}/v1/citers", self.base);
891 let body: serde_json::Value = send(
892 authorize(ureq::get(&url))
893 .query("workspace", workspace)
894 .query("accession", accession),
895 None,
896 timeout(),
897 )
898 .map_err(|e| into_error(&url, e))?
899 .into_json()?;
900 let found = body
901 .get("atoms")
902 .cloned()
903 .unwrap_or(serde_json::Value::Array(vec![]));
904 Ok(serde_json::from_value(found)?)
905 }
906
907 pub fn delete_atom(
922 &self,
923 workspace: &str,
924 id: &str,
925 why: Option<&str>,
926 ) -> Result<serde_json::Value, Error> {
927 let url = format!("{}/v1/atoms/delete", self.base);
928 let mut body = serde_json::json!({
929 "workspace": workspace,
930 "id": id,
931 });
932 if let Some(accession) = why {
933 body["why"] = serde_json::Value::String(accession.to_string());
934 }
935 let resp = match send(authorize(ureq::post(&url)), Some(&body), timeout()) {
936 Ok(resp) => resp,
937 Err(Attempt::Ureq(e)) if matches!(*e, ureq::Error::Status(404, _)) => {
938 return Err(Error::Bad(format!("no atom {id}")));
939 }
940 Err(e) => return Err(into_error(&url, e)),
941 };
942 Ok(resp.into_json()?)
943 }
944
945 pub fn grade(
947 &self,
948 workspace: &str,
949 id: &str,
950 recalled: bool,
951 ) -> Result<serde_json::Value, Error> {
952 let url = format!("{}/v1/grade", self.base);
953 let body: serde_json::Value = send(
954 authorize(ureq::post(&url)),
955 Some(&serde_json::json!({
956 "workspace": workspace,
957 "id": id,
958 "recalled": recalled,
959 })),
960 timeout(),
961 )
962 .map_err(|e| into_error(&url, e))?
963 .into_json()?;
964 Ok(body)
965 }
966
967 pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
970 let url = format!("{}/v1/hubs", self.base);
971 let body: serde_json::Value = send(
972 authorize(ureq::get(&url))
973 .query("workspace", workspace)
974 .query("limit", &limit.to_string()),
975 None,
976 timeout(),
977 )
978 .map_err(|e| into_error(&url, e))?
979 .into_json()?;
980 Ok(body)
981 }
982
983 pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
984 let url = format!("{}/v1/islands", self.base);
985 let body: serde_json::Value = send(
986 authorize(ureq::get(&url)).query("workspace", workspace),
987 None,
988 timeout(),
989 )
990 .map_err(|e| into_error(&url, e))?
991 .into_json()?;
992 Ok(body)
993 }
994
995 pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
997 self.fire_as(workspace, ids, None)
998 }
999
1000 pub fn fire_as(
1007 &self,
1008 workspace: &str,
1009 ids: &[String],
1010 lens: Option<&str>,
1011 ) -> Result<serde_json::Value, Error> {
1012 let url = format!("{}/v1/fire", self.base);
1013 let body: serde_json::Value = send(
1014 authorize(ureq::post(&url)),
1015 Some(
1016 &serde_json::json!({"workspace": workspace, "ids": ids, "as": lens.unwrap_or("")}),
1017 ),
1018 timeout(),
1019 )
1020 .map_err(|e| into_error(&url, e))?
1021 .into_json()?;
1022 Ok(body)
1023 }
1024
1025 pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
1029 let url = format!("{}/v1/consolidate", self.base);
1030 let body: serde_json::Value = send(
1031 authorize(ureq::post(&url)),
1032 Some(&serde_json::json!({"workspace": workspace, "apply": apply})),
1033 timeout(),
1034 )
1035 .map_err(|e| into_error(&url, e))?
1036 .into_json()?;
1037 Ok(body)
1038 }
1039
1040 pub fn sweep(&self, workspace: &str) -> Result<serde_json::Value, Error> {
1047 let url = format!("{}/v1/sweep", self.base);
1048 let body: serde_json::Value = send(
1049 authorize(ureq::post(&url)),
1050 Some(&serde_json::json!({"workspace": workspace})),
1051 timeout(),
1052 )
1053 .map_err(|e| into_error(&url, e))?
1054 .into_json()?;
1055 Ok(body)
1056 }
1057
1058 pub fn forget_workspace(&self, workspace: &str) -> Result<serde_json::Value, Error> {
1066 let url = format!("{}/v1/forget", self.base);
1067 let body: serde_json::Value = send(
1068 authorize(ureq::post(&url)),
1069 Some(&serde_json::json!({"workspace": workspace})),
1070 timeout(),
1071 )
1072 .map_err(|e| into_error(&url, e))?
1073 .into_json()?;
1074 Ok(body)
1075 }
1076
1077 pub fn activate(
1080 &self,
1081 workspace: &str,
1082 q: &str,
1083 limit: u32,
1084 fire: bool,
1085 ) -> Result<serde_json::Value, Error> {
1086 self.activate_as(workspace, q, limit, fire, None)
1087 }
1088
1089 pub fn activate_as(
1096 &self,
1097 workspace: &str,
1098 q: &str,
1099 limit: u32,
1100 fire: bool,
1101 lens: Option<&str>,
1102 ) -> Result<serde_json::Value, Error> {
1103 let url = format!("{}/v1/activate", self.base);
1104 let mut req = authorize(ureq::get(&url))
1105 .query("workspace", workspace)
1106 .query("q", q)
1107 .query("limit", &limit.to_string())
1108 .query("fire", if fire { "1" } else { "0" });
1109 if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
1110 req = req.query("as", name);
1111 }
1112 let body: serde_json::Value = send(req, None, timeout())
1113 .map_err(|e| into_error(&url, e))?
1114 .into_json()?;
1115 Ok(body)
1116 }
1117
1118 pub fn atoms_in_set(
1125 &self,
1126 workspace: &str,
1127 set: &str,
1128 ) -> Result<Vec<serde_json::Value>, Error> {
1129 let url = format!("{}/v1/pack", self.base);
1130 let body: serde_json::Value = send(
1131 authorize(ureq::get(&url))
1132 .query("workspace", workspace)
1133 .query("set", set),
1134 None,
1135 timeout(),
1136 )
1137 .map_err(|e| into_error(&url, e))?
1138 .into_json()?;
1139 Ok(body
1140 .get("atoms")
1141 .and_then(serde_json::Value::as_array)
1142 .cloned()
1143 .unwrap_or_default())
1144 }
1145
1146 pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
1147 let url = format!("{}/v1/atoms", self.base);
1148 let body: serde_json::Value = send(authorize(ureq::post(&url)), Some(atom), timeout())
1149 .map_err(|e| into_error(&url, e))?
1150 .into_json()?;
1151 Ok(body)
1152 }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::*;
1158
1159 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1164
1165 #[test]
1166 fn only_a_loopback_url_has_a_port_to_check() {
1167 assert_eq!(loopback_port("http://127.0.0.1:8761/health"), Some(8761));
1168 assert_eq!(loopback_port("http://localhost:18462"), Some(18462));
1169 assert_eq!(loopback_port("http://[::1]:8761/v1/search?q=a"), Some(8761));
1170 assert_eq!(loopback_port("http://127.0.0.1/health"), Some(80));
1171 assert_eq!(loopback_port("http://example.org:8761/"), None);
1172 assert_eq!(loopback_port("http://[::1]/"), Some(80));
1173 }
1174
1175 #[cfg(target_os = "linux")]
1176 #[test]
1177 fn the_owner_of_a_listening_port_is_read_off_the_table() {
1178 let table = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
1179 0: 0100007F:223A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001 0 4242 1 0 100 0 0 10 0
1180 1: 0100007F:223A 0100007F:9C40 01 00000000:00000000 00:00000000 00000000 1000 0 4343 1 0 20 4 30 10 -1
1181 2: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 4444 1 0 100 0 0 10 0
1182";
1183 assert_eq!(listen_uids(table, 8762), vec![1001], "only the listener");
1184 assert_eq!(listen_uids(table, 8080), vec![1000]);
1185 assert!(listen_uids(table, 9).is_empty());
1186 }
1187
1188 #[cfg(target_os = "linux")]
1189 #[test]
1190 fn a_port_this_user_holds_is_not_foreign() {
1191 let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1192 let port = held.local_addr().unwrap().port();
1193 assert!(!foreign_listener(&format!(
1194 "http://127.0.0.1:{port}/health"
1195 )));
1196 drop(held);
1197 assert!(
1198 !foreign_listener(&format!("http://127.0.0.1:{port}/health")),
1199 "nobody listening is not foreign"
1200 );
1201 }
1202
1203 #[test]
1204 fn resolved_workspace_reads_ljos_env_not_default() {
1205 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1206 let dir = std::env::temp_dir().join(format!("packset-ljos-env-{}", std::process::id()));
1207 std::fs::create_dir_all(dir.join(".config/ljos")).unwrap();
1208 std::fs::write(
1209 dir.join(".config/ljos/env"),
1210 "PACKSET_WORKSPACE=git:example.com/seat/notes\n",
1211 )
1212 .unwrap();
1213 let old_home = env::var("HOME").ok();
1214 let old_ws = env::var("PACKSET_WORKSPACE").ok();
1215 unsafe {
1216 env::remove_var("PACKSET_WORKSPACE");
1217 env::set_var("HOME", &dir);
1218 }
1219 let got = resolved_workspace();
1220 unsafe {
1221 match old_home {
1222 Some(h) => env::set_var("HOME", h),
1223 None => env::remove_var("HOME"),
1224 }
1225 match old_ws {
1226 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1227 None => env::remove_var("PACKSET_WORKSPACE"),
1228 }
1229 }
1230 assert_eq!(got, "git:example.com/seat/notes");
1231 }
1232
1233 #[test]
1234 fn resolved_workspace_without_env_is_seat_not_default() {
1235 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1236 let dir = std::env::temp_dir().join(format!("packset-no-ljos-env-{}", std::process::id()));
1237 std::fs::create_dir_all(&dir).unwrap();
1238 let old_home = env::var("HOME").ok();
1239 let old_ws = env::var("PACKSET_WORKSPACE").ok();
1240 unsafe {
1241 env::remove_var("PACKSET_WORKSPACE");
1242 env::set_var("HOME", &dir);
1243 }
1244 let got = resolved_workspace();
1245 unsafe {
1246 match old_home {
1247 Some(h) => env::set_var("HOME", h),
1248 None => env::remove_var("HOME"),
1249 }
1250 match old_ws {
1251 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1252 None => env::remove_var("PACKSET_WORKSPACE"),
1253 }
1254 }
1255 assert_eq!(got, "seat");
1256 assert_ne!(got, "default");
1257 }
1258
1259 fn busy_for(
1262 busy: usize,
1263 ) -> (
1264 PacksetClient,
1265 std::sync::Arc<std::sync::atomic::AtomicUsize>,
1266 ) {
1267 use std::io::{BufRead, BufReader, Read, Write};
1268 use std::sync::atomic::Ordering;
1269 let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
1270 let client = PacksetClient::new(format!("http://{}", listener.local_addr().unwrap()));
1271 let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1272 let counted = std::sync::Arc::clone(&seen);
1273 std::thread::spawn(move || {
1274 for stream in listener.incoming().flatten() {
1275 let mut reader = BufReader::new(&stream);
1276 let mut length = 0;
1277 let mut line = String::new();
1278 while reader.read_line(&mut line).is_ok_and(|n| n > 2) {
1279 if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
1280 length = v.trim().parse().unwrap_or(0);
1281 }
1282 line.clear();
1283 }
1284 let mut body = vec![0u8; length];
1285 let _ = reader.read_exact(&mut body);
1286 let (status, text) = if counted.fetch_add(1, Ordering::SeqCst) < busy {
1287 ("503 Service Unavailable", r#"{"error":"busy"}"#)
1288 } else {
1289 ("200 OK", r#"{"ok":true}"#)
1290 };
1291 let _ = (&stream).write_all(
1292 format!(
1293 "HTTP/1.1 {status}\r\nRetry-After: 1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
1294 text.len()
1295 )
1296 .as_bytes(),
1297 );
1298 }
1299 });
1300 (client, seen)
1301 }
1302
1303 #[test]
1304 fn a_busy_writer_is_asked_again_for_a_read_and_a_write() {
1305 use std::sync::atomic::Ordering;
1306 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1307 let (client, seen) = busy_for(2);
1308 assert_eq!(client.status(None).unwrap()["ok"], true);
1309 assert_eq!(seen.load(Ordering::SeqCst), 3);
1310 let (client, seen) = busy_for(1);
1311 let stored = client
1312 .post_atom(&serde_json::json!({"text": "one write", "workspace": "w"}))
1313 .unwrap();
1314 assert_eq!(stored["ok"], true);
1315 assert_eq!(
1316 seen.load(Ordering::SeqCst),
1317 2,
1318 "the write was not sent again"
1319 );
1320 }
1321
1322 #[test]
1323 fn a_writer_that_stays_busy_fails_in_one_sentence() {
1324 use std::sync::atomic::Ordering;
1325 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1326 let old = env::var_os("PACKSET_TIMEOUT_MS");
1327 unsafe { env::set_var("PACKSET_TIMEOUT_MS", "700") };
1328 let (client, seen) = busy_for(usize::MAX);
1329 let err = client.status(None).unwrap_err().to_string();
1330 unsafe {
1331 match old {
1332 Some(value) => env::set_var("PACKSET_TIMEOUT_MS", value),
1333 None => env::remove_var("PACKSET_TIMEOUT_MS"),
1334 }
1335 }
1336 assert_eq!(err, "packsetd is busy.", "{err}");
1337 assert!(
1338 !err.contains("os error") && !err.contains("status code"),
1339 "{err}"
1340 );
1341 assert_eq!(
1344 seen.load(Ordering::SeqCst),
1345 usize::try_from(attempts_in(Duration::from_millis(700))).unwrap(),
1346 "{err}"
1347 );
1348 }
1349
1350 #[test]
1351 fn a_refused_connection_is_retried_and_then_one_sentence() {
1352 use std::time::Instant;
1353 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1354 let dir = std::env::temp_dir().join(format!("packset-down-{}", std::process::id()));
1355 std::fs::create_dir_all(&dir).unwrap();
1356 let saved = save_env(&[
1357 "HOME",
1358 "PACKSET_HOME",
1359 "LJOS_HOME",
1360 "LJOS_INSIDE_MEMORY_HOME",
1361 "PACKSET_BIN",
1362 "PACKSET_TIMEOUT_MS",
1363 ]);
1364 unsafe {
1365 env::set_var("HOME", &dir);
1366 env::remove_var("PACKSET_HOME");
1367 env::remove_var("LJOS_HOME");
1368 env::remove_var("LJOS_INSIDE_MEMORY_HOME");
1369 env::remove_var("PACKSET_BIN");
1370 env::set_var("PACKSET_TIMEOUT_MS", "250");
1371 }
1372 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1373 let port = listener.local_addr().unwrap().port();
1374 drop(listener);
1375 let client = PacksetClient::new(format!("http://127.0.0.1:{port}"));
1376 let started = Instant::now();
1377 let err = client.status(None).unwrap_err().to_string();
1378 let took = started.elapsed();
1379 restore_env(saved);
1380 let _ = std::fs::remove_dir_all(&dir);
1381 assert_eq!(err, "packsetd is not running.", "{err}");
1382 assert!(took >= Duration::from_millis(40), "no backoff: {took:?}");
1383 assert!(took < Duration::from_secs(2), "{took:?}");
1384 }
1385
1386 #[test]
1387 fn a_down_local_writer_is_started_once() {
1388 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1389 let dir = std::env::temp_dir().join(format!(
1390 "packset-autostart-{}-{}",
1391 std::process::id(),
1392 std::time::SystemTime::now()
1393 .duration_since(std::time::UNIX_EPOCH)
1394 .unwrap()
1395 .as_nanos()
1396 ));
1397 let home = dir.join("home");
1398 std::fs::create_dir_all(&home).unwrap();
1399 let log = dir.join("starts");
1400 let stub = dir.join("packsetd-stub");
1401 std::fs::write(
1402 &stub,
1403 format!(
1404 "#!/bin/sh\necho start >> \"{}\"\necho pid $$ >> \"{}\"\nexec python3 - \"$@\" << 'PY'\nimport sys\nargs = sys.argv[1:]\nport = int(args[args.index('--port') + 1])\nhome = args[args.index('--home') + 1]\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nclass Handler(BaseHTTPRequestHandler):\n def do_GET(self):\n body = b'{{\"ok\":true}}'\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.send_header('Content-Length', str(len(body)))\n self.end_headers()\n self.wfile.write(body)\n def log_message(self, *args):\n pass\nHTTPServer(('127.0.0.1', port), Handler).serve_forever()\nPY\n",
1405 log.display(),
1406 log.display(),
1407 ),
1408 )
1409 .unwrap();
1410 #[cfg(unix)]
1411 {
1412 use std::os::unix::fs::PermissionsExt;
1413 let mut mode = std::fs::metadata(&stub).unwrap().permissions();
1414 mode.set_mode(0o755);
1415 std::fs::set_permissions(&stub, mode).unwrap();
1416 }
1417 let saved = save_env(&[
1418 "HOME",
1419 "PACKSET_HOME",
1420 "LJOS_HOME",
1421 "LJOS_INSIDE_MEMORY_HOME",
1422 "PACKSET_BIN",
1423 "PACKSET_TIMEOUT_MS",
1424 "PACKSET_TOKEN",
1425 ]);
1426 unsafe {
1427 env::set_var("HOME", &dir);
1428 env::set_var("PACKSET_HOME", &home);
1429 env::remove_var("LJOS_HOME");
1430 env::remove_var("LJOS_INSIDE_MEMORY_HOME");
1431 env::remove_var("PACKSET_TOKEN");
1432 env::set_var("PACKSET_BIN", &stub);
1433 env::set_var("PACKSET_TIMEOUT_MS", "5000");
1434 }
1435 let reap = Reap(log.clone());
1436 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1437 let port = listener.local_addr().unwrap().port();
1438 drop(listener);
1439 let client = PacksetClient::new(format!("http://127.0.0.1:{port}"));
1440 let body = client.status(None).expect("the stub should answer");
1441 let text = std::fs::read_to_string(&log).unwrap_or_default();
1442 drop(reap);
1443 restore_env(saved);
1444 let _ = std::fs::remove_dir_all(&dir);
1445 assert_eq!(body["ok"], true, "{body}");
1446 assert_eq!(
1447 text.lines().filter(|line| *line == "start").count(),
1448 1,
1449 "{text}"
1450 );
1451 }
1452
1453 fn save_env(keys: &[&str]) -> Vec<(String, Option<std::ffi::OsString>)> {
1454 keys.iter()
1455 .map(|key| ((*key).to_string(), env::var_os(key)))
1456 .collect()
1457 }
1458
1459 fn restore_env(saved: Vec<(String, Option<std::ffi::OsString>)>) {
1460 for (key, value) in saved {
1461 unsafe {
1462 match value {
1463 Some(value) => env::set_var(key, value),
1464 None => env::remove_var(key),
1465 }
1466 }
1467 }
1468 }
1469
1470 struct Reap(std::path::PathBuf);
1472
1473 impl Drop for Reap {
1474 fn drop(&mut self) {
1475 let Ok(text) = std::fs::read_to_string(&self.0) else {
1476 return;
1477 };
1478 for line in text.lines() {
1479 let Some(pid) = line.strip_prefix("pid ") else {
1480 continue;
1481 };
1482 let _ = std::process::Command::new("kill")
1483 .args(["-9", pid.trim()])
1484 .status();
1485 }
1486 }
1487 }
1488
1489 const BUDGET: Duration = Duration::from_millis(100);
1492
1493 #[test]
1496 fn the_retries_stop_where_the_budget_does() {
1497 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1498 let old = env::var_os("PACKSET_TIMEOUT_MS");
1499 unsafe { env::set_var("PACKSET_TIMEOUT_MS", BUDGET.as_millis().to_string()) };
1500 let budget = timeout();
1501 unsafe {
1502 match old {
1503 Some(v) => env::set_var("PACKSET_TIMEOUT_MS", v),
1504 None => env::remove_var("PACKSET_TIMEOUT_MS"),
1505 }
1506 }
1507 assert_eq!(budget, BUDGET);
1508 let second = BUSY_WAIT * BUSY_GROWTH;
1509 assert!(
1510 ask_again(Duration::ZERO, BUSY_WAIT, budget),
1511 "the first wait did not fit"
1512 );
1513 assert!(!ask_again(BUSY_WAIT, second, budget), "the second wait fit");
1514 assert!(
1515 !ask_again(budget - BUSY_WAIT, BUSY_WAIT, budget),
1516 "a first round trip that spent the room was followed by a wait"
1517 );
1518 }
1519
1520 #[test]
1523 fn the_attempt_count_is_the_waits_that_fit_the_budget() {
1524 assert_eq!(attempts_in(Duration::from_millis(50)), 1);
1525 assert_eq!(attempts_in(Duration::from_millis(51)), 2);
1526 assert_eq!(attempts_in(Duration::from_millis(100)), 2);
1527 assert_eq!(attempts_in(Duration::from_millis(700)), 4);
1528 assert_eq!(attempts_in(Duration::from_secs(30)), 10);
1529 }
1530}