Skip to main content

qk_executor/
readiness.rs

1//! A continuous target's `readyWhen`, as Nx 24 checks it: a url to request, a
2//! port to connect to, a command that must succeed, or text to find in the
3//! output. Tasks that depend on the target with `waitFor: "ready"` start once
4//! the check passes.
5//!
6//! This is unrelated to the run-commands option of the same name, which marks
7//! the run-commands task itself as ready.
8
9use 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
24// As in Nx 24.0.0-rc.0, long enough for a dev server's cold first build.
25pub const DEFAULT_TIMEOUT_MS: u64 = 600_000;
26/// As in Nx 24.0.0-beta.3, probes retry every 250 ms unless `interval` says
27/// otherwise.
28const DEFAULT_INTERVAL_MS: u64 = 250;
29const MAX_DURATION_MS: u64 = 2_147_483_647;
30/// How often a wait looks at whether it has been stopped or decided.
31const POLL: Duration = Duration::from_millis(20);
32
33/// What tells that a task is ready.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum Probe {
36    /// A GET answered with a status from 200 to 403.
37    Url(String),
38    /// A TCP connection, to loopback unless a host is named.
39    Port { port: u16, host: Option<String> },
40    /// A shell command at the workspace root exiting with 0, run with the
41    /// environment the task's commands run with and its options as JSON in
42    /// `NX_TASK_OPTIONS`, as in Nx 24.
43    Command {
44        command: String,
45        env: BTreeMap<OsString, OsString>,
46    },
47    /// Every one of these appearing in the task's output.
48    LogMatches(Vec<String>),
49    /// The task reporting it is ready, through the socket qk names to it in
50    /// `NX_READY_SIGNAL_SOCKET`, as `@nx/devkit`'s `signalTaskReady` does.
51    Signal,
52}
53
54/// A resolved `readyWhen`.
55#[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/// What a task's readiness check concluded, shared between the check, the
90/// task's output and the scheduler. The first conclusion stands.
91#[derive(Debug, Default)]
92pub struct Verdict {
93    state: AtomicU8,
94    reason: Mutex<Option<String>>,
95    /// The address a `signal` check's task reported with its readiness.
96    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    /// Why the check failed, once it has.
109    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    /// Ready, with the address the task reported, unless it was decided
121    /// already.
122    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    /// The address the task reported once ready, if it reported one.
134    pub fn url(&self) -> Option<String> {
135        self.is_ready()
136            .then(|| self.url.lock().unwrap().clone())
137            .flatten()
138    }
139
140    /// Records a failure unless the check was decided already, and says
141    /// whether this call decided it.
142    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
155/// What `readyWhen` placeholders read.
156pub struct Scope<'a> {
157    pub workspace_root: &'a str,
158    pub project_root: &'a str,
159    pub project_name: &'a str,
160    /// The options the task runs with, its arguments applied.
161    pub options: &'a Map<String, Value>,
162    /// The environment the task runs with.
163    pub env: &'a BTreeMap<String, String>,
164}
165
166/// How a placeholder treats what it reads.
167#[derive(Clone, Copy, PartialEq, Eq)]
168enum Field {
169    Text,
170    /// `true` and `false`, which a flag without a value gives, are unset.
171    Host,
172    /// As `Host`, and an empty value is `0`, a port the server picks.
173    Port,
174}
175
176/// Resolves a target's `readyWhen`: `None` when the server picks its own
177/// port, which leaves nothing to check.
178pub 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                // The server picks its port when it starts, so nothing can be
261                // checked: it is ready once started.
262                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
323/// A `readyWhen` string with its placeholders replaced. Braces that are no
324/// placeholder stay as written.
325fn 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
352/// The value a `{…}` placeholder stands for, or `None` when it is no
353/// placeholder.
354fn 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
424/// Checks until the task is ready, the check times out, or `stop` is set,
425/// and records the conclusion in `verdict`. `logMatches` is decided by the
426/// task's output, so this only times it out.
427pub 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            // Decided by the task's output, or by its signal.
438            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
477/// A wildcard means loopback of its own family, as Nx probes it.
478fn 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
495/// The address webpack-dev-server and the Rspack dev server pick for the
496/// `local-ip` hosts: the machine's own outward address of that family, or
497/// loopback when it has none.
498fn 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    // Connecting a UDP socket sends nothing; it only picks the route.
511    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
526/// Without a host, either loopback address accepting is enough: a server
527/// bound to only one of them is still ready.
528fn 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
554/// The url to request, a wildcard host rewritten to loopback, and whether it
555/// stays on this machine: a loopback address, or a `localhost` name whose
556/// every address is loopback.
557fn 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
581/// Ready on a status from 200 to 403, a redirect included: an auth challenge
582/// or a forbidden root still means the server is up. A 404 at the root is
583/// tried again at `/index.html`, which may be all a static server answers.
584fn 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            // A request to this machine skips proxies and accepts a
599            // self-signed development certificate.
600            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
629/// Runs the command through the shell at the workspace root, with `env` in
630/// place of qk's own when it has any. On timeout or stop, its whole process
631/// group is killed.
632fn 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/// Watches a task's output for `logMatches` text, in any order, on either
668/// stream and across reads. Terminal control sequences are left out, as a
669/// terminal would not show them.
670#[derive(Debug)]
671pub struct LogMatcher {
672    pending: Vec<String>,
673    /// Each stream's visible text, kept as long as the longest pending match.
674    tails: [String; 2],
675    /// Each stream's place in a control sequence a read ended inside.
676    states: [Escape; 2],
677}
678
679#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
680enum Escape {
681    #[default]
682    Text,
683    /// After ESC.
684    Start,
685    /// In a CSI sequence, up to its final byte.
686    Csi,
687    /// In an OSC, DCS, SOS, PM or APC string, up to BEL or ST.
688    Control,
689    /// After ESC inside such a string, which ST ends.
690    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    /// Feeds one read; true once every pattern has appeared.
703    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        // Nothing but the task's own report makes it ready.
855        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}