Watch
0
0
Fork
You've already forked hyperhive
0

Merge forge/main into atlas/5034-s4-remove-hive-model

This commit is contained in:
atlas 2026-10-11 03:42:22 +02:00
commit baefb32bd5
22 changed files with 921 additions and 248 deletions

View file

@ -132,7 +132,37 @@ pub fn claude_dir() -> PathBuf {
PathBuf::from("/root/.claude")
}
/// Held by every test that points `HYPERHIVE_HARNESS_DIR` at a temp dir:
/// cargo runs a crate's tests on parallel threads of one process.
/// Run `f` with `HYPERHIVE_HARNESS_DIR` pointing at a fresh temp dir, then
/// restore the previous value. Tests that build a `Bus` or read the harness
/// state files go through this: the variable is process-wide, so the calls
/// are serialised on a lock.
#[cfg(test)]
pub(crate) static HARNESS_DIR_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub fn with_harness_dir<T>(f: impl FnOnce() -> T) -> T {
use std::ffi::OsString;
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
struct Restore(Option<OsString>);
impl Drop for Restore {
fn drop(&mut self) {
// SAFETY: still holding `LOCK`, the only writer of this variable.
unsafe {
match self.0.take() {
Some(v) => std::env::set_var("HYPERHIVE_HARNESS_DIR", v),
None => std::env::remove_var("HYPERHIVE_HARNESS_DIR"),
}
}
}
}
let _lock = LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = tempfile::tempdir().unwrap();
// Declared after `_lock` so the variable is restored before the lock drops.
let _restore = Restore(std::env::var_os("HYPERHIVE_HARNESS_DIR"));
// SAFETY: serialised by `LOCK`.
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
f()
}

View file

@ -320,37 +320,30 @@ mod tests {
/// persisted files, so clearing it, or a restart, finds the local value.
#[test]
fn the_swarm_value_is_not_persisted_over_the_local_one() {
let dir = tempfile::tempdir().unwrap();
let _env = crate::paths::HARNESS_DIR_ENV
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prev = std::env::var_os("HYPERHIVE_HARNESS_DIR");
// SAFETY: every test that sets this variable holds `HARNESS_DIR_ENV`.
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
let bus = Bus::new();
std::fs::write(dir.path().join("hyperhive-model"), "sonnet\n").unwrap();
std::fs::write(dir.path().join("hyperhive-effort"), "low\n").unwrap();
let target = Harness {
bus: bus.clone(),
nix_model: None,
nix_effort: None,
};
let mut o = Overridden::default();
o.apply(&set(Some("opus"), Some(Effort::Max)), &target);
let swarm = (bus.model(), bus.effort());
let swarm_sources = (bus.model_source(), bus.effort_source());
let files = (
crate::harness_state::load_model(),
crate::harness_state::load_effort(),
);
o.apply(&AgentInference::default(), &target);
let cleared = (bus.model(), bus.effort());
let cleared_sources = (bus.model_source(), bus.effort_source());
match prev {
// SAFETY: as above.
Some(v) => unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", v) },
None => unsafe { std::env::remove_var("HYPERHIVE_HARNESS_DIR") },
}
let (swarm, swarm_sources, files, cleared, cleared_sources) =
crate::paths::with_harness_dir(|| {
let bus = Bus::new();
let dir = crate::paths::harness_dir();
std::fs::write(dir.join("hyperhive-model"), "sonnet\n").unwrap();
std::fs::write(dir.join("hyperhive-effort"), "low\n").unwrap();
let target = Harness {
bus: bus.clone(),
nix_model: None,
nix_effort: None,
};
let mut o = Overridden::default();
o.apply(&set(Some("opus"), Some(Effort::Max)), &target);
let swarm = (bus.model(), bus.effort());
let swarm_sources = (bus.model_source(), bus.effort_source());
let files = (
crate::harness_state::load_model(),
crate::harness_state::load_effort(),
);
o.apply(&AgentInference::default(), &target);
let cleared = (bus.model(), bus.effort());
let cleared_sources = (bus.model_source(), bus.effort_source());
(swarm, swarm_sources, files, cleared, cleared_sources)
});
assert_eq!(swarm, pair("opus", "max"));
assert_eq!(files, (Some("sonnet".to_owned()), Some("low".to_owned())));
assert_eq!(cleared, pair("sonnet", "low"));

View file

@ -50,8 +50,8 @@ const DEFAULT_SESSION_TITLE: &str = "hive-session";
/// capacity limits.
const DEFAULT_RATE_LIMIT_SLEEP_SECS: u64 = 300;
/// Idle watchdog window: kill claude and surface `ApiStall` if it produces no
/// stdout for this long. The timer resets on every stdout line, so a large but
/// Idle watchdog window: send claude SIGINT and surface `ApiStall` once it
/// exits, if it produces no stdout for this long. The timer resets on every stdout line, so a large but
/// still-streaming turn is never cut — only a complete silence trips it.
/// Default is 10 minutes: long enough for legitimate big-context turns, short
/// enough to cut a multi-retry Anthropic connection storm (atlas telemetry:
@ -155,7 +155,7 @@ pub async fn write_system_prompt(socket: &Path, label: &str) -> Result<PathBuf>
/// One claude turn's outcome: `Ok(compacted)` on success, or a [`TurnError`]
/// the serve loop must act on. The `compacted` bool is `true` when a
/// compaction ran this turn (reactively on overflow, or proactively per the
/// policy — or an operator `/compact` at turn end); it's recorded as
/// policy — or a successful operator `/compact` at turn end); it's recorded as
/// `result_kind = "compacted"` in turn stats so the stats page can distinguish
/// those turns. Both `Ok(true)` and `Ok(false)` are ack'd; the error cases
/// each map to a distinct serve-loop action (see [`emit_turn_end`] and the
@ -189,14 +189,14 @@ pub enum TurnError {
/// the wake message, the serve loop requeues it so the next turn retries;
/// no status park.
SessionNotFound,
/// The harness-side idle watchdog killed claude after `turn_idle_secs()` of
/// The harness-side idle watchdog stopped claude after `turn_idle_secs()` of
/// output silence — indicative of an Anthropic API stall (e.g. a multi-retry
/// connection storm burning minutes of wall-clock with no stream progress).
/// The serve loop parks for `stall_sleep_secs()` and requeues, like the
/// rate-limit path — NOT a crash.
ApiStall,
/// The ACP backend's idle watchdog stopped a turn that sent nothing for
/// `turn_idle_secs()`: the agent was sent `session/cancel`, and killed if
/// `turn_idle_secs()`: the agent was sent `session/cancel`, and SIGINT if
/// it ignored that. Carries what happened. An ACP agent retrying a failing
/// provider (e.g. HTTP 429) without reporting it ends up here. Handled
/// like [`Self::ApiStall`].
@ -461,7 +461,7 @@ pub async fn drive_turn(
// Reflect `Compacting` in the UI like the idle path (`run_pending_compact`)
// does; the serve loop resets to `Idle` once this turn returns.
bus.set_state(crate::events::TurnState::Compacting);
let _ = session.compact(&config, &sink).await;
let compact_ok = report_compact(bus, session.compact(&config, &sink).await);
// If the compact call asked to be woken (the agent's own `compact`
// tool with a `wake_prompt`), stash it — the serve loop reads it
// back after this turn returns and drives a synthetic follow-up
@ -470,11 +470,38 @@ pub async fn drive_turn(
if let Some(prompt) = request.wake_prompt {
bus.set_post_compact_wake(prompt);
}
return Ok(true);
return with_turn_end_compact(outcome, compact_ok);
}
outcome
}
/// Emit the `/compact done` / `/compact failed: …` note for a finished
/// operator or agent `/compact` and return whether it succeeded. Shared by the
/// turn-end path in [`drive_turn`] and the idle path in [`run_pending_compact`].
fn report_compact<E: std::fmt::Display>(bus: &Bus, result: Result<(), E>) -> bool {
match result {
Ok(()) => {
bus.emit(LiveEvent::Note {
text: "/compact done".into(),
});
true
}
Err(e) => {
bus.emit(LiveEvent::Note {
text: format!("/compact failed: {e}"),
});
false
}
}
}
/// Fold a turn-end `/compact` into the turn's own outcome. A compaction that
/// ran during the turn (`Ok(true)`) stays recorded even when the turn-end
/// `/compact` failed; a failed `/compact` never turns it into `Ok(false)`.
fn with_turn_end_compact(outcome: TurnOutcome, compact_ok: bool) -> TurnOutcome {
outcome.map(|turn_compacted| turn_compacted || compact_ok)
}
/// Pre-turn auto-reset check. If context is large AND the prompt cache has
/// gone cold (idle time >= cache TTL), archive the current session so the
/// next wake-up turn's `--resume <title>` misses and self-heals into a fresh
@ -558,11 +585,11 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
bus.emit(LiveEvent::TurnEnd {
ok: false,
note: Some(format!(
"claude killed after {}s of output silence — API stall suspected, parking + requeueing",
"claude stopped with SIGINT after {}s of output silence — API stall suspected, parking + requeueing",
turn_idle_secs()
)),
});
tracing::warn!("turn killed: API stall (idle watchdog)");
tracing::warn!("turn stopped: API stall (idle watchdog)");
}
Err(TurnError::AgentStall(note)) => {
bus.emit(LiveEvent::TurnEnd {
@ -600,14 +627,7 @@ pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus, session: &AgentSe
bus.set_state(crate::events::TurnState::Compacting);
let config = claude_config(bus, files);
let sink = BusSink::new(bus);
match session.compact(&config, &sink).await {
Ok(()) => bus.emit(LiveEvent::Note {
text: "/compact done".into(),
}),
Err(e) => bus.emit(LiveEvent::Note {
text: format!("/compact failed: {e}"),
}),
}
report_compact(bus, session.compact(&config, &sink).await);
bus.set_state(crate::events::TurnState::Idle);
if let Some(prompt) = request.wake_prompt {
bus.set_post_compact_wake(prompt);
@ -821,8 +841,10 @@ fn archive_session(bus: &Bus, session: &AgentSession) {
#[cfg(test)]
mod tests {
use super::{
PermissionAsk, TurnError, acp_permits, nonzero_or, parse_u64, runtime_error_to_turn,
PermissionAsk, TurnError, acp_permits, nonzero_or, parse_u64, report_compact,
runtime_error_to_turn, with_turn_end_compact,
};
use crate::events::{Bus, LiveEvent};
use hive_runtime::{AcpError, Error};
#[test]
@ -959,4 +981,49 @@ mod tests {
Err(TurnError::Failed(_))
));
}
fn compact_report(result: Result<(), &str>) -> (bool, Vec<String>) {
crate::paths::with_harness_dir(|| {
let bus = Bus::new();
let mut rx = bus.subscribe();
let ok = report_compact(&bus, result);
let mut notes = Vec::new();
while let Ok(ev) = rx.try_recv() {
if let LiveEvent::Note { text } = ev.event {
notes.push(text);
}
}
(ok, notes)
})
}
#[test]
fn a_failed_compact_is_reported_and_not_counted_as_compacted() {
let (compacted, notes) = compact_report(Err("spawn failed"));
assert!(!compacted);
assert_eq!(notes, ["/compact failed: spawn failed"]);
}
#[test]
fn a_successful_compact_is_reported_and_counted_as_compacted() {
let (compacted, notes) = compact_report(Ok(()));
assert!(compacted);
assert_eq!(notes, ["/compact done"]);
}
#[test]
fn a_failed_turn_end_compact_keeps_the_turns_own_compaction() {
assert!(matches!(with_turn_end_compact(Ok(true), false), Ok(true)));
}
#[test]
fn a_failed_turn_end_compact_leaves_a_plain_turn_uncompacted() {
assert!(matches!(with_turn_end_compact(Ok(false), false), Ok(false)));
}
#[test]
fn a_successful_turn_end_compact_marks_the_turn_compacted() {
assert!(matches!(with_turn_end_compact(Ok(false), true), Ok(true)));
assert!(matches!(with_turn_end_compact(Ok(true), true), Ok(true)));
}
}

View file

@ -69,7 +69,7 @@ pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response
.emit(crate::events::LiveEvent::Note { text: note.into() });
return (axum::http::StatusCode::OK, "ok").into_response();
}
let out = super::sigint_claude().await;
let out = super::sigint_claude();
let note = match out {
SigintOutcome::Signalled => {
// A process actually got signalled — the *next* turn's wake

View file

@ -84,7 +84,7 @@ pub(super) async fn post_login_cancel(State(state): State<AppState>) -> Response
/// preservation invariants.
pub(super) async fn post_logout(State(state): State<AppState>) -> Response {
// Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`).
let _ = super::sigint_claude().await;
let _ = super::sigint_claude();
// Step 2: delete OAuth credential files only — login::clear_session owns
// the file set and preserves session-history files alongside them.
let dir = crate::paths::claude_dir();

View file

@ -343,21 +343,40 @@ fn find_claude_child() -> Option<u32> {
/// (best-effort). See [`find_claude_child`] for how it's identified —
/// **not** a name-based `pkill claude`, which would also hit an unrelated
/// `claude` process someone is running interactively in the container.
async fn sigint_claude() -> SigintOutcome {
fn sigint_claude() -> SigintOutcome {
let Some(pid) = find_claude_child() else {
return SigintOutcome::NoProcess;
};
match tokio::process::Command::new("kill")
.args(["-INT", &pid.to_string()])
.status()
.await
{
Ok(status) if status.success() => SigintOutcome::Signalled,
// Non-zero from `kill` means "no such process" (ESRCH) — it already
// exited between the check above and the signal. Same as never
// having found it.
Ok(_) => SigintOutcome::NoProcess,
Err(e) => SigintOutcome::Failed(e),
sigint_turn(pid)
}
/// SIGINT `pid`, and its whole process group when it leads one, so the
/// commands a turn's claude started stop with it. `hive-claude` spawns the
/// turn's claude as the leader of its own group. A `claude` child that
/// leads none (`plugins.rs`'s `claude plugin` calls) shares the harness's
/// group, so it is signalled alone.
fn sigint_turn(pid: u32) -> SigintOutcome {
// A child is never pid 0 or 1, and `-0` / `-1` would reach the
// harness's own group or every process it may signal.
let Some(pid) = i32::try_from(pid).ok().filter(|&pid| pid > 1) else {
return SigintOutcome::NoProcess;
};
// SAFETY: `getpgid(2)` only reads; a gone pid returns -1, never `pid`.
let target = if unsafe { libc::getpgid(pid) } == pid {
-pid
} else {
pid
};
// SAFETY: plain `kill(2)` on the child or the group it leads.
if unsafe { libc::kill(target, libc::SIGINT) } == 0 {
return SigintOutcome::Signalled;
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ESRCH) {
// It exited between the scan and the signal: same as never found.
SigintOutcome::NoProcess
} else {
SigintOutcome::Failed(err)
}
}
@ -425,14 +444,18 @@ fn broker_error_response(err: &BrokerError, action: &str) -> Response {
#[cfg(test)]
mod tests {
use std::os::unix::process::CommandExt as _;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, PoisonError};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use tower::ServiceExt;
use super::{AppState, routes};
use super::{AppState, SigintOutcome, routes, sigint_turn};
use crate::events::Bus;
use crate::login::LoginState;
@ -447,45 +470,127 @@ mod tests {
/// either.
#[test]
fn neither_model_nor_effort_has_a_route() {
let dir = tempfile::tempdir().unwrap();
let _env = crate::paths::HARNESS_DIR_ENV
.lock()
.unwrap_or_else(PoisonError::into_inner);
let prev = std::env::var_os("HYPERHIVE_HARNESS_DIR");
// SAFETY: every test that sets this variable holds `HARNESS_DIR_ENV`.
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
let state = AppState {
label: "test".to_owned(),
login: Arc::new(Mutex::new(LoginState::Online)),
session: Arc::new(Mutex::new(None)),
bus: Bus::new(),
socket: dir.path().join("mcp.sock"),
gui_vnc_port: None,
interrupted: Arc::new(AtomicBool::new(false)),
};
let app = routes().with_state(state);
let (model, effort, compact) = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
let model = app.clone().oneshot(form("/api/model", "model=opus"));
let effort = app.clone().oneshot(form("/api/effort", "effort=high"));
// A route the router does serve, so the 404s above are not
// the router refusing everything.
let compact = app.oneshot(form("/api/compact", ""));
(
model.await.unwrap(),
effort.await.unwrap(),
compact.await.unwrap(),
)
});
match prev {
// SAFETY: as above.
Some(v) => unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", v) },
None => unsafe { std::env::remove_var("HYPERHIVE_HARNESS_DIR") },
}
let (model, effort, compact) = crate::paths::with_harness_dir(|| {
let state = AppState {
label: "test".to_owned(),
login: Arc::new(Mutex::new(LoginState::Online)),
session: Arc::new(Mutex::new(None)),
bus: Bus::new(),
socket: crate::paths::harness_dir().join("mcp.sock"),
gui_vnc_port: None,
interrupted: Arc::new(AtomicBool::new(false)),
};
let app = routes().with_state(state);
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
let model = app.clone().oneshot(form("/api/model", "model=opus"));
let effort = app.clone().oneshot(form("/api/effort", "effort=high"));
// A route the router does serve, so the 404s above are not
// the router refusing everything.
let compact = app.oneshot(form("/api/compact", ""));
(
model.await.unwrap(),
effort.await.unwrap(),
compact.await.unwrap(),
)
})
});
assert_eq!(model.status(), StatusCode::NOT_FOUND);
assert_eq!(effort.status(), StatusCode::NOT_FOUND);
assert_eq!(compact.status(), StatusCode::OK);
}
/// Poll `path` until it holds a complete, newline-terminated pid.
fn read_pid(path: &Path) -> i32 {
for _ in 0..250 {
if let Ok(text) = std::fs::read_to_string(path)
&& text.ends_with('\n')
{
return text.trim().parse().expect("pid file holds a pid");
}
std::thread::sleep(Duration::from_millis(20));
}
unreachable!("no pid in {} within 5s", path.display());
}
/// Whether `pid` is still running. A zombie counts as gone: it has
/// exited, and `kill(pid, 0)` only keeps finding it until it is reaped.
fn is_running(pid: i32) -> bool {
// SAFETY: signal 0 delivers nothing; it only checks that `pid` exists.
if unsafe { libc::kill(pid, 0) } != 0 {
return false;
}
!std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| {
stat.rsplit_once(") ")
.is_some_and(|(_, rest)| rest.starts_with('Z'))
})
}
#[test]
fn a_cancel_stops_the_commands_a_turn_started() {
let dir = tempfile::tempdir().expect("tempdir");
let pidfile = dir.path().join("grandchild.pid");
// A turn's claude as hive-claude spawns it: leading its own group,
// with a command of its own running in the foreground (a
// non-interactive shell starts `&` jobs with SIGINT ignored). The
// trailing `exit` keeps `sh -c` from exec'ing into that command.
let mut turn = Command::new("/bin/sh")
.args([
"-c",
r#"sh -c 'echo $$ >"$1"; exec sleep 300' sh "$1"; exit"#,
"sh",
])
.arg(&pidfile)
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn()
.expect("spawn the stand-in turn");
let grandchild = read_pid(&pidfile);
assert_ne!(
i32::try_from(turn.id()).ok(),
Some(grandchild),
"the command must be a child of the turn, not the turn itself"
);
assert!(matches!(sigint_turn(turn.id()), SigintOutcome::Signalled));
let mut alive = is_running(grandchild);
for _ in 0..250 {
if !alive {
break;
}
std::thread::sleep(Duration::from_millis(20));
alive = is_running(grandchild);
}
if alive {
// SAFETY: `kill(2)` on a pid this test started; keeps a failed
// run from leaving a 300s `sleep` behind.
unsafe {
libc::kill(grandchild, libc::SIGKILL);
}
}
let _ = turn.wait();
assert!(!alive, "grandchild {grandchild} outlived the cancel");
}
#[test]
fn a_claude_sharing_the_harness_group_is_signalled_alone() {
// In this test process's own group, as `claude plugin` runs. A group
// signal here would SIGINT the test binary itself.
let mut child = Command::new("sleep")
.arg("300")
.spawn()
.expect("spawn sleep");
assert!(matches!(sigint_turn(child.id()), SigintOutcome::Signalled));
let status = child.wait().expect("reap sleep");
assert_eq!(
std::os::unix::process::ExitStatusExt::signal(&status),
Some(libc::SIGINT)
);
}
}

View file

@ -169,7 +169,7 @@ async fn proxy_handler(
HeaderName::from_bytes(name.as_str().as_bytes()),
HeaderValue::from_bytes(value.as_bytes()),
) {
resp_headers.insert(n, v);
resp_headers.append(n, v);
}
}
match upstream_resp.bytes().await {
@ -269,8 +269,75 @@ async fn forward_over_unix_socket(
if HOP_BY_HOP.contains(&name.as_str()) {
continue;
}
resp_headers.insert(name.clone(), value.clone());
resp_headers.append(name.clone(), value.clone());
}
let body_bytes = upstream_resp.into_body().collect().await?.to_bytes();
Ok((status, resp_headers, body_bytes).into_response())
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// Raw HTTP/1.1 answer with two `Set-Cookie` headers and a body.
const TWO_COOKIES: &[u8] = b"HTTP/1.1 200 OK\r\n\
Set-Cookie: session=abc\r\n\
Set-Cookie: csrf=def\r\n\
Content-Length: 2\r\n\
Connection: close\r\n\r\nok";
/// Read the request head, then answer with [`TWO_COOKIES`].
async fn answer_once<S: AsyncReadExt + AsyncWriteExt + Unpin>(mut stream: S) {
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf).await;
stream.write_all(TWO_COOKIES).await.unwrap();
stream.shutdown().await.unwrap();
}
fn cookies(resp: &Response) -> Vec<&str> {
resp.headers()
.get_all("set-cookie")
.iter()
.map(|v| v.to_str().unwrap())
.collect()
}
#[tokio::test]
async fn unix_upstream_keeps_repeated_response_headers() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("up.sock");
let listener = tokio::net::UnixListener::bind(&sock).unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
answer_once(stream).await;
});
let resp =
forward_over_unix_socket(&sock, Method::GET, "/", HeaderMap::new(), Bytes::new())
.await
.unwrap();
assert_eq!(cookies(&resp), ["session=abc", "csrf=def"]);
}
#[tokio::test]
async fn http_upstream_keeps_repeated_response_headers() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
answer_once(stream).await;
});
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let resp = proxy_handler(
State((Arc::new(client), Arc::new(base))),
Method::GET,
Uri::from_static("/"),
HeaderMap::new(),
Bytes::new(),
)
.await;
assert_eq!(cookies(&resp), ["session=abc", "csrf=def"]);
}
}