Skip to content

Commit 9739471

Browse files
fix(lab): project a terminal reverse result after the worker exits (#10659) (#10688)
* fix(lab): project a terminal reverse result after the worker exits A detached Cook's reverse worker exits the moment it publishes its terminal result to the broker, so its controller-session heartbeat necessarily ages out past REVERSE_RUNNER_HEARTBEAT_TTL. daemon_api_request gated every reverse read on that liveness, so runner_job_log_snapshot returned "runner is not connected to a daemon", agent-task reconciliation annotated the run runner_disconnected, and the durable parent could never reach a terminal state -- the exact assertion detached_cook_accepts_reverse_capacity_queue_and_worker_completes_once makes. The broker is a controller-reachable durable rendezvous, not the runner machine. Serve reverse broker reads from a recorded session that names its broker endpoint. Mutations still assert an intent a live worker has to honor, and every direct-daemon transport reads the runner machine itself, so both keep the liveness gate. The acceptance fixture hid this by pinning last_seen_at five minutes in the future, which reverse_controller_session_is_live accepts because a negative age fails Duration::try_from and falls open. That gave the test a fixed ~390s liveness window while the test itself runs 430-590s, so the outcome flipped on where that wall-clock cliff landed. Beat the session honestly while the fixture worker is connected, then record the expired session a departed worker actually leaves behind before asserting terminal projection. Refs #10659 * test(lab): keep reverse status reads on the fixture SSH shim An expired reverse session makes `runner status` fall through to its SSH recovery probe. The acceptance fixture already ships an `ssh` shim for exactly that reason, but the `agent-task status` reads inherited the ambient PATH, so a real `ssh` to reverse-fixture.invalid could escape the hermetic context and stall the read. Refs #10659 --------- Co-authored-by: chubes-bot <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent c71ae5a commit 9739471

2 files changed

Lines changed: 195 additions & 10 deletions

File tree

crates/homeboy-lab-runner/src/execution/daemon_api.rs

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,36 @@ pub fn daemon_api_post(runner_id: &str, path: &str) -> Result<Value> {
130130
daemon_api_request(runner_id, path, "POST")
131131
}
132132

133+
/// A reverse runner's broker is a controller-reachable durable rendezvous, not
134+
/// the runner machine. A detached Cook's worker exits as soon as it publishes
135+
/// its terminal result, so its controller-session heartbeat necessarily ages
136+
/// out past `REVERSE_RUNNER_HEARTBEAT_TTL` — but the result it already wrote to
137+
/// the broker stays readable and must stay projectable
138+
/// (Extra-Chill/homeboy#10659). Observation of durable broker state therefore
139+
/// requires only a recorded reverse session that names its broker endpoint.
140+
/// Mutations still assert an intent that a live worker has to honor, so they
141+
/// keep the liveness gate, as does every direct-daemon transport.
142+
fn reverse_broker_read_without_live_worker(session: Option<&RunnerSession>, method: &str) -> bool {
143+
method == "GET"
144+
&& session.is_some_and(|session| {
145+
session.mode == RunnerTunnelMode::Reverse
146+
&& session.local_url.is_none()
147+
&& session
148+
.broker_url
149+
.as_deref()
150+
.is_some_and(|url| !url.trim().is_empty())
151+
})
152+
}
153+
133154
pub(super) fn daemon_api_request(runner_id: &str, path: &str, method: &str) -> Result<Value> {
134155
let runner = load(runner_id)?;
135156
let connected = status(runner_id)?;
136-
let Some(legacy_session) = connected.session.filter(|_| connected.connected) else {
157+
let recorded_broker_read =
158+
reverse_broker_read_without_live_worker(connected.session.as_ref(), method);
159+
let Some(legacy_session) = connected
160+
.session
161+
.filter(|_| connected.connected || recorded_broker_read)
162+
else {
137163
return Err(Error::validation_invalid_argument(
138164
"runner",
139165
"runner is not connected to a daemon; run `homeboy runner connect <runner-id>` first",
@@ -143,7 +169,14 @@ pub(super) fn daemon_api_request(runner_id: &str, path: &str, method: &str) -> R
143169
]),
144170
));
145171
};
146-
let session = daemon_api_session_for_path(runner_id, path, legacy_session)?;
172+
// Generation routing resolves which *live* direct-daemon endpoint owns a
173+
// job. A recorded-only reverse session has no generation to reconcile, so
174+
// read it through the broker endpoint it recorded.
175+
let session = if connected.connected {
176+
daemon_api_session_for_path(runner_id, path, legacy_session)?
177+
} else {
178+
legacy_session
179+
};
147180
let client = Client::builder()
148181
.no_proxy()
149182
.timeout(Duration::from_secs(10))
@@ -289,6 +322,67 @@ mod tests {
289322
let body = canonical_daemon_body(&data, "reverse broker job").expect("canonical body");
290323
assert_eq!(body["job"]["id"], "job-1");
291324
}
325+
326+
fn reverse_session() -> RunnerSession {
327+
let mut session = session("lease-reverse", "unused");
328+
session.mode = RunnerTunnelMode::Reverse;
329+
session.broker_url = Some("http://127.0.0.1:9000".to_string());
330+
session.local_url = None;
331+
session.local_port = None;
332+
session.remote_daemon_address = None;
333+
session
334+
}
335+
336+
#[test]
337+
fn recorded_reverse_session_still_serves_broker_reads_after_its_worker_exits() {
338+
// A detached Cook's worker exits the moment it publishes its terminal
339+
// result, so its session heartbeat is always stale by the time the
340+
// controller projects. The broker is durable and controller-reachable,
341+
// so the read must not depend on worker liveness
342+
// (Extra-Chill/homeboy#10659).
343+
assert!(reverse_broker_read_without_live_worker(
344+
Some(&reverse_session()),
345+
"GET"
346+
));
347+
}
348+
349+
#[test]
350+
fn recorded_reverse_session_still_gates_mutations_and_direct_transports() {
351+
// A mutation asserts an intent a live worker has to honor.
352+
assert!(!reverse_broker_read_without_live_worker(
353+
Some(&reverse_session()),
354+
"POST"
355+
));
356+
// Direct-SSH transport reads the runner machine itself, so liveness
357+
// remains the correct gate.
358+
assert!(!reverse_broker_read_without_live_worker(
359+
Some(&session("lease-a", "daemon-a")),
360+
"GET"
361+
));
362+
// A reverse session that never recorded a broker endpoint has nothing
363+
// durable to read.
364+
let mut without_broker = reverse_session();
365+
without_broker.broker_url = None;
366+
assert!(!reverse_broker_read_without_live_worker(
367+
Some(&without_broker),
368+
"GET"
369+
));
370+
let mut blank_broker = reverse_session();
371+
blank_broker.broker_url = Some(" ".to_string());
372+
assert!(!reverse_broker_read_without_live_worker(
373+
Some(&blank_broker),
374+
"GET"
375+
));
376+
// A reverse session pinned to a local daemon endpoint would route
377+
// direct, and that endpoint really is dead once the worker exits.
378+
let mut with_local = reverse_session();
379+
with_local.local_url = Some("http://127.0.0.1:4100".to_string());
380+
assert!(!reverse_broker_read_without_live_worker(
381+
Some(&with_local),
382+
"GET"
383+
));
384+
assert!(!reverse_broker_read_without_live_worker(None, "GET"));
385+
}
292386
}
293387

294388
pub(super) fn daemon_post(client: &Client, local_url: &str, path: &str) -> Result<Value> {

tests/reverse_cook_queue_acceptance.rs

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,90 @@
1+
use std::path::{Path, PathBuf};
12
use std::process::{Command, Stdio};
3+
use std::sync::atomic::{AtomicBool, Ordering};
4+
use std::sync::Arc;
25
use std::time::{Duration, Instant};
36

47
use homeboy::core::api_jobs::{JobEventKind, JobStatus};
58
use homeboy_core::test_support::{HermeticTestContext, ReverseBrokerFixture, TestBinary};
69

10+
/// A live reverse worker republishes its controller session heartbeat while it
11+
/// is connected; the recorded `last_seen_at` is only as old as its last beat.
12+
/// The fixture used to fake that with a single timestamp five minutes in the
13+
/// future, which `reverse_controller_session_is_live` accepts because a
14+
/// negative age fails `Duration::try_from` and falls open. That gave the test a
15+
/// fixed ~390 s liveness window (300 s of future skew plus the 90 s heartbeat
16+
/// TTL) while the test itself runs 430–590 s, so whether it passed depended on
17+
/// where that wall-clock cliff landed. Beat the session honestly instead.
18+
struct ReverseSessionHeartbeat {
19+
path: PathBuf,
20+
session: serde_json::Value,
21+
stop: Arc<AtomicBool>,
22+
handle: Option<std::thread::JoinHandle<()>>,
23+
}
24+
25+
impl ReverseSessionHeartbeat {
26+
fn start(path: &Path, mut session: serde_json::Value) -> Self {
27+
let stop = Arc::new(AtomicBool::new(false));
28+
session["last_seen_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
29+
Self::write(path, &session);
30+
let handle = {
31+
let path = path.to_path_buf();
32+
let session = session.clone();
33+
let stop = stop.clone();
34+
std::thread::spawn(move || {
35+
while !stop.load(Ordering::SeqCst) {
36+
std::thread::sleep(Duration::from_millis(500));
37+
if stop.load(Ordering::SeqCst) {
38+
break;
39+
}
40+
let mut beat = session.clone();
41+
beat["last_seen_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
42+
Self::write(&path, &beat);
43+
}
44+
})
45+
};
46+
Self {
47+
path: path.to_path_buf(),
48+
session,
49+
stop,
50+
handle: Some(handle),
51+
}
52+
}
53+
54+
/// Stop beating and record the session a worker that has already exited
55+
/// leaves behind: a real `last_seen_at` older than the reverse heartbeat
56+
/// TTL. The controller must still project the terminal result the worker
57+
/// published to the broker before it exited.
58+
fn expire(&mut self) {
59+
self.stop.store(true, Ordering::SeqCst);
60+
if let Some(handle) = self.handle.take() {
61+
handle.join().expect("reverse session heartbeat thread");
62+
}
63+
let mut expired = self.session.clone();
64+
expired["last_seen_at"] =
65+
serde_json::json!((chrono::Utc::now() - chrono::Duration::minutes(5)).to_rfc3339());
66+
Self::write(&self.path, &expired);
67+
}
68+
69+
/// Publish through a same-directory rename. A truncating rewrite would let
70+
/// a controller read observe an empty session file mid-beat and report the
71+
/// runner disconnected for reasons that have nothing to do with liveness.
72+
fn write(path: &Path, session: &serde_json::Value) {
73+
let staged = path.with_extension("beat");
74+
std::fs::write(&staged, session.to_string()).expect("stage reverse controller session");
75+
std::fs::rename(&staged, path).expect("publish reverse controller session");
76+
}
77+
}
78+
79+
impl Drop for ReverseSessionHeartbeat {
80+
fn drop(&mut self) {
81+
self.stop.store(true, Ordering::SeqCst);
82+
if let Some(handle) = self.handle.take() {
83+
let _ = handle.join();
84+
}
85+
}
86+
}
87+
788
fn output(command: &mut Command) -> std::process::Output {
889
let output = command.output().expect("run homeboy fixture command");
990
assert!(
@@ -163,8 +244,10 @@ fn detached_cook_accepts_reverse_capacity_queue_and_worker_completes_once() {
163244
.join("runner-sessions/lab/fixture-controller.json");
164245
std::fs::create_dir_all(session_path.parent().expect("session parent"))
165246
.expect("create session directory");
166-
std::fs::write(
167-
session_path,
247+
// Beat the session for as long as the fixture worker is "connected", the
248+
// way `homeboy runner work` does, instead of pinning one future timestamp.
249+
let mut session_heartbeat = ReverseSessionHeartbeat::start(
250+
&session_path,
168251
serde_json::json!({
169252
"runner_id": "lab",
170253
"mode": "reverse",
@@ -179,15 +262,12 @@ fn detached_cook_accepts_reverse_capacity_queue_and_worker_completes_once() {
179262
"connected_at": "2026-01-01T00:00:00Z",
180263
"worker_identity": "fixture-worker",
181264
"worker_pid": 1,
182-
"last_seen_at": (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339()
183-
})
184-
.to_string(),
185-
)
186-
.expect("write reverse controller session");
265+
}),
266+
);
187267

188268
let mut cook_command = context.command(TestBinary::HomeboyFixture);
189269
cook_command
190-
.env("PATH", path)
270+
.env("PATH", &path)
191271
.env("HOMEBOY_CONTROLLER_ID", "fixture-controller")
192272
.args([
193273
"--runner",
@@ -249,6 +329,7 @@ fn detached_cook_accepts_reverse_capacity_queue_and_worker_completes_once() {
249329
let run_id = accepted["latest_run_id"].as_str().unwrap_or("unknown");
250330
let status = context
251331
.command(TestBinary::HomeboyFixture)
332+
.env("PATH", &path)
252333
.args(["agent-task", "status", run_id])
253334
.output()
254335
.expect("inspect stalled controller parent");
@@ -366,6 +447,12 @@ fn detached_cook_accepts_reverse_capacity_queue_and_worker_completes_once() {
366447
})
367448
.expect("duplicate worker wake");
368449
assert_eq!(duplicate_code, 0);
450+
// Both worker waves have returned, so the reverse worker is gone and its
451+
// controller-session heartbeat stops. That is the steady state of a
452+
// detached Cook, not an edge case: the worker exits the moment it publishes
453+
// its terminal result. Record it explicitly so terminal projection is
454+
// proven against a genuinely expired session instead of racing one.
455+
session_heartbeat.expire();
369456
// The controller must project the broker result after the worker exits.
370457
// `daemon serve` is intentionally un-tokenized, so terminate the test-owned
371458
// foreground child only after that durable parent lifecycle is terminal.
@@ -374,6 +461,10 @@ fn detached_cook_accepts_reverse_capacity_queue_and_worker_completes_once() {
374461
let terminal = loop {
375462
let status = context
376463
.command(TestBinary::HomeboyFixture)
464+
// A recorded-only reverse session makes `runner status` reach for
465+
// its SSH recovery probe. Keep that on the fixture shim rather than
466+
// letting a real `ssh` escape the hermetic context.
467+
.env("PATH", &path)
377468
.args(["agent-task", "status", run_id])
378469
.output()
379470
.expect("read terminal parent status");

0 commit comments

Comments
 (0)