forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.9 KiB
1//! Helpers shared by the forge-web e2e tests. Every test file pulls this in with `mod common;`, so
2//! items a given test binary does not use are allowed to be dead code.
3//!
4//! The server is started with `launch`, which waits for the three listeners (HTTP, user content
5//! and SSH) with backoff. A connection that is refused just means "not yet". If the child exits
6//! first, the panic carries its captured log. A port picked with `pick_ports` is only free until
7//! the server binds it, so `launch` retries on fresh ports when the server reports a bind conflict.
8
9#![allow(dead_code)]
10
11use std::fs;
12use std::net::TcpStream;
13use std::path::{Path, PathBuf};
14use std::process::{Child, Command, ExitStatus, Output, Stdio};
15use std::time::{Duration, Instant};
16
17pub const BIN: &str = env!("CARGO_BIN_EXE_forge");
18
19/// How long a freshly spawned server gets to answer on all three listeners.
20const READY_TIMEOUT: Duration = Duration::from_secs(30);
21/// Starts `forge serve` on fresh ports at most this many times after bind conflicts.
22const MAX_LAUNCH_ATTEMPTS: u32 = 5;
23
24/// The running `forge serve` child. Killed on drop, even when an assertion fails.
25pub struct Server(pub Child);
26
27impl Drop for Server {
28 fn drop(&mut self) {
29 let _ = self.0.kill();
30 let _ = self.0.wait();
31 }
32}
33
34/// The three loopback ports a server listens on: the app, SSH and the raw user-content listener.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct Ports {
37 pub http: u16,
38 pub ssh: u16,
39 pub content: u16,
40}
41
42/// A port the kernel handed out for a moment. It is free only until something binds it.
43pub fn free_port() -> u16 {
44 std::net::TcpListener::bind("127.0.0.1:0")
45 .expect("bind free port")
46 .local_addr()
47 .expect("local addr")
48 .port()
49}
50
51/// Three distinct free ports.
52pub fn pick_ports() -> Ports {
53 let mut ports: Vec<u16> = Vec::new();
54 while ports.len() < 3 {
55 let port = free_port();
56 if !ports.contains(&port) {
57 ports.push(port);
58 }
59 }
60 Ports {
61 http: ports[0],
62 ssh: ports[1],
63 content: ports[2],
64 }
65}
66
67pub fn tool_on_path(name: &str) -> bool {
68 Command::new(name)
69 .stdin(Stdio::null())
70 .stdout(Stdio::null())
71 .stderr(Stdio::null())
72 .status()
73 .is_ok()
74}
75
76/// Writes `dir/forge.toml` for `ports` and returns its path.
77pub fn write_config(dir: &Path, ports: Ports) -> PathBuf {
78 write_config_with(dir, ports, "")
79}
80
81/// Like `write_config`, with `extra` TOML appended after the defaults (for example a `[webhooks]`
82/// table). `extra` must not repeat a key or table the defaults already set.
83pub fn write_config_with(dir: &Path, ports: Ports, extra: &str) -> PathBuf {
84 let d = dir.display();
85 let Ports {
86 http: http_port,
87 ssh: ssh_port,
88 content: content_port,
89 } = ports;
90 let text = format!(
91 "database = \"{d}/forge.db\"\n\
92 data_dir = \"{d}\"\n\
93 listen = \"127.0.0.1:{http_port}\"\n\
94 base_url = \"http://127.0.0.1:{http_port}\"\n\
95 user_content_base_url = \"http://127.0.0.1:{content_port}\"\n\
96 user_content_listen = \"127.0.0.1:{content_port}\"\n\
97 ssh_listen = \"127.0.0.1:{ssh_port}\"\n\
98 ssh_public_host = \"127.0.0.1\"\n\
99 ssh_public_port = {ssh_port}\n\
100 registration = \"closed\"\n{extra}"
101 );
102 let path = dir.join("forge.toml");
103 fs::write(&path, text).expect("write config");
104 path
105}
106
107pub fn describe(out: &Output) -> String {
108 format!(
109 "status: {}\nstdout: {}\nstderr: {}",
110 out.status,
111 String::from_utf8_lossy(&out.stdout),
112 String::from_utf8_lossy(&out.stderr)
113 )
114}
115
116/// Starts `forge serve` on `ports` and returns once all three listeners answer. The config is
117/// written to `dir/forge.toml` and the log to `dir/serve.log`.
118///
119/// If the server exits because one of the ports was taken in the meantime, it is started again on
120/// fresh ports. The ports in use are returned, so callers must build their clients from them.
121pub async fn launch(dir: &Path, ports: Ports) -> (Server, Ports) {
122 launch_with(dir, ports, "").await
123}
124
125/// `launch` with extra TOML appended to the config (see `write_config_with`).
126pub async fn launch_with(dir: &Path, ports: Ports, extra: &str) -> (Server, Ports) {
127 let log_path = dir.join("serve.log");
128 let mut ports = ports;
129 for attempt in 1..=MAX_LAUNCH_ATTEMPTS {
130 let config = write_config_with(dir, ports, extra);
131 let mut server = spawn(&config, &log_path);
132 match wait_ready(ports, &mut server, &log_path).await {
133 Ok(()) => return (server, ports),
134 Err(status) => {
135 let log = read_log(&log_path);
136 if attempt < MAX_LAUNCH_ATTEMPTS && bind_conflict(&log) {
137 ports = pick_ports();
138 continue;
139 }
140 panic!("forge exited early ({status}):\n{log}");
141 }
142 }
143 }
144 unreachable!("the last attempt either returns or panics")
145}
146
147/// Starts the server again on the exact `ports` of an earlier run, for restart tests. Unlike
148/// `launch` this does not retry, because the caller's clients already use these ports.
149pub async fn relaunch(config: &Path, log_path: &Path, ports: Ports) -> Server {
150 let mut server = spawn(config, log_path);
151 match wait_ready(ports, &mut server, log_path).await {
152 Ok(()) => server,
153 Err(status) => panic!(
154 "forge exited early on restart ({status}):\n{}",
155 read_log(log_path)
156 ),
157 }
158}
159
160fn spawn(config: &Path, log_path: &Path) -> Server {
161 let log = fs::File::create(log_path).expect("log file");
162 let child = Command::new(BIN)
163 .arg("--config")
164 .arg(config)
165 .arg("serve")
166 .stdin(Stdio::null())
167 .stdout(Stdio::from(log.try_clone().expect("clone log")))
168 .stderr(Stdio::from(log))
169 .spawn()
170 .expect("spawn forge serve");
171 Server(child)
172}
173
174/// Polls the three listeners with backoff until they all answer. Returns the exit status if the
175/// child dies first, and panics with the log if the deadline passes.
176async fn wait_ready(ports: Ports, server: &mut Server, log: &Path) -> Result<(), ExitStatus> {
177 let client = reqwest::Client::builder()
178 .timeout(Duration::from_secs(2))
179 .build()
180 .expect("probe client");
181 let app = format!("http://127.0.0.1:{}/healthz", ports.http);
182 let raw = format!("http://127.0.0.1:{}/healthz", ports.content);
183 let deadline = Instant::now() + READY_TIMEOUT;
184 let mut delay = Duration::from_millis(10);
185 loop {
186 if let Some(status) = server.0.try_wait().expect("poll forge-web") {
187 return Err(status);
188 }
189 if probe(&client, &app).await && probe(&client, &raw).await && ssh_open(ports.ssh) {
190 return Ok(());
191 }
192 if Instant::now() >= deadline {
193 panic!(
194 "forge was not ready within {READY_TIMEOUT:?}:\n{}",
195 read_log(log)
196 );
197 }
198 tokio::time::sleep(delay).await;
199 delay = (delay * 2).min(Duration::from_millis(250));
200 }
201}
202
203/// True when the URL answers 200. Connection refused, timeouts and other errors all mean "not yet".
204async fn probe(client: &reqwest::Client, url: &str) -> bool {
205 matches!(client.get(url).send().await, Ok(res) if res.status() == reqwest::StatusCode::OK)
206}
207
208fn ssh_open(port: u16) -> bool {
209 TcpStream::connect(("127.0.0.1", port)).is_ok()
210}
211
212fn read_log(log: &Path) -> String {
213 fs::read_to_string(log).unwrap_or_default()
214}
215
216/// Whether the server log reports that a listener could not bind because the port is taken.
217fn bind_conflict(log: &str) -> bool {
218 let log = log.to_ascii_lowercase();
219 log.contains("address already in use")
220 || log.contains("addrinuse")
221 || log.contains("os error 98")
222 || log.contains("os error 48")
223 || log.contains("os error 10048")
224}