use super::tests::{World, land_with_failed_index, request, world};
use super::*;
use crate::service_client::{Activation, ClientConfig, Submitted, submit};
use crate::service_messages::ClientFailure;
use crate::service_server::{Bind, ServeSettings, serve};
use crate::test_support::{git_text, remove, while_test_lives};
use std::os::unix::net::UnixListener;
use std::time::{Duration, Instant};
const PATIENCE: Duration = Duration::from_secs(60);
const STUCK_AFTER: Duration = Duration::from_secs(1);
const SECOND_CEILING: Duration = Duration::from_secs(2);
const INSIDE_CEILING: Duration = Duration::from_secs(5);
const SECOND_WITHDRAWAL: Duration = Duration::from_secs(45);
fn git_that_holds_the_branch_write(world: &World) -> (PathBuf, PathBuf, PathBuf) {
let entered: PathBuf = world.root.join("write-entered");
let release: PathBuf = world.root.join("write-release");
let program: PathBuf = world.root.join("holding-update-ref-git");
let script: String = format!(
"#!/bin/sh\ncase \" $* \" in\n *\" update-ref --stdin \"*)\n : > '{entered}'\n while [ ! -e '{release}' ] && {alive}; do sleep 0.05; done\n exec '{real}' \"$@\";;\n *) exec '{real}' \"$@\";;\nesac\n",
entered = entered.display(),
release = release.display(),
alive = while_test_lives(world.root.as_path()),
real = crate::test_support::REAL_GIT
);
std::fs::write(&program, script).expect("script");
std::fs::set_permissions(&program, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("executable");
return (program, entered, release);
}
fn short_directory(name: &str) -> PathBuf {
let home: PathBuf = PathBuf::from(std::env::var_os("HOME").expect("a home directory for the short directory"));
let directory: PathBuf = home.join("temp/agent").join(format!("hw-{}-{name}", std::process::id()));
let _stale = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("short directory");
return directory;
}
struct Serving {
directory: PathBuf,
socket: PathBuf,
}
fn serve_on_a_thread(context: LandContext, name: &str) -> Serving {
return serve_on_a_thread_stuck_after(context, name, STUCK_AFTER);
}
fn serve_on_a_thread_stuck_after(context: LandContext, name: &str, stuck_after: Duration) -> Serving {
let directory: PathBuf = short_directory(name);
let socket: PathBuf = directory.join("s");
let listener: UnixListener = UnixListener::bind(&socket).expect("bind");
let mut settings: ServeSettings = ServeSettings::new(PATIENCE);
settings.stuck_after = stuck_after;
std::thread::spawn(move || { serve(listener, &context, &settings); });
return Serving { directory, socket };
}
fn client(serving: &Serving, answer_ceiling: Duration, withdraw_ceiling: Duration) -> ClientConfig {
return client_with_extra_wait(serving, answer_ceiling, withdraw_ceiling, crate::wait_ceilings::SHORT_EXTRA_WAIT);
}
fn client_with_extra_wait(serving: &Serving, answer_ceiling: Duration, withdraw_ceiling: Duration, extra_wait: Duration) -> ClientConfig {
return ClientConfig {
address: Bind::Path(serving.socket.clone()),
activation: Activation::Managed,
start_wait: PATIENCE,
heartbeat_silence: PATIENCE,
start_watch: None,
keep_fresh: None,
answer_ceiling,
withdraw_ceiling,
extra_wait,
start_ceiling: PATIENCE,
};
}
struct Submitting {
ended: std::sync::mpsc::Receiver<(Result<Submitted, ClientFailure>, String)>,
}
impl Submitting {
fn join(self) -> Result<(Result<Submitted, ClientFailure>, String), String> {
return self.ended.recv_timeout(PATIENCE).map_err(|_| { return String::from("the client was still waiting after the longest a control waits"); });
}
}
fn submit_on_a_thread(config: ClientConfig, request: CommitRequest) -> Submitting {
let (sender, ended) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut notices: Vec<u8> = Vec::new();
let answered = submit(&config, &request, &mut notices);
let _sent = sender.send((answered, String::from_utf8(notices).expect("UTF-8")));
});
return Submitting { ended };
}
fn wait_for(path: &Path) {
let began: Instant = Instant::now();
while !path.exists() {
assert!(began.elapsed() < PATIENCE, "{} never appeared", path.display());
std::thread::sleep(Duration::from_millis(20));
}
}
fn wait_until_settled(world: &World) {
let began: Instant = Instant::now();
loop {
let open: usize = crate::service_journal::list_intents(world.context.journal.as_path(), crate::service_journal::OPEN_EXTENSION).len();
let marked: bool = std::fs::read_dir(world.context.journal.join("busy")).is_ok_and(|entries| {
return entries.flatten().any(|entry| { return entry.file_name() == "step" || std::fs::read_to_string(entry.path()).is_ok_and(|content| { return content == "landing"; }); });
});
if open == 0 && !marked && !world.repo.join(".git/index.lock").exists() {
break;
}
assert!(began.elapsed() < PATIENCE, "the service never finished with its batches");
std::thread::sleep(Duration::from_millis(20));
}
std::thread::sleep(Duration::from_millis(500));
}
fn subjects_since(world: &World, base: &str) -> String {
return git_text(world.repo.as_path(), &["log", "--reverse", "--format=%s", format!("{base}..HEAD").as_str()]);
}
#[test]
fn a_commit_queued_behind_a_branch_write_that_does_not_return_is_withdrawn_at_its_ceiling_naming_the_write() {
let world: World = world("service-hung-write-queued");
let base: String = git_text(world.repo.as_path(), &["rev-parse", "HEAD"]);
let (hanging, entered, release) = git_that_holds_the_branch_write(&world);
let mut context: LandContext = LandContext { git: crate::test_support::git_context(), journal: world.context.journal.clone(), on_objects_written: None, lock_ceiling: crate::wait_ceilings::LOCK_CEILING };
context.git.real_git = hanging;
let serving: Serving = serve_on_a_thread(context, "queued");
let first = submit_on_a_thread(client(&serving, PATIENCE, PATIENCE), request(&world, "first", "first.txt", "first\n"));
wait_for(entered.as_path());
std::thread::sleep(STUCK_AFTER + Duration::from_secs(1));
let began: Instant = Instant::now();
let second = submit_on_a_thread(client(&serving, SECOND_CEILING, SECOND_WITHDRAWAL), request(&world, "second", "second.txt", "second\n"));
let (second_answer, second_notices) = second.join().expect("second client");
let waited: Duration = began.elapsed();
let Err(ClientFailure::StillWithService { blocker, .. }) = &second_answer else { panic!("{second_answer:?} {second_notices}") };
let words: &str = blocker.as_deref().expect("the error names what the service was stuck in");
assert!(words.contains("git update-ref --stdin") && words.contains("never ends a write"), "{words}");
assert!(waited < SECOND_WITHDRAWAL / 2, "withdrawn at its own ceiling, not at the withdrawal ceiling: {waited:?}");
assert!(second_notices.contains("git update-ref --stdin"), "the client was told while it waited: {second_notices}");
assert_eq!(subjects_since(&world, base.as_str()), "", "the held write has not landed anything");
std::fs::write(&release, b"").expect("let the write go");
let (first_answer, first_notices) = first.join().expect("first client");
assert!(matches!(first_answer, Ok(Submitted::Landed { .. })), "{first_answer:?} {first_notices}");
let third = submit_on_a_thread(client(&serving, PATIENCE, PATIENCE), request(&world, "third", "third.txt", "third\n"));
let (third_answer, third_notices) = third.join().expect("third client");
assert!(matches!(third_answer, Ok(Submitted::Landed { .. })), "{third_answer:?} {third_notices}");
assert_eq!(subjects_since(&world, base.as_str()), "message of first\nmessage of third", "the withdrawn commit never landed");
wait_until_settled(&world);
remove(serving.directory.as_path());
remove(world.root.as_path());
}
#[test]
fn the_commit_inside_a_branch_write_that_does_not_return_is_reported_as_not_known_and_lands_when_the_write_returns() {
let world: World = world("service-hung-write-inside");
let base: String = git_text(world.repo.as_path(), &["rev-parse", "HEAD"]);
let (hanging, entered, release) = git_that_holds_the_branch_write(&world);
let mut context: LandContext = LandContext { git: crate::test_support::git_context(), journal: world.context.journal.clone(), on_objects_written: None, lock_ceiling: crate::wait_ceilings::LOCK_CEILING };
context.git.real_git = hanging;
let serving: Serving = serve_on_a_thread(context, "inside");
let extra_wait: Duration = Duration::from_secs(2);
let only = submit_on_a_thread(client_with_extra_wait(&serving, INSIDE_CEILING, SECOND_WITHDRAWAL, extra_wait), request(&world, "only", "only.txt", "only\n"));
wait_for(entered.as_path());
let began: Instant = Instant::now();
let (answer, notices) = only.join().expect("client");
let waited: Duration = began.elapsed();
assert!(waited < SECOND_WITHDRAWAL / 3, "returned at its own bound plus the extra wait, not at the withdrawal ceiling: {waited:?}");
let Err(ClientFailure::WithdrawalUnconfirmed { blocker }) = &answer else { panic!("{answer:?} {notices}") };
let words: &str = blocker.as_deref().expect("the error names the write");
assert!(words.contains("git update-ref --stdin") && words.contains("refs/worktree/cli-git-service/"), "{words}");
let text: String = crate::service_messages::render(&answer.clone().expect_err("an error"));
assert!(text.contains("git log -1") && text.contains("not known whether the commit was made") && text.contains(words.trim()), "{text}");
std::fs::write(&release, b"").expect("let the write go");
let released_at: Instant = Instant::now();
while subjects_since(&world, base.as_str()) != "message of only" {
assert!(released_at.elapsed() < PATIENCE, "the commit never landed: {}", subjects_since(&world, base.as_str()));
std::thread::sleep(Duration::from_millis(50));
}
wait_until_settled(&world);
remove(serving.directory.as_path());
remove(world.root.as_path());
}
#[test]
fn the_error_for_a_commit_behind_a_hung_write_names_the_way_out() {
let failure: ClientFailure = ClientFailure::StillWithService { minutes: 20, blocker: Some(String::from("cli-git: the commit service has been inside a write for 21 minutes")) };
let text: String = crate::service_messages::render(&failure);
assert!(text.contains("21 minutes") && text.contains(crate::service_messages::RESTART_COMMAND) && text.contains(crate::service_messages::LOG_COMMAND), "{text}");
}
#[test]
fn a_request_whose_commit_is_on_the_branch_in_an_open_batch_is_not_withdrawn_early() {
let world: World = world("service-hung-write-open-batch");
let landed: CommitRequest = request(&world, "landed", "landed.txt", "landed\n");
land_with_failed_index(&world, 1, &[landed]);
let board: std::sync::Arc<crate::service_busy::LandingBoard> = crate::service_busy::LandingBoard::new(world.context.journal.clone(), Duration::ZERO);
crate::service_busy::use_on_this_thread(std::sync::Arc::clone(&board));
assert_eq!(board.begin_group(&[String::from("stuck")]), crate::service_busy::GroupAdmission::default());
let _step = crate::service_busy::enter_step(String::from("git update-ref --stdin in /repo"));
std::thread::sleep(Duration::from_millis(20));
assert_eq!(board.withdraw_early("landed"), crate::service_busy::EarlyAnswer::Queued, "its commit is on the branch");
assert!(matches!(board.withdraw_early("never-seen"), crate::service_busy::EarlyAnswer::Withdrawn(_)), "a request no batch names is withdrawn");
remove(world.root.as_path());
}
#[test]
fn a_client_is_told_it_is_inside_the_write_when_the_write_starts() {
let world: World = world("service-hung-write-told-at-start");
let (hanging, entered, release) = git_that_holds_the_branch_write(&world);
let mut context: LandContext = LandContext { git: crate::test_support::git_context(), journal: world.context.journal.clone(), on_objects_written: None, lock_ceiling: crate::wait_ceilings::LOCK_CEILING };
context.git.real_git = hanging;
let serving: Serving = serve_on_a_thread_stuck_after(context, "told-at-start", Duration::from_secs(3600));
let extra_wait: Duration = Duration::from_secs(1);
let only = submit_on_a_thread(client_with_extra_wait(&serving, Duration::from_secs(4), SECOND_WITHDRAWAL, extra_wait), request(&world, "only", "only.txt", "only\n"));
wait_for(entered.as_path());
let (answer, notices) = only.join().expect("client");
assert!(matches!(answer, Err(ClientFailure::WithdrawalUnconfirmed { .. })), "{answer:?} {notices}");
assert!(notices.contains("this commit is inside a write of the commit service"), "{notices}");
std::fs::write(&release, b"").expect("let the write go");
wait_until_settled(&world);
remove(serving.directory.as_path());
remove(world.root.as_path());
}