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