Skip to content

Commit 2e5a96b

Browse files
authored
fix: bound runner disconnect recovery (#10667)
* fix: bound runner disconnect recovery * fix: expose local runner recovery * docs: sync runner command reference
1 parent bfa6fab commit 2e5a96b

10 files changed

Lines changed: 201 additions & 23 deletions

File tree

crates/homeboy-cli/src/commands/runner/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,10 @@ pub(super) enum RunnerCommand {
285285
Disconnect {
286286
/// Runner ID
287287
id: String,
288+
289+
/// Remove only this controller's matching local tunnel/session state without contacting the remote runner
290+
#[arg(long)]
291+
local_recovery: bool,
288292
},
289293
/// Build or select the Homeboy binary used for runner/Lab jobs
290294
RefreshHomeboy {

crates/homeboy-cli/src/commands/runner/dispatch.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ pub fn run(args: RunnerArgs) -> CmdResult<RunnerCommandOutput> {
170170
generations,
171171
full,
172172
} => map_registry(status_mod::status(id.as_deref(), generations, full)),
173-
RunnerCommand::Disconnect { id } => map_registry(registry::disconnect(&id)),
173+
RunnerCommand::Disconnect { id, local_recovery } => {
174+
map_registry(registry::disconnect(&id, local_recovery))
175+
}
174176
RunnerCommand::RefreshHomeboy {
175177
runner_id,
176178
select,

crates/homeboy-cli/src/commands/runner/registry.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,13 +412,17 @@ pub(super) fn connect(id: &str, input: RunnerConnectInput) -> CmdResult<RunnerOu
412412
))
413413
}
414414

415-
pub(super) fn disconnect(id: &str) -> CmdResult<RunnerOutput> {
415+
pub(super) fn disconnect(id: &str, local_recovery: bool) -> CmdResult<RunnerOutput> {
416416
Ok((
417417
RunnerOutput {
418418
command: "runner.disconnect".to_string(),
419419
id: Some(id.to_string()),
420420
extra: RunnerExtra {
421-
connection: Some(RunnerConnectionOutput::Disconnect(runner::disconnect(id)?)),
421+
connection: Some(RunnerConnectionOutput::Disconnect(if local_recovery {
422+
runner::disconnect_local_recovery(id)?
423+
} else {
424+
runner::disconnect(id)?
425+
})),
422426
..Default::default()
423427
},
424428
..Default::default()

crates/homeboy-lab-runner/src/connection.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2734,6 +2734,10 @@ pub fn disconnect(runner_id: &str) -> Result<RunnerDisconnectReport> {
27342734
stop_transport_recovery::disconnect_with_force(runner_id, false)
27352735
}
27362736

2737+
pub fn disconnect_local_recovery(runner_id: &str) -> Result<RunnerDisconnectReport> {
2738+
stop_transport_recovery::disconnect_local_recovery(runner_id)
2739+
}
2740+
27372741
#[cfg(test)]
27382742
mod indexed_inspection_tests {
27392743
#[test]

crates/homeboy-lab-runner/src/connection/session_store.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,13 @@ pub(super) fn remove_session(runner_id: &str) -> Result<()> {
293293
Ok(())
294294
}
295295

296+
/// Delete only the snapshot this controller inspected. A changed session is a
297+
/// new connection and must be left for its owner to manage.
298+
pub(super) fn remove_session_if_matches(runner_id: &str, expected: &RunnerSession) -> Result<bool> {
299+
let path = session_path(runner_id)?;
300+
remove_session_at_if_matches(&path, expected)
301+
}
302+
296303
pub(super) fn remove_ownership(runner_id: &str) -> Result<()> {
297304
let path = ownership_path(runner_id)?;
298305
if path.exists() {
@@ -303,6 +310,24 @@ pub(super) fn remove_ownership(runner_id: &str) -> Result<()> {
303310
Ok(())
304311
}
305312

313+
pub(super) fn remove_ownership_if_matches(
314+
runner_id: &str,
315+
expected: &RunnerSession,
316+
) -> Result<bool> {
317+
let path = ownership_path(runner_id)?;
318+
remove_session_at_if_matches(&path, expected)
319+
}
320+
321+
fn remove_session_at_if_matches(path: &PathBuf, expected: &RunnerSession) -> Result<bool> {
322+
if read_session_at(path)? != Some(expected.clone()) {
323+
return Ok(false);
324+
}
325+
std::fs::remove_file(path).map_err(|err| {
326+
Error::internal_io(err.to_string(), Some(format!("delete {}", path.display())))
327+
})?;
328+
Ok(true)
329+
}
330+
306331
pub(super) fn has_live_peer_session(session: &RunnerSession) -> Result<bool> {
307332
let directory = paths::runner_sessions_dir()?.join(&session.runner_id);
308333
has_live_peer_session_in(&directory, session, session_is_live)
@@ -445,6 +470,21 @@ mod tests {
445470
}
446471
}
447472

473+
#[test]
474+
fn compare_and_delete_retains_a_changed_controller_session() {
475+
let root = TempDir::new().expect("session directory");
476+
let path = root.path().join("controller.json");
477+
let recorded = session("controller", "lease-recorded");
478+
let replacement = session("controller", "lease-reconnected");
479+
write_session_at(&path, &replacement).expect("write replacement session");
480+
481+
assert!(!remove_session_at_if_matches(&path, &recorded).expect("compare session"));
482+
assert_eq!(
483+
read_session_at(&path).expect("read replacement session"),
484+
Some(replacement)
485+
);
486+
}
487+
448488
fn serve_health(
449489
lease_id: &str,
450490
pid: u32,

crates/homeboy-lab-runner/src/connection_stop_transport_recovery.rs

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,66 @@ pub(crate) fn disconnect_with_force(
2222
disconnect_with_session(runner_id, None, force)
2323
}
2424

25+
/// Recover the controller-owned side of a wedged tunnel without observing or
26+
/// mutating the remote daemon. Remote jobs intentionally remain ambiguous.
27+
pub(crate) fn disconnect_local_recovery(runner_id: &str) -> Result<RunnerDisconnectReport> {
28+
let session = read_session(runner_id)?;
29+
let session_path = session_path(runner_id)?.display().to_string();
30+
if let Some(session) = session.as_ref() {
31+
if session.mode == RunnerTunnelMode::DirectSsh {
32+
if let Some(pid) = session.tunnel_pid {
33+
terminate_pid(pid);
34+
}
35+
}
36+
let removed = remove_session_if_matches(runner_id, session)?;
37+
if removed {
38+
let _ = remove_ownership_if_matches(runner_id, session)?;
39+
}
40+
return Ok(RunnerDisconnectReport {
41+
runner_id: runner_id.to_string(),
42+
disconnected: removed,
43+
partial: true,
44+
remote_error: Some(
45+
"remote daemon was not contacted; its jobs and lifecycle remain ambiguous"
46+
.to_string(),
47+
),
48+
local_recovery_command: None,
49+
session: (!removed).then(|| session.clone()),
50+
session_path,
51+
});
52+
}
53+
Ok(RunnerDisconnectReport {
54+
runner_id: runner_id.to_string(),
55+
disconnected: false,
56+
partial: true,
57+
remote_error: Some(
58+
"no controller-local session was present; remote daemon was not contacted".to_string(),
59+
),
60+
local_recovery_command: None,
61+
session: None,
62+
session_path,
63+
})
64+
}
65+
66+
fn partial_disconnect_report(
67+
runner_id: &str,
68+
session: Option<RunnerSession>,
69+
remote_error: impl Into<String>,
70+
) -> Result<RunnerDisconnectReport> {
71+
Ok(RunnerDisconnectReport {
72+
runner_id: runner_id.to_string(),
73+
disconnected: false,
74+
partial: true,
75+
remote_error: Some(remote_error.into()),
76+
local_recovery_command: Some(format!(
77+
"homeboy runner disconnect {} --local-recovery",
78+
shell::quote_arg(runner_id)
79+
)),
80+
session,
81+
session_path: session_path(runner_id)?.display().to_string(),
82+
})
83+
}
84+
2585
/// Stop the daemon through the current live session after confirming it still
2686
/// owns the remote daemon observed by a caller's promotion transaction.
2787
pub(crate) fn disconnect_with_session(
@@ -66,8 +126,14 @@ pub(crate) fn disconnect_with_session(
66126
// SSH and clean up stale local tunnel processes only after its stop.
67127
let retained_generations =
68128
super::super::generation_store::live_sessions(runner_id, Some(session))?;
129+
let authoritative_status = match probe_authoritative_daemon_status(runner_id) {
130+
Ok(status) => status,
131+
Err(error) => {
132+
return partial_disconnect_report(runner_id, session.clone().into(), error.message)
133+
}
134+
};
69135
if remote_daemon::authoritative_stale_generations_are_dead(
70-
&probe_authoritative_daemon_status(runner_id)?,
136+
&authoritative_status,
71137
&eligible_stale_generation_leases(&retained_generations).unwrap_or_default(),
72138
) {
73139
let leases =
@@ -78,13 +144,23 @@ pub(crate) fn disconnect_with_session(
78144
return Ok(RunnerDisconnectReport {
79145
runner_id: runner_id.to_string(),
80146
disconnected: true,
147+
partial: false,
148+
remote_error: None,
149+
local_recovery_command: None,
81150
session: None,
82151
session_path: session_path(runner_id)?.display().to_string(),
83152
});
84153
}
85-
if let Some(authoritative_session) =
86-
reconcile_authoritative_idle_stale_generations(runner_id, &retained_generations)?
87-
{
154+
let authoritative_session = match reconcile_authoritative_idle_stale_generations(
155+
runner_id,
156+
&retained_generations,
157+
) {
158+
Ok(session) => session,
159+
Err(error) => {
160+
return partial_disconnect_report(runner_id, session.clone().into(), error.message)
161+
}
162+
};
163+
if let Some(authoritative_session) = authoritative_session {
88164
*session = authoritative_session.clone();
89165
}
90166
let mut reconciled_tunnel_pids = retained_generations
@@ -115,12 +191,18 @@ pub(crate) fn disconnect_with_session(
115191
}
116192
}
117193
if !unresolved.is_empty() {
118-
return Err(Error::validation_invalid_argument(
119-
"disconnect",
120-
format!("runner `{runner_id}` has unresolved daemon generations; sessions and ledger were retained"),
121-
Some(runner_id.to_string()),
122-
Some(unresolved.into_iter().map(|entry| entry.to_string()).collect()),
123-
));
194+
return partial_disconnect_report(
195+
runner_id,
196+
session.clone().into(),
197+
format!(
198+
"remote daemon stop was not proven; sessions and ledger were retained: {}",
199+
unresolved
200+
.into_iter()
201+
.map(|entry| entry.to_string())
202+
.collect::<Vec<_>>()
203+
.join(", ")
204+
),
205+
);
124206
}
125207
for pid in reconciled_tunnel_pids {
126208
terminate_pid(pid);
@@ -136,12 +218,17 @@ pub(crate) fn disconnect_with_session(
136218
// new generation merely to clean that already-dead inventory.
137219
let retained_generations = super::super::generation_store::live_sessions(runner_id, None)?;
138220
let leases = eligible_stale_generation_leases(&retained_generations).unwrap_or_default();
139-
if !leases.is_empty()
140-
&& remote_daemon::authoritative_stale_generations_are_dead(
141-
&probe_authoritative_daemon_status(runner_id)?,
142-
&leases,
143-
)
144-
{
221+
let authoritative_status = if leases.is_empty() {
222+
None
223+
} else {
224+
match probe_authoritative_daemon_status(runner_id) {
225+
Ok(status) => Some(status),
226+
Err(error) => return partial_disconnect_report(runner_id, None, error.message),
227+
}
228+
};
229+
if authoritative_status.as_ref().is_some_and(|status| {
230+
remote_daemon::authoritative_stale_generations_are_dead(status, &leases)
231+
}) {
145232
super::super::generation_store::tombstone_dead_direct_generations(runner_id, &leases)?;
146233
remove_ownership(runner_id)?;
147234
}
@@ -150,6 +237,9 @@ pub(crate) fn disconnect_with_session(
150237
Ok(RunnerDisconnectReport {
151238
runner_id: runner_id.to_string(),
152239
disconnected: session.is_some(),
240+
partial: false,
241+
remote_error: None,
242+
local_recovery_command: None,
153243
session,
154244
session_path: session_path(runner_id)?.display().to_string(),
155245
})
@@ -880,6 +970,29 @@ mod tests {
880970
);
881971
}
882972

973+
#[test]
974+
fn local_recovery_removes_only_the_controller_session_without_remote_access() {
975+
homeboy_core::test_support::with_isolated_home(|_| {
976+
let mut session = direct_ssh_session("lease-wedged");
977+
session.tunnel_pid = None;
978+
write_session(&session).expect("record controller session");
979+
write_ownership(&session).expect("record ownership");
980+
981+
let report = disconnect_local_recovery("homeboy-lab").expect("local recovery");
982+
983+
assert!(report.disconnected);
984+
assert!(report.partial);
985+
assert!(report
986+
.remote_error
987+
.expect("ambiguity is explicit")
988+
.contains("not contacted"));
989+
assert!(read_session("homeboy-lab").expect("read session").is_none());
990+
assert!(read_ownership("homeboy-lab")
991+
.expect("read ownership")
992+
.is_none());
993+
});
994+
}
995+
883996
#[test]
884997
fn foreign_loopback_html_is_identity_mismatch_and_never_receives_stop() {
885998
let listener = TcpListener::bind("127.0.0.1:0").expect("foreign listener");

crates/homeboy-lab-runner/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,8 @@ pub(crate) use connection::daemon_endpoint_identity;
137137
pub(crate) use connection::disconnect_with_force;
138138
pub use connection::{
139139
close_reconnected_job_log_owner, connect, connect_reverse, connect_with_live_lease_adoption,
140-
connect_with_orphan_adoption, disconnect, persisted_status, persisted_statuses,
141-
reconcile_terminal_jobs, reconnect_job_log_owner, reverse_broker_artifact,
140+
connect_with_orphan_adoption, disconnect, disconnect_local_recovery, persisted_status,
141+
persisted_statuses, reconcile_terminal_jobs, reconnect_job_log_owner, reverse_broker_artifact,
142142
reverse_broker_artifact_content, reverse_broker_reconcile, runner_artifact_content, status,
143143
statuses, statuses_indexed, submit_reverse_broker_job,
144144
};

crates/homeboy-lab-runner/src/runners.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pub use crate::{
2626
BROKER_TOKEN_ENV, BROKER_TOKEN_HEADER,
2727
};
2828
pub use crate::{
29-
connect_reverse, disconnect, download_remote_artifact,
29+
connect_reverse, disconnect, disconnect_local_recovery, download_remote_artifact,
3030
evaluate_lab_runner_capabilities_for_runner, exec, execute_lab_offload,
3131
hydrate_prepared_workspace_source_snapshot, is_remote_runner_artifact_path,
3232
is_reportable_artifact_evidence_path, is_retrievable_runner_artifact,

crates/homeboy-lab-runner/src/session.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,13 @@ fn same_homeboy_version(left: &str, right: &str) -> bool {
11411141
pub struct RunnerDisconnectReport {
11421142
pub runner_id: String,
11431143
pub disconnected: bool,
1144+
/// The remote daemon could not be authoritatively stopped. Its jobs and
1145+
/// lifecycle remain ambiguous, while the controller session is retained.
1146+
pub partial: bool,
1147+
#[serde(skip_serializing_if = "Option::is_none")]
1148+
pub remote_error: Option<String>,
1149+
#[serde(skip_serializing_if = "Option::is_none")]
1150+
pub local_recovery_command: Option<String>,
11441151
#[serde(skip_serializing_if = "Option::is_none")]
11451152
pub session: Option<RunnerSession>,
11461153
pub session_path: String,

docs/reference/cli/commands/runner.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ Show persisted runner tunnel status
253253
## `homeboy runner disconnect`
254254

255255
```sh
256-
homeboy runner disconnect <ID>
256+
homeboy runner disconnect [OPTIONS] <ID>
257257
```
258258

259259
Close a runner tunnel and remove its persisted session state
@@ -262,6 +262,10 @@ Close a runner tunnel and remove its persisted session state
262262
| --- | --- | --- |
263263
| `<ID>` | yes | Runner ID |
264264

265+
| Option | Value | Description |
266+
| --- | --- | --- |
267+
| `--local-recovery` | flag | Remove only this controller's matching local tunnel/session state without contacting the remote runner |
268+
265269
## `homeboy runner refresh-homeboy`
266270

267271
```sh

0 commit comments

Comments
 (0)