|
7 | 7 | #![allow(clippy::all, clippy::pedantic, clippy::nursery, clippy::restriction)] |
8 | 8 |
|
9 | 9 | use std::{ |
| 10 | + ffi::OsStr, |
10 | 11 | fs, |
| 12 | + io::{self, Read}, |
11 | 13 | path::{Path, PathBuf}, |
12 | | - process::Command, |
| 14 | + process::{Command, ExitStatus, Output, Stdio}, |
13 | 15 | sync::LazyLock, |
| 16 | + thread, |
| 17 | + time::{Duration, Instant}, |
14 | 18 | }; |
15 | 19 |
|
16 | 20 | use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; |
@@ -359,8 +363,124 @@ fn settlement_instruction(instruction: &InstructionBox) -> &SettlementInstructio |
359 | 363 | .expect("settlement instruction payload") |
360 | 364 | } |
361 | 365 |
|
362 | | -fn command() -> Command { |
363 | | - let mut cmd = Command::new(cli_binary()); |
| 366 | +const CLI_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); |
| 367 | +const CLI_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(10); |
| 368 | + |
| 369 | +struct TestCommand { |
| 370 | + inner: Command, |
| 371 | + timeout: Duration, |
| 372 | +} |
| 373 | + |
| 374 | +impl TestCommand { |
| 375 | + fn new(program: impl AsRef<OsStr>) -> Self { |
| 376 | + Self { |
| 377 | + inner: Command::new(program), |
| 378 | + timeout: CLI_COMMAND_TIMEOUT, |
| 379 | + } |
| 380 | + } |
| 381 | + |
| 382 | + fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self { |
| 383 | + self.inner.arg(arg); |
| 384 | + self |
| 385 | + } |
| 386 | + |
| 387 | + fn args<I, S>(&mut self, args: I) -> &mut Self |
| 388 | + where |
| 389 | + I: IntoIterator<Item = S>, |
| 390 | + S: AsRef<OsStr>, |
| 391 | + { |
| 392 | + self.inner.args(args); |
| 393 | + self |
| 394 | + } |
| 395 | + |
| 396 | + fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self { |
| 397 | + self.inner.current_dir(dir); |
| 398 | + self |
| 399 | + } |
| 400 | + |
| 401 | + fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self { |
| 402 | + self.inner.env(key, value); |
| 403 | + self |
| 404 | + } |
| 405 | + |
| 406 | + fn output(&mut self) -> io::Result<Output> { |
| 407 | + self.inner |
| 408 | + .stdin(Stdio::null()) |
| 409 | + .stdout(Stdio::piped()) |
| 410 | + .stderr(Stdio::piped()); |
| 411 | + let mut child = self.inner.spawn()?; |
| 412 | + let stdout = child.stdout.take().map(read_pipe); |
| 413 | + let stderr = child.stderr.take().map(read_pipe); |
| 414 | + let Some(status) = wait_for_exit(&mut child, self.timeout)? else { |
| 415 | + let _ = child.kill(); |
| 416 | + let _ = child.wait(); |
| 417 | + let _ = join_pipe(stdout); |
| 418 | + let _ = join_pipe(stderr); |
| 419 | + return Err(command_timeout_error(&self.inner, self.timeout)); |
| 420 | + }; |
| 421 | + |
| 422 | + Ok(Output { |
| 423 | + status, |
| 424 | + stdout: join_pipe(stdout), |
| 425 | + stderr: join_pipe(stderr), |
| 426 | + }) |
| 427 | + } |
| 428 | + |
| 429 | + fn status(&mut self) -> io::Result<ExitStatus> { |
| 430 | + self.inner.stdin(Stdio::null()); |
| 431 | + let mut child = self.inner.spawn()?; |
| 432 | + let Some(status) = wait_for_exit(&mut child, self.timeout)? else { |
| 433 | + let _ = child.kill(); |
| 434 | + let _ = child.wait(); |
| 435 | + return Err(command_timeout_error(&self.inner, self.timeout)); |
| 436 | + }; |
| 437 | + Ok(status) |
| 438 | + } |
| 439 | +} |
| 440 | + |
| 441 | +fn read_pipe<R>(mut pipe: R) -> thread::JoinHandle<Vec<u8>> |
| 442 | +where |
| 443 | + R: Read + Send + 'static, |
| 444 | +{ |
| 445 | + thread::spawn(move || { |
| 446 | + let mut bytes = Vec::new(); |
| 447 | + let _ = pipe.read_to_end(&mut bytes); |
| 448 | + bytes |
| 449 | + }) |
| 450 | +} |
| 451 | + |
| 452 | +fn join_pipe(handle: Option<thread::JoinHandle<Vec<u8>>>) -> Vec<u8> { |
| 453 | + handle |
| 454 | + .and_then(|handle| handle.join().ok()) |
| 455 | + .unwrap_or_default() |
| 456 | +} |
| 457 | + |
| 458 | +fn wait_for_exit( |
| 459 | + child: &mut std::process::Child, |
| 460 | + timeout: Duration, |
| 461 | +) -> io::Result<Option<ExitStatus>> { |
| 462 | + let started = Instant::now(); |
| 463 | + loop { |
| 464 | + if let Some(status) = child.try_wait()? { |
| 465 | + return Ok(Some(status)); |
| 466 | + } |
| 467 | + let elapsed = started.elapsed(); |
| 468 | + if elapsed >= timeout { |
| 469 | + return Ok(None); |
| 470 | + } |
| 471 | + thread::sleep(CLI_COMMAND_POLL_INTERVAL.min(timeout - elapsed)); |
| 472 | + } |
| 473 | +} |
| 474 | + |
| 475 | +fn command_timeout_error(command: &Command, timeout: Duration) -> io::Error { |
| 476 | + io::Error::new( |
| 477 | + io::ErrorKind::TimedOut, |
| 478 | + format!("command {command:?} timed out after {timeout:?}"), |
| 479 | + ) |
| 480 | +} |
| 481 | + |
| 482 | +fn command() -> TestCommand { |
| 483 | + let mut cmd = TestCommand::new(cli_binary()); |
364 | 484 | // Disable ANSI color codes so the assertions can match plain text. |
365 | 485 | cmd.env("NO_COLOR", "1"); |
366 | 486 | cmd.env("CLICOLOR", "0"); |
@@ -4949,7 +5069,7 @@ fn address_convert_outputs_i105_by_default() { |
4949 | 5069 | let expected_i105 = |
4950 | 5070 | encode_account_id_to_i105_for_discriminant(&account, 753).expect("i105 string"); |
4951 | 5071 |
|
4952 | | - let output = Command::new(cli_binary()) |
| 5072 | + let output = command() |
4953 | 5073 | .args(["tools", "address", "convert", &expected_i105]) |
4954 | 5074 | .output() |
4955 | 5075 | .expect("run address convert"); |
@@ -4983,7 +5103,7 @@ fn address_convert_json_summary_contains_i105_and_canonical_hex() { |
4983 | 5103 | let i105 = encode_account_id_to_i105_for_discriminant(&account, 753).expect("i105 string"); |
4984 | 5104 | let canonical = encode_account_id_to_canonical_hex(&account).expect("canonical"); |
4985 | 5105 |
|
4986 | | - let output = Command::new(cli_binary()) |
| 5106 | + let output = command() |
4987 | 5107 | .args([ |
4988 | 5108 | "tools", |
4989 | 5109 | "address", |
@@ -5051,7 +5171,7 @@ fn address_convert_rejects_domain_suffix() { |
5051 | 5171 | let i105 = encode_account_id_to_i105_for_discriminant(&account, 753).expect("i105"); |
5052 | 5172 | let literal = format!("{i105}@{domain}"); |
5053 | 5173 |
|
5054 | | - let output = Command::new(cli_binary()) |
| 5174 | + let output = command() |
5055 | 5175 | .current_dir(workspace_root()) |
5056 | 5176 | .args([ |
5057 | 5177 | "--config", |
@@ -5085,7 +5205,7 @@ fn address_convert_json_rejects_domain_suffix() { |
5085 | 5205 | let i105 = encode_account_id_to_i105_for_discriminant(&account, 753).expect("i105"); |
5086 | 5206 | let literal = format!("{i105}@universal"); |
5087 | 5207 |
|
5088 | | - let output = Command::new(cli_binary()) |
| 5208 | + let output = command() |
5089 | 5209 | .current_dir(workspace_root()) |
5090 | 5210 | .args([ |
5091 | 5211 | "--config", |
@@ -5121,7 +5241,7 @@ fn address_convert_json_summary_is_domainless() { |
5121 | 5241 | let account = AccountId::new(key_pair.public_key().clone()); |
5122 | 5242 | let i105 = encode_account_id_to_i105_for_discriminant(&account, 753).expect("i105"); |
5123 | 5243 |
|
5124 | | - let output = Command::new(cli_binary()) |
| 5244 | + let output = command() |
5125 | 5245 | .current_dir(workspace_root()) |
5126 | 5246 | .args([ |
5127 | 5247 | "--config", |
@@ -5167,7 +5287,7 @@ fn address_audit_reports_parsed_and_errors() { |
5167 | 5287 | let contents = format!("# sample addresses\n{local_i105}\n{default_i105}\ninvalid-address\n"); |
5168 | 5288 | fs::write(&input_path, contents).expect("write addresses"); |
5169 | 5289 |
|
5170 | | - let output = Command::new(cli_binary()) |
| 5290 | + let output = command() |
5171 | 5291 | .current_dir(workspace_root()) |
5172 | 5292 | .args([ |
5173 | 5293 | "--config", |
@@ -5219,7 +5339,7 @@ fn address_audit_rejects_domain_suffix() { |
5219 | 5339 | let path = temp_dir.path().join("addresses.txt"); |
5220 | 5340 | fs::write(&path, format!("{literal}\n")).expect("write addresses"); |
5221 | 5341 |
|
5222 | | - let output = Command::new(cli_binary()) |
| 5342 | + let output = command() |
5223 | 5343 | .current_dir(workspace_root()) |
5224 | 5344 | .args([ |
5225 | 5345 | "--config", |
@@ -5258,7 +5378,7 @@ fn address_audit_supports_csv_output() { |
5258 | 5378 | let path = temp_dir.path().join("addresses.txt"); |
5259 | 5379 | fs::write(&path, format!("{i105}\ninvalid-address\n")).expect("write addresses"); |
5260 | 5380 |
|
5261 | | - let output = Command::new(cli_binary()) |
| 5381 | + let output = command() |
5262 | 5382 | .current_dir(workspace_root()) |
5263 | 5383 | .args([ |
5264 | 5384 | "--config", |
@@ -5430,7 +5550,7 @@ fn space_directory_manifest_audit_bundle_cli() { |
5430 | 5550 | let bundle_dir = temp_dir.path().join("bundle"); |
5431 | 5551 | let bundle_dir_str = bundle_dir.to_str().expect("bundle path utf-8").to_owned(); |
5432 | 5552 |
|
5433 | | - let status = Command::new(cli_binary()) |
| 5553 | + let status = command() |
5434 | 5554 | .args([ |
5435 | 5555 | "app", |
5436 | 5556 | "space-directory", |
@@ -5496,13 +5616,16 @@ mod torii_mock_support { |
5496 | 5616 | net::{TcpListener, TcpStream}, |
5497 | 5617 | path::{Path, PathBuf}, |
5498 | 5618 | process::{Command, Stdio}, |
| 5619 | + sync::mpsc, |
5499 | 5620 | thread, |
5500 | | - time::{SystemTime, UNIX_EPOCH}, |
| 5621 | + time::{Duration, SystemTime, UNIX_EPOCH}, |
5501 | 5622 | }; |
5502 | 5623 |
|
5503 | 5624 | use norito::json; |
5504 | 5625 | use url::Url; |
5505 | 5626 |
|
| 5627 | + const MOCK_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); |
| 5628 | + |
5506 | 5629 | #[derive(Debug)] |
5507 | 5630 | pub enum SpawnError { |
5508 | 5631 | PythonUnavailable, |
@@ -5575,35 +5698,66 @@ mod torii_mock_support { |
5575 | 5698 | .stdout |
5576 | 5699 | .take() |
5577 | 5700 | .ok_or_else(|| SpawnError::Setup("missing stdout pipe".into()))?; |
5578 | | - let mut reader = BufReader::new(stdout); |
5579 | | - let mut line = String::new(); |
5580 | | - match reader.read_line(&mut line) { |
5581 | | - Ok(0) => { |
| 5701 | + let (line_tx, line_rx) = mpsc::channel(); |
| 5702 | + let startup_thread = thread::spawn(move || { |
| 5703 | + let mut reader = BufReader::new(stdout); |
| 5704 | + let mut line = String::new(); |
| 5705 | + let result = reader.read_line(&mut line).map(|read| (read, line, reader)); |
| 5706 | + let _ = line_tx.send(result); |
| 5707 | + }); |
| 5708 | + match line_rx.recv_timeout(MOCK_STARTUP_TIMEOUT) { |
| 5709 | + Ok(Ok((0, _, _))) => { |
| 5710 | + let _ = child.kill(); |
| 5711 | + let _ = child.wait(); |
| 5712 | + let _ = startup_thread.join(); |
5582 | 5713 | last_error = Some(io::Error::new( |
5583 | 5714 | io::ErrorKind::UnexpectedEof, |
5584 | 5715 | "torii mock exited early", |
5585 | 5716 | )); |
5586 | | - let _ = child.wait(); |
5587 | 5717 | } |
5588 | | - Ok(_) => { |
5589 | | - let base_url = parse_base_url(line.trim())?; |
5590 | | - let captured_url = base_url.clone(); |
| 5718 | + Ok(Ok((_, line, reader))) => { |
| 5719 | + let _ = startup_thread.join(); |
| 5720 | + let base_url = match parse_base_url(line.trim()) { |
| 5721 | + Ok(base_url) => base_url, |
| 5722 | + Err(err) => { |
| 5723 | + let _ = child.kill(); |
| 5724 | + let _ = child.wait(); |
| 5725 | + return Err(err); |
| 5726 | + } |
| 5727 | + }; |
5591 | 5728 | let stdout_thread = thread::spawn(move || { |
5592 | 5729 | let mut reader = reader; |
5593 | 5730 | let mut sink = io::sink(); |
5594 | 5731 | let _ = io::copy(&mut reader, &mut sink); |
5595 | 5732 | }); |
5596 | 5733 | return Ok(Self { |
5597 | 5734 | child, |
5598 | | - base_url: captured_url, |
| 5735 | + base_url, |
5599 | 5736 | stdout_thread: Some(stdout_thread), |
5600 | 5737 | }); |
5601 | 5738 | } |
5602 | | - Err(err) => { |
| 5739 | + Ok(Err(err)) => { |
| 5740 | + let _ = startup_thread.join(); |
5603 | 5741 | let _ = child.kill(); |
5604 | 5742 | let _ = child.wait(); |
5605 | 5743 | return Err(SpawnError::Io(err)); |
5606 | 5744 | } |
| 5745 | + Err(mpsc::RecvTimeoutError::Timeout) => { |
| 5746 | + let _ = child.kill(); |
| 5747 | + let _ = child.wait(); |
| 5748 | + let _ = startup_thread.join(); |
| 5749 | + return Err(SpawnError::Setup(format!( |
| 5750 | + "torii mock did not announce base_url within {MOCK_STARTUP_TIMEOUT:?}" |
| 5751 | + ))); |
| 5752 | + } |
| 5753 | + Err(mpsc::RecvTimeoutError::Disconnected) => { |
| 5754 | + let _ = child.kill(); |
| 5755 | + let _ = child.wait(); |
| 5756 | + let _ = startup_thread.join(); |
| 5757 | + return Err(SpawnError::Setup( |
| 5758 | + "torii mock startup reader stopped before base_url".into(), |
| 5759 | + )); |
| 5760 | + } |
5607 | 5761 | } |
5608 | 5762 | } |
5609 | 5763 | if let Some(err) = last_error { |
|
0 commit comments