1use std::collections::BTreeMap;
10use std::ffi::OsString;
11use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream, ToSocketAddrs, UdpSocket};
12use std::path::Path;
13use std::process::Stdio;
14use std::sync::Mutex;
15use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
16use std::time::{Duration, Instant};
17
18use anyhow::Result;
19use command_group::CommandGroup;
20use serde_json::{Map, Value};
21
22use crate::tokens::{closing_brace, env_name, fallbacks};
23
24pub const DEFAULT_TIMEOUT_MS: u64 = 600_000;
26const DEFAULT_INTERVAL_MS: u64 = 250;
29const MAX_DURATION_MS: u64 = 2_147_483_647;
30const POLL: Duration = Duration::from_millis(20);
32
33#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum Probe {
36 Url(String),
38 Port { port: u16, host: Option<String> },
40 Command {
44 command: String,
45 env: BTreeMap<OsString, OsString>,
46 },
47 LogMatches(Vec<String>),
49 Signal,
52}
53
54#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct ReadyCheck {
57 pub probe: Probe,
58 pub timeout: Duration,
59 pub interval: Duration,
60}
61
62impl ReadyCheck {
63 pub fn describe(&self) -> String {
64 match &self.probe {
65 Probe::Url(url) => format!("url {url}"),
66 Probe::Port {
67 port,
68 host: Some(host),
69 } => format!("port {host}:{port}"),
70 Probe::Port { port, host: None } => format!("port {port}"),
71 Probe::Command { command, .. } => format!("command \"{command}\""),
72 Probe::LogMatches(matches) => format!(
73 "logMatches {}",
74 matches
75 .iter()
76 .map(|text| format!("\"{text}\""))
77 .collect::<Vec<_>>()
78 .join(", ")
79 ),
80 Probe::Signal => "signal".into(),
81 }
82 }
83}
84
85const PENDING: u8 = 0;
86const READY: u8 = 1;
87const FAILED: u8 = 2;
88
89#[derive(Debug, Default)]
92pub struct Verdict {
93 state: AtomicU8,
94 reason: Mutex<Option<String>>,
95 url: Mutex<Option<String>>,
97}
98
99impl Verdict {
100 pub fn is_ready(&self) -> bool {
101 self.state.load(Ordering::SeqCst) == READY
102 }
103
104 pub fn is_decided(&self) -> bool {
105 self.state.load(Ordering::SeqCst) != PENDING
106 }
107
108 pub fn failure(&self) -> Option<String> {
110 (self.state.load(Ordering::SeqCst) == FAILED)
111 .then(|| self.reason.lock().unwrap().clone().unwrap_or_default())
112 }
113
114 pub fn set_ready(&self) {
115 let _ = self
116 .state
117 .compare_exchange(PENDING, READY, Ordering::SeqCst, Ordering::SeqCst);
118 }
119
120 pub fn set_ready_at(&self, url: Option<String>) {
123 let mut stored = self.url.lock().unwrap();
124 if self
125 .state
126 .compare_exchange(PENDING, READY, Ordering::SeqCst, Ordering::SeqCst)
127 .is_ok()
128 {
129 *stored = url;
130 }
131 }
132
133 pub fn url(&self) -> Option<String> {
135 self.is_ready()
136 .then(|| self.url.lock().unwrap().clone())
137 .flatten()
138 }
139
140 pub fn fail(&self, reason: String) -> bool {
143 let mut stored = self.reason.lock().unwrap();
144 let decided = self
145 .state
146 .compare_exchange(PENDING, FAILED, Ordering::SeqCst, Ordering::SeqCst)
147 .is_ok();
148 if decided {
149 *stored = Some(reason);
150 }
151 decided
152 }
153}
154
155pub struct Scope<'a> {
157 pub workspace_root: &'a str,
158 pub project_root: &'a str,
159 pub project_name: &'a str,
160 pub options: &'a Map<String, Value>,
162 pub env: &'a BTreeMap<String, String>,
164}
165
166#[derive(Clone, Copy, PartialEq, Eq)]
168enum Field {
169 Text,
170 Host,
172 Port,
174}
175
176pub fn resolve(task: &str, ready_when: &Value, scope: &Scope) -> Result<Option<ReadyCheck>> {
179 let invalid = |reason: String| -> anyhow::Error {
180 anyhow::anyhow!("Task \"{task}\" has an invalid \"readyWhen\": {reason}.")
181 };
182 let Value::Object(config) = ready_when else {
183 return Err(invalid("expected an object".into()));
184 };
185 for key in config.keys() {
186 if !matches!(
187 key.as_str(),
188 "url" | "port" | "host" | "command" | "logMatches" | "signal" | "timeout" | "interval"
189 ) {
190 return Err(invalid(format!("unknown key \"{key}\"")));
191 }
192 }
193 let present: Vec<&str> = ["url", "port", "command", "logMatches", "signal"]
194 .into_iter()
195 .filter(|key| config.get(*key).is_some_and(|value| !value.is_null()))
196 .collect();
197 let [kind] = present[..] else {
198 return Err(invalid(
199 "expected exactly one of \"url\", \"port\", \"command\", \"logMatches\", \"signal\""
200 .into(),
201 ));
202 };
203 let duration = |key: &str, default: Option<u64>| -> Result<Option<Duration>> {
204 match config.get(key).filter(|value| !value.is_null()) {
205 None => Ok(default.map(Duration::from_millis)),
206 Some(value) => match value
207 .as_u64()
208 .filter(|ms| (1..=MAX_DURATION_MS).contains(ms))
209 {
210 Some(ms) => Ok(Some(Duration::from_millis(ms))),
211 None => Err(invalid(format!(
212 "\"{key}\" must be an integer from 1 to {MAX_DURATION_MS}"
213 ))),
214 },
215 }
216 };
217 let timeout = duration("timeout", Some(DEFAULT_TIMEOUT_MS))?.unwrap();
218 let interval = duration("interval", Some(DEFAULT_INTERVAL_MS))?.unwrap();
219 let text = |key: &str, field: Field| -> Result<Option<Value>> {
220 match config.get(key) {
221 None | Some(Value::Null) => Ok(None),
222 Some(Value::String(template)) => render(template, scope, field)
223 .map(|text| Some(Value::String(text)))
224 .map_err(invalid),
225 Some(other) => Ok(Some(other.clone())),
226 }
227 };
228 let probe = match kind {
229 "url" => match text("url", Field::Text)? {
230 Some(Value::String(url))
231 if (url.starts_with("http://") || url.starts_with("https://"))
232 && url.parse::<ureq::http::Uri>().is_ok() =>
233 {
234 Probe::Url(url)
235 }
236 _ => return Err(invalid("\"url\" must be an http or https URL".into())),
237 },
238 "port" => {
239 let port = text("port", Field::Port)?;
240 let host = match text("host", Field::Host)? {
241 None => None,
242 Some(Value::String(host)) if !host.is_empty() && !host.contains(['{', '}']) => {
243 Some(host)
244 }
245 Some(Value::String(host)) if host.contains(['{', '}']) => {
246 return Err(invalid("\"host\" contains an unknown placeholder".into()));
247 }
248 _ => return Err(invalid("\"host\" must be a non-empty string".into())),
249 };
250 let port = match port {
251 Some(Value::String(port)) if port == "auto" => 0,
252 Some(Value::String(port)) if port.contains(['{', '}']) => {
253 return Err(invalid("\"port\" contains an unknown placeholder".into()));
254 }
255 Some(Value::String(port)) => port.parse::<u64>().unwrap_or(u64::MAX),
256 Some(Value::Number(port)) => port.as_u64().unwrap_or(u64::MAX),
257 _ => u64::MAX,
258 };
259 match port {
260 0 => return Ok(None),
263 1..=65535 => Probe::Port {
264 port: port as u16,
265 host,
266 },
267 _ => {
268 return Err(invalid(
269 "\"port\" must be an integer from 0 to 65535, or \"auto\"".into(),
270 ));
271 }
272 }
273 }
274 "signal" => {
275 if config["signal"] != Value::Bool(true) {
276 return Err(invalid("\"signal\" must be true".into()));
277 }
278 if config.get("interval").is_some_and(|value| !value.is_null()) {
279 return Err(invalid("\"interval\" is not used by \"signal\"".into()));
280 }
281 Probe::Signal
282 }
283 "command" => match text("command", Field::Text)? {
284 Some(Value::String(command)) if !command.is_empty() => Probe::Command {
285 command,
286 env: BTreeMap::new(),
287 },
288 _ => return Err(invalid("\"command\" must be a non-empty string".into())),
289 },
290 _ => {
291 let templates = match &config["logMatches"] {
292 Value::Array(templates) => templates.clone(),
293 single => vec![single.clone()],
294 };
295 let mut matches = Vec::new();
296 for template in &templates {
297 match template {
298 Value::String(template) if !template.is_empty() => {
299 matches.push(render(template, scope, Field::Text).map_err(invalid)?);
300 }
301 _ => {
302 return Err(invalid(
303 "\"logMatches\" must be a non-empty string or an array of them".into(),
304 ));
305 }
306 }
307 }
308 if matches.is_empty() || matches.iter().any(String::is_empty) {
309 return Err(invalid(
310 "\"logMatches\" must be a non-empty string or an array of them".into(),
311 ));
312 }
313 Probe::LogMatches(matches)
314 }
315 };
316 Ok(Some(ReadyCheck {
317 probe,
318 timeout,
319 interval,
320 }))
321}
322
323fn render(template: &str, scope: &Scope, field: Field) -> std::result::Result<String, String> {
326 let mut result = String::new();
327 let mut index = 0;
328 while let Some(offset) = template[index..].find('{') {
329 let open = index + offset;
330 let resolved = match closing_brace(template, open) {
331 Some(close) => {
332 placeholder(&template[open..=close], scope, field)?.map(|value| (value, close))
333 }
334 None => None,
335 };
336 match resolved {
337 Some((value, close)) => {
338 result.push_str(&template[index..open]);
339 result.push_str(&value);
340 index = close + 1;
341 }
342 None => {
343 result.push_str(&template[index..=open]);
344 index = open + 1;
345 }
346 }
347 }
348 result.push_str(&template[index..]);
349 Ok(result)
350}
351
352fn placeholder(
355 raw: &str,
356 scope: &Scope,
357 field: Field,
358) -> std::result::Result<Option<String>, String> {
359 let sources = fallbacks(&raw[1..raw.len() - 1]);
360 let read = |source: &str| -> Option<Option<Value>> {
361 if let Some(name) = env_name(source) {
362 return Some(scope.env.get(name).cloned().map(Value::String));
363 }
364 if let Some(path) = source.strip_prefix("options.") {
365 let mut value = Some(&Value::Object(scope.options.clone()));
366 let mut owned = None;
367 for key in path.split('.') {
368 owned = value.and_then(|value| value.get(key)).cloned();
369 value = owned.as_ref();
370 }
371 return Some(owned);
372 }
373 match source {
374 "workspaceRoot" => Some(Some(Value::String(scope.workspace_root.into()))),
375 "projectRoot" => Some(Some(Value::String(scope.project_root.into()))),
376 "projectName" => Some(Some(Value::String(scope.project_name.into()))),
377 _ => None,
378 }
379 };
380 if read(sources[0]).is_none() {
381 return Ok(None);
382 }
383 for (index, source) in sources.iter().enumerate() {
384 let value = match read(source) {
385 Some(value) => value,
386 None if index + 1 == sources.len() => {
387 Some(Value::String(render(source, scope, Field::Text)?))
388 }
389 None => {
390 return Err(format!(
391 "\"{raw}\" has a fallback after the literal \"{source}\" that can never be used"
392 ));
393 }
394 };
395 let value = match value {
396 Some(Value::String(text)) if text.is_empty() && field == Field::Port => {
397 Value::Number(0.into())
398 }
399 Some(value) => value,
400 None => continue,
401 };
402 let unset = match &value {
403 Value::Null => true,
404 Value::String(text) => {
405 text.is_empty()
406 || (field != Field::Text && matches!(text.as_str(), "true" | "false"))
407 }
408 Value::Bool(_) => field != Field::Text,
409 _ => false,
410 };
411 if unset {
412 continue;
413 }
414 return match value {
415 Value::String(text) => Ok(Some(text)),
416 Value::Number(number) => Ok(Some(number.to_string())),
417 Value::Bool(flag) => Ok(Some(flag.to_string())),
418 _ => Err(format!("\"{raw}\" is not a string or a number")),
419 };
420 }
421 Err(format!("\"{raw}\" has no value"))
422}
423
424pub fn wait(task: &str, check: &ReadyCheck, root: &Path, verdict: &Verdict, stop: &AtomicBool) {
428 let deadline = Instant::now() + check.timeout;
429 loop {
430 if verdict.is_decided() || stop.load(Ordering::SeqCst) {
431 return;
432 }
433 let ready = match &check.probe {
434 Probe::Url(url) => probe_url(url, deadline),
435 Probe::Port { port, host } => probe_port(*port, host.as_deref(), deadline),
436 Probe::Command { command, env } => probe_command(command, env, root, deadline, stop),
437 Probe::LogMatches(_) | Probe::Signal => false,
439 };
440 if ready {
441 verdict.set_ready();
442 return;
443 }
444 let now = Instant::now();
445 if now >= deadline {
446 let longer = (check.timeout.as_millis() as u64 * 2).min(MAX_DURATION_MS);
447 verdict.fail(format!(
448 "Task \"{task}\" did not become ready within {}ms (readyWhen: {}). If it needs \
449 longer, set \"timeout\": {longer} in the target's \"readyWhen\", as \
450 {{ \"...\": true, \"timeout\": {longer} }} to keep a check a target default \
451 sets. To change the check, set \"readyWhen\" on the target or in \
452 \"targetDefaults\" in nx.json, or set it to null to turn it off.",
453 check.timeout.as_millis(),
454 check.describe()
455 ));
456 return;
457 }
458 let pause = if matches!(check.probe, Probe::LogMatches(_) | Probe::Signal) {
459 deadline - now
460 } else {
461 check.interval.min(deadline - now)
462 };
463 let until = now + pause;
464 while Instant::now() < until {
465 if verdict.is_decided() || stop.load(Ordering::SeqCst) {
466 return;
467 }
468 std::thread::sleep(POLL.min(until.saturating_duration_since(Instant::now())));
469 }
470 }
471}
472
473fn unbracketed(host: &str) -> &str {
474 host.trim_start_matches('[').trim_end_matches(']')
475}
476
477fn destination(ip: IpAddr) -> IpAddr {
479 match ip.to_canonical() {
480 IpAddr::V4(v4) if v4.is_unspecified() => Ipv4Addr::LOCALHOST.into(),
481 IpAddr::V6(v6) if v6.is_unspecified() => Ipv6Addr::LOCALHOST.into(),
482 _ => ip,
483 }
484}
485
486fn is_loopback(ip: IpAddr) -> bool {
487 match ip {
488 IpAddr::V4(ip) => ip.is_loopback(),
489 IpAddr::V6(ip) => {
490 ip.is_loopback() || ip.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
491 }
492 }
493}
494
495fn local_ip(v6: bool) -> IpAddr {
499 let (bind, probe): (SocketAddr, SocketAddr) = if v6 {
500 (
501 (Ipv6Addr::UNSPECIFIED, 0).into(),
502 "[2001:4860:4860::8888]:53".parse().unwrap(),
503 )
504 } else {
505 (
506 (Ipv4Addr::UNSPECIFIED, 0).into(),
507 "8.8.8.8:53".parse().unwrap(),
508 )
509 };
510 UdpSocket::bind(bind)
512 .and_then(|socket| socket.connect(probe).and_then(|()| socket.local_addr()))
513 .map(|address| address.ip())
514 .unwrap_or(if v6 {
515 Ipv6Addr::LOCALHOST.into()
516 } else {
517 Ipv4Addr::LOCALHOST.into()
518 })
519}
520
521fn connects(address: SocketAddr, deadline: Instant) -> bool {
522 let left = deadline.saturating_duration_since(Instant::now());
523 !left.is_zero() && TcpStream::connect_timeout(&address, left).is_ok()
524}
525
526fn probe_port(port: u16, host: Option<&str>, deadline: Instant) -> bool {
529 let addresses: Vec<SocketAddr> = match host.map(unbracketed) {
530 None => vec![
531 (Ipv4Addr::LOCALHOST, port).into(),
532 (Ipv6Addr::LOCALHOST, port).into(),
533 ],
534 Some("local-ip") => {
535 let ip = local_ip(false);
536 let ip = if ip.is_loopback() { local_ip(true) } else { ip };
537 vec![(ip, port).into()]
538 }
539 Some("local-ipv4") => vec![(local_ip(false), port).into()],
540 Some("local-ipv6") => vec![(local_ip(true), port).into()],
541 Some(host) => match host.parse::<IpAddr>() {
542 Ok(ip) => vec![(destination(ip), port).into()],
543 Err(_) => match (host, port).to_socket_addrs() {
544 Ok(addresses) => addresses.collect(),
545 Err(_) => return false,
546 },
547 },
548 };
549 addresses
550 .into_iter()
551 .any(|address| connects(address, deadline))
552}
553
554fn target(url: &str) -> Option<(String, bool)> {
558 let uri: ureq::http::Uri = url.parse().ok()?;
559 let host = uri.host()?;
560 let bare = unbracketed(host);
561 if let Ok(ip) = bare.parse::<IpAddr>() {
562 let ip = destination(ip);
563 let written = match ip {
564 IpAddr::V4(v4) => v4.to_string(),
565 IpAddr::V6(v6) => format!("[{v6}]"),
566 };
567 let url = url.replacen(host, &written, 1);
568 return Some((url, is_loopback(ip)));
569 }
570 let name = bare.trim_end_matches('.');
571 let local = (name == "localhost" || name.ends_with(".localhost"))
572 && (bare, uri.port_u16().unwrap_or(80))
573 .to_socket_addrs()
574 .is_ok_and(|addresses| {
575 let addresses: Vec<_> = addresses.collect();
576 !addresses.is_empty() && addresses.iter().all(|address| is_loopback(address.ip()))
577 });
578 Some((url.to_owned(), local))
579}
580
581fn probe_url(url: &str, deadline: Instant) -> bool {
585 let Some((url, local)) = target(url) else {
586 return false;
587 };
588 let status = |url: &str| -> Option<u16> {
589 let left = deadline.saturating_duration_since(Instant::now());
590 if left.is_zero() {
591 return None;
592 }
593 let mut config = ureq::Agent::config_builder()
594 .max_redirects(0)
595 .http_status_as_error(false)
596 .timeout_global(Some(left));
597 if local {
598 config = config.proxy(None).tls_config(
601 ureq::tls::TlsConfig::builder()
602 .disable_verification(true)
603 .build(),
604 );
605 }
606 let agent: ureq::Agent = config.build().into();
607 agent
608 .get(url)
609 .call()
610 .ok()
611 .map(|response| response.status().as_u16())
612 };
613 let ready = |status: u16| (200..=403).contains(&status);
614 match status(&url) {
615 Some(404) => {
616 let uri: Option<ureq::http::Uri> = url.parse().ok();
617 if uri.as_ref().is_some_and(|uri| uri.path() == "/") {
618 let index = format!("{}/index.html", url.trim_end_matches('/'));
619 status(&index).is_some_and(ready)
620 } else {
621 false
622 }
623 }
624 Some(found) => ready(found),
625 None => false,
626 }
627}
628
629fn probe_command(
633 command: &str,
634 env: &BTreeMap<OsString, OsString>,
635 root: &Path,
636 deadline: Instant,
637 stop: &AtomicBool,
638) -> bool {
639 let mut shell = crate::shell(command);
640 if !env.is_empty() {
641 shell.env_clear().envs(env);
642 }
643 shell
644 .current_dir(root)
645 .stdin(Stdio::null())
646 .stdout(Stdio::null())
647 .stderr(Stdio::null());
648 let Ok(mut child) = shell.group_spawn() else {
649 return false;
650 };
651 loop {
652 match child.try_wait() {
653 Ok(Some(status)) => return status.success(),
654 Ok(None) => {}
655 Err(_) => break,
656 }
657 if Instant::now() >= deadline || stop.load(Ordering::SeqCst) {
658 break;
659 }
660 std::thread::sleep(POLL);
661 }
662 let _ = child.kill();
663 let _ = child.wait();
664 false
665}
666
667#[derive(Debug)]
671pub struct LogMatcher {
672 pending: Vec<String>,
673 tails: [String; 2],
675 states: [Escape; 2],
677}
678
679#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
680enum Escape {
681 #[default]
682 Text,
683 Start,
685 Csi,
687 Control,
689 ControlEnd,
691}
692
693impl LogMatcher {
694 pub fn new(patterns: Vec<String>) -> Self {
695 Self {
696 pending: patterns,
697 tails: Default::default(),
698 states: Default::default(),
699 }
700 }
701
702 pub fn feed(&mut self, stderr: bool, bytes: &[u8]) -> bool {
704 let stream = usize::from(stderr);
705 let text = String::from_utf8_lossy(bytes);
706 let (tail, state) = (&mut self.tails[stream], &mut self.states[stream]);
707 for c in text.chars() {
708 *state = match (*state, c) {
709 (Escape::Text, '\u{1b}') => Escape::Start,
710 (Escape::Text, c) => {
711 tail.push(c);
712 Escape::Text
713 }
714 (Escape::Start, '[') => Escape::Csi,
715 (Escape::Start, ']' | 'P' | 'X' | '^' | '_') => Escape::Control,
716 (Escape::Start, _) => Escape::Text,
717 (Escape::Csi, '\u{40}'..='\u{7e}') => Escape::Text,
718 (Escape::Csi, _) => Escape::Csi,
719 (Escape::Control, '\u{7}') => Escape::Text,
720 (Escape::Control, '\u{1b}') => Escape::ControlEnd,
721 (Escape::Control, _) => Escape::Control,
722 (Escape::ControlEnd, '\\') => Escape::Text,
723 (Escape::ControlEnd, _) => Escape::Control,
724 };
725 }
726 self.pending
727 .retain(|pattern| !tail.contains(pattern.as_str()));
728 let keep = self
729 .pending
730 .iter()
731 .map(|pattern| pattern.len().saturating_sub(1))
732 .max()
733 .unwrap_or(0);
734 if tail.len() > keep {
735 let mut start = tail.len() - keep;
736 while !tail.is_char_boundary(start) {
737 start += 1;
738 }
739 tail.drain(..start);
740 }
741 self.pending.is_empty()
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use serde_json::json;
749
750 fn scope<'a>(options: &'a Map<String, Value>, env: &'a BTreeMap<String, String>) -> Scope<'a> {
751 Scope {
752 workspace_root: "/repo",
753 project_root: "apps/app",
754 project_name: "app",
755 options,
756 env,
757 }
758 }
759
760 fn resolved(
761 ready_when: Value,
762 options: Value,
763 env: &[(&str, &str)],
764 ) -> Result<Option<ReadyCheck>> {
765 let options = options.as_object().unwrap().clone();
766 let env = env
767 .iter()
768 .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
769 .collect();
770 resolve("app:serve", &ready_when, &scope(&options, &env))
771 }
772
773 #[test]
774 fn placeholders_read_options_env_and_fallbacks() {
775 let check = resolved(
776 json!({"url": "http://localhost:{options.port ?? env.PORT ?? 4200}/{projectName}"}),
777 json!({}),
778 &[("PORT", "5001")],
779 )
780 .unwrap()
781 .unwrap();
782 assert_eq!(check.probe, Probe::Url("http://localhost:5001/app".into()));
783 assert_eq!(check.timeout, Duration::from_millis(600_000));
784 assert_eq!(check.interval, Duration::from_millis(250));
785 let check = resolved(
786 json!({"port": "{options.port}"}),
787 json!({"port": 3000}),
788 &[],
789 )
790 .unwrap()
791 .unwrap();
792 assert_eq!(
793 check.probe,
794 Probe::Port {
795 port: 3000,
796 host: None
797 }
798 );
799 let check = resolved(
800 json!({"logMatches": ["ready on {options.server.port}", "{other}"], "timeout": 5}),
801 json!({"server": {"port": 1}}),
802 &[],
803 )
804 .unwrap()
805 .unwrap();
806 assert_eq!(
807 check.probe,
808 Probe::LogMatches(vec!["ready on 1".into(), "{other}".into()])
809 );
810 assert_eq!(check.timeout, Duration::from_millis(5));
811 }
812
813 #[test]
814 fn a_port_the_server_picks_leaves_nothing_to_check() {
815 for port in [json!(0), json!("auto"), json!("{options.port}")] {
816 let options = json!({"port": ""});
817 assert_eq!(resolved(json!({"port": port}), options, &[]).unwrap(), None);
818 }
819 }
820
821 #[test]
822 fn a_flag_without_a_value_leaves_host_and_port_unset() {
823 let check = resolved(
824 json!({"port": "{options.port ?? 4200}", "host": "{options.host ?? env.HOST}"}),
825 json!({"port": "true", "host": "true"}),
826 &[],
827 );
828 assert!(format!("{:#}", check.unwrap_err()).contains("has no value"));
829 let check = resolved(
830 json!({"port": "{options.port ?? 4200}"}),
831 json!({"port": true}),
832 &[],
833 )
834 .unwrap()
835 .unwrap();
836 assert_eq!(
837 check.probe,
838 Probe::Port {
839 port: 4200,
840 host: None
841 }
842 );
843 }
844
845 #[test]
846 fn a_signal_waits_for_the_task_to_report() {
847 let check = resolved(json!({"signal": true, "timeout": 5}), json!({}), &[])
848 .unwrap()
849 .unwrap();
850 assert_eq!(
851 (check.probe.clone(), check.describe()),
852 (Probe::Signal, "signal".into())
853 );
854 let verdict = Verdict::default();
856 wait(
857 "app:serve",
858 &check,
859 Path::new("."),
860 &verdict,
861 &AtomicBool::new(false),
862 );
863 assert!(
864 verdict
865 .failure()
866 .unwrap()
867 .contains("did not become ready within 5ms")
868 );
869 let verdict = Verdict::default();
870 verdict.set_ready_at(Some("http://localhost:1/".into()));
871 verdict.set_ready_at(Some("http://localhost:2/".into()));
872 assert_eq!(verdict.url().as_deref(), Some("http://localhost:1/"));
873 }
874
875 #[test]
876 fn invalid_settings_say_why() {
877 for (ready_when, reason) in [
878 (json!({}), "exactly one of"),
879 (json!({"url": "a", "port": 1}), "exactly one of"),
880 (json!({"url": "ftp://x"}), "http or https"),
881 (json!({"port": 70000}), "from 0 to 65535"),
882 (json!({"port": "{options.missing}"}), "has no value"),
883 (json!({"command": ""}), "non-empty"),
884 (json!({"logMatches": []}), "logMatches"),
885 (json!({"port": 1, "timeout": 0}), "\"timeout\""),
886 (json!({"port": 1, "other": 0}), "unknown key"),
887 (json!({"signal": false}), "\"signal\" must be true"),
888 (json!({"signal": "yes"}), "\"signal\" must be true"),
889 (
890 json!({"signal": true, "interval": 10}),
891 "not used by \"signal\"",
892 ),
893 (json!({"signal": true, "port": 1}), "exactly one of"),
894 (
895 json!({"url": "http://{options.a ?? x ?? options.b}"}),
896 "can never be used",
897 ),
898 ] {
899 let error = resolved(ready_when.clone(), json!({}), &[]).unwrap_err();
900 assert!(
901 format!("{error:#}").contains(reason),
902 "{ready_when}: {error:#}"
903 );
904 }
905 }
906
907 #[test]
908 fn log_matcher_needs_every_pattern_without_control_sequences() {
909 let mut matcher = LogMatcher::new(vec!["ready".into(), "listening".into()]);
910 assert!(!matcher.feed(false, b"server listening on 3000"));
911 assert!(matcher.feed(true, b"\x1b[32mrea\x1b[0mdy"));
912 let mut matcher = LogMatcher::new(vec!["ready".into()]);
913 assert!(!matcher.feed(false, b"rea\x1b["));
914 assert!(matcher.feed(false, b"32mdy"));
915 let mut matcher = LogMatcher::new(vec!["ready".into()]);
916 assert!(!matcher.feed(false, b"\x1b]0;ready\x07"));
917 assert!(matcher.feed(false, b"rea\x1b]8;;http://x\x1b\\dy"));
918 let mut matcher = LogMatcher::new(vec!["éready".into()]);
919 assert!(!matcher.feed(false, "éééa".as_bytes()));
920 assert!(matcher.feed(false, "éready".as_bytes()));
921 }
922
923 #[test]
924 fn port_and_command_probes_wait_for_readiness() {
925 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
926 let port = listener.local_addr().unwrap().port();
927 let stop = AtomicBool::new(false);
928 for host in [None, Some("127.0.0.1"), Some("0.0.0.0")] {
929 let verdict = Verdict::default();
930 let check = ReadyCheck {
931 probe: Probe::Port {
932 port,
933 host: host.map(str::to_owned),
934 },
935 timeout: Duration::from_secs(5),
936 interval: Duration::from_millis(10),
937 };
938 wait("app:serve", &check, Path::new("."), &verdict, &stop);
939 assert!(verdict.is_ready(), "{host:?}");
940 }
941 drop(listener);
942 let verdict = Verdict::default();
943 let check = ReadyCheck {
944 probe: Probe::Command {
945 command: "exit 1".into(),
946 env: BTreeMap::new(),
947 },
948 timeout: Duration::from_millis(100),
949 interval: Duration::from_millis(10),
950 };
951 wait("app:serve", &check, Path::new("."), &verdict, &stop);
952 assert!(
953 verdict
954 .failure()
955 .unwrap()
956 .contains("did not become ready within 100ms")
957 );
958 let verdict = Verdict::default();
959 let check = ReadyCheck {
960 probe: Probe::Command {
961 command: "exit 0".into(),
962 env: BTreeMap::new(),
963 },
964 ..check
965 };
966 wait("app:serve", &check, Path::new("."), &verdict, &stop);
967 assert!(verdict.is_ready());
968 }
969
970 #[test]
971 fn url_probes_take_statuses_up_to_403_and_try_index_html() {
972 use std::io::{Read, Write};
973 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
974 let port = listener.local_addr().unwrap().port();
975 std::thread::spawn(move || {
976 for mut stream in listener.incoming().flatten() {
977 let mut request = Vec::new();
978 let mut byte = [0u8; 1];
979 while stream.read(&mut byte).is_ok_and(|n| n == 1) {
980 request.push(byte[0]);
981 if request.ends_with(b"\r\n\r\n") {
982 break;
983 }
984 }
985 let request = String::from_utf8_lossy(&request);
986 let status = if request.starts_with("GET /index.html") {
987 "200 OK"
988 } else if request.starts_with("GET /missing") || request.starts_with("GET / ") {
989 "404 Not Found"
990 } else {
991 "302 Found\r\nlocation: /elsewhere"
992 };
993 let _ = stream.write_all(
994 format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\n\r\n").as_bytes(),
995 );
996 }
997 });
998 let deadline = Instant::now() + Duration::from_secs(5);
999 assert!(probe_url(&format!("http://127.0.0.1:{port}/"), deadline));
1000 assert!(probe_url(
1001 &format!("http://0.0.0.0:{port}/redirects"),
1002 deadline
1003 ));
1004 assert!(!probe_url(
1005 &format!("http://127.0.0.1:{port}/missing"),
1006 deadline
1007 ));
1008 }
1009}