Skip to content

Commit 5a381bc

Browse files
Merge pull request #479 from spa5k/feat/corrupt-branch-auto-repair
fix: auto-repair corrupt derived branch stores
2 parents 8339371 + c6fddf3 commit 5a381bc

10 files changed

Lines changed: 544 additions & 29 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tracedecay": patch
3+
---
4+
5+
Preserve corrupt derived-branch database families under the project recovery directory and rebuild the active branch index from a healthy tracked ancestor, while keeping default stores fail-closed, correcting branch-specific doctor recovery paths, and reloading rotated daemon credentials when long-lived clients reconnect after a restart.

src/daemon.rs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -917,9 +917,9 @@ fn is_transient_daemon_connect_error(kind: std::io::ErrorKind) -> bool {
917917
)
918918
}
919919

920-
async fn connect_to_daemon_connection(connection: &DaemonConnection) -> Result<BrokerStream> {
921-
connect_with_restart_grace(
922-
connection,
920+
async fn connect_to_current_daemon(socket_path: &Path) -> Result<(DaemonConnection, BrokerStream)> {
921+
connect_with_restart_grace_resolving(
922+
|| client_connection(socket_path),
923923
DAEMON_RESTART_GRACE,
924924
DAEMON_RESTART_POLL_INTERVAL,
925925
)
@@ -935,10 +935,27 @@ async fn connect_with_restart_grace(
935935
grace: Duration,
936936
poll_interval: Duration,
937937
) -> Result<BrokerStream> {
938+
let (_, stream) =
939+
connect_with_restart_grace_resolving(|| Ok(connection.clone()), grace, poll_interval)
940+
.await?;
941+
Ok(stream)
942+
}
943+
944+
/// Resolves the current authority record on every connect attempt.
945+
///
946+
/// A daemon restart replaces both its endpoint authority epoch and auth token.
947+
/// Resolving only before the restart window would connect to the new socket
948+
/// with stale credentials after it rebinds.
949+
async fn connect_with_restart_grace_resolving(
950+
mut resolve: impl FnMut() -> Result<DaemonConnection>,
951+
grace: Duration,
952+
poll_interval: Duration,
953+
) -> Result<(DaemonConnection, BrokerStream)> {
938954
let deadline = tokio::time::Instant::now() + grace;
939955
loop {
956+
let connection = resolve()?;
940957
match BrokerStream::connect(&connection.endpoint).await {
941-
Ok(stream) => return Ok(stream),
958+
Ok(stream) => return Ok((connection, stream)),
942959
Err(TraceDecayError::Io(err)) => {
943960
if !is_transient_daemon_connect_error(err.kind())
944961
|| tokio::time::Instant::now() >= deadline
@@ -1291,8 +1308,7 @@ async fn send_daemon_request_line_with_liveness_poll(
12911308
line: &str,
12921309
liveness_poll_interval: Duration,
12931310
) -> Result<Vec<String>> {
1294-
let connection = client_connection(socket_path)?;
1295-
let stream = connect_to_daemon_connection(&connection).await?;
1311+
let (connection, stream) = connect_to_current_daemon(socket_path).await?;
12961312
let (reader, mut writer) = stream.into_split();
12971313

12981314
write_daemon_preamble(&mut writer, &connection, handshake).await?;
@@ -1490,8 +1506,7 @@ async fn call_tool_with_liveness_poll(
14901506
arguments: serde_json::Value,
14911507
liveness_poll_interval: Duration,
14921508
) -> Result<serde_json::Value> {
1493-
let connection = client_connection(socket_path)?;
1494-
let stream = connect_to_daemon_connection(&connection).await?;
1509+
let (connection, stream) = connect_to_current_daemon(socket_path).await?;
14951510
let (reader, mut writer) = stream.into_split();
14961511
let id = json!(1);
14971512
let request = JsonRpcRequest {

src/daemon/service.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,9 +1078,9 @@ mod tests {
10781078
#[cfg(unix)]
10791079
use tempfile::TempDir;
10801080

1081-
use super::{
1082-
DaemonServiceSpec, DaemonServiceState, LaunchctlFailureMode, LaunchdCommand, ServiceRunner,
1083-
};
1081+
use super::{DaemonServiceSpec, LaunchctlFailureMode, LaunchdCommand};
1082+
#[cfg(target_os = "linux")]
1083+
use super::{DaemonServiceState, ServiceRunner};
10841084
use crate::config::lock_user_data_dir_test_env;
10851085

10861086
struct EnvVarGuard {

src/daemon/tests.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,55 @@ async fn answer_one_proxy_request(listener: tokio::net::UnixListener, generation
729729
writer.shutdown().await.expect("shutdown fake daemon");
730730
}
731731

732+
#[cfg(unix)]
733+
async fn answer_one_authenticated_proxy_request(
734+
listener: tokio::net::UnixListener,
735+
expected_token: &str,
736+
generation: u64,
737+
) {
738+
let (stream, _addr) = listener.accept().await.expect("accept proxied client");
739+
let (reader, mut writer) = stream.into_split();
740+
let mut lines = tokio::io::BufReader::new(reader).lines();
741+
let auth_line = lines
742+
.next_line()
743+
.await
744+
.expect("read auth preface")
745+
.expect("auth preface line");
746+
let preface =
747+
super::transport::DaemonAuthPreface::from_line(auth_line.trim()).expect("auth preface");
748+
assert!(
749+
preface.authenticate(expected_token),
750+
"proxy must reload the current daemon authority token"
751+
);
752+
let handshake_line = lines
753+
.next_line()
754+
.await
755+
.expect("read handshake")
756+
.expect("handshake line");
757+
DaemonHandshake::from_line(&handshake_line).expect("parse handshake");
758+
let request_line = lines
759+
.next_line()
760+
.await
761+
.expect("read request")
762+
.expect("request line");
763+
let request: Value = serde_json::from_str(&request_line).expect("request json");
764+
let response = json!({
765+
"jsonrpc": "2.0",
766+
"id": request["id"],
767+
"result": { "generation": generation }
768+
});
769+
writer
770+
.write_all(
771+
serde_json::to_string(&response)
772+
.expect("response json")
773+
.as_bytes(),
774+
)
775+
.await
776+
.expect("write response");
777+
writer.write_all(b"\n").await.expect("write newline");
778+
writer.shutdown().await.expect("shutdown fake daemon");
779+
}
780+
732781
#[cfg(unix)]
733782
async fn daemon_round_trip(
734783
engine: super::DaemonEngine,
@@ -1306,6 +1355,86 @@ async fn long_lived_proxy_reconnects_after_daemon_socket_rebind() {
13061355
await_test_task(daemon, "daemon rebind task").await;
13071356
}
13081357

1358+
#[cfg(unix)]
1359+
#[tokio::test]
1360+
async fn long_lived_proxy_reloads_rotated_auth_after_daemon_restart() {
1361+
let dir = TempDir::new().expect("temp dir");
1362+
let profile = dir.path().canonicalize().expect("canonical profile");
1363+
let socket = profile.join("daemon.sock");
1364+
let endpoint = super::transport::DaemonEndpoint::Unix(socket.clone());
1365+
let first_listener = tokio::net::UnixListener::bind(&socket).expect("bind first daemon socket");
1366+
let first_authority = super::authority::DaemonAuthority::acquire(&profile, &endpoint, "first")
1367+
.expect("first daemon authority");
1368+
let first_token = first_authority.auth_token().to_string();
1369+
let rebound_socket = socket.clone();
1370+
let rebound_profile = profile.clone();
1371+
let rebound_endpoint = endpoint.clone();
1372+
let (unbound_tx, unbound_rx) = tokio::sync::oneshot::channel();
1373+
let daemon = tokio::spawn(async move {
1374+
answer_one_authenticated_proxy_request(first_listener, &first_token, 1).await;
1375+
drop(first_authority);
1376+
std::fs::remove_file(&rebound_socket).expect("unlink first daemon socket");
1377+
unbound_tx.send(()).expect("notify daemon outage");
1378+
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1379+
1380+
let second_listener =
1381+
tokio::net::UnixListener::bind(&rebound_socket).expect("bind second daemon socket");
1382+
let second_authority = super::authority::DaemonAuthority::acquire(
1383+
&rebound_profile,
1384+
&rebound_endpoint,
1385+
"second",
1386+
)
1387+
.expect("second daemon authority");
1388+
let second_token = second_authority.auth_token().to_string();
1389+
assert_ne!(first_token, second_token, "daemon restart must rotate auth");
1390+
answer_one_authenticated_proxy_request(second_listener, &second_token, 2).await;
1391+
drop(second_authority);
1392+
});
1393+
1394+
let (mut transport, sender, mut receiver) = crate::mcp::transport::ChannelTransport::new();
1395+
let proxy_socket = socket.clone();
1396+
let proxy = tokio::spawn(async move {
1397+
super::proxy_transport_to_daemon(
1398+
&proxy_socket,
1399+
&test_handshake_defaults(),
1400+
None,
1401+
&mut transport,
1402+
)
1403+
.await
1404+
});
1405+
let request = |id| {
1406+
serde_json::to_string(&json!({
1407+
"jsonrpc": "2.0",
1408+
"id": id,
1409+
"method": "tools/list"
1410+
}))
1411+
.expect("request json")
1412+
};
1413+
1414+
sender.send(request(1)).expect("send first request");
1415+
let first = tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv())
1416+
.await
1417+
.expect("first response timed out")
1418+
.expect("first response");
1419+
let first: Value = serde_json::from_str(first.trim()).expect("first response json");
1420+
assert_eq!(first["result"]["generation"], json!(1));
1421+
1422+
unbound_rx.await.expect("first daemon should unlink socket");
1423+
sender.send(request(2)).expect("send second request");
1424+
let second = tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv())
1425+
.await
1426+
.expect("second response timed out")
1427+
.expect("second response");
1428+
let second: Value = serde_json::from_str(second.trim()).expect("second response json");
1429+
assert_eq!(second["result"]["generation"], json!(2));
1430+
1431+
drop(sender);
1432+
await_test_task(proxy, "rotating-auth proxy task")
1433+
.await
1434+
.expect("proxy transport");
1435+
await_test_task(daemon, "rotating-auth daemon task").await;
1436+
}
1437+
13091438
#[cfg(unix)]
13101439
#[tokio::test]
13111440
async fn proxy_uses_daemon_initialize_route_without_registry_access() {

src/doctor.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,12 @@ fn fallback_database_path(project_path: &Path) -> Option<PathBuf> {
375375
fn database_recovery_guidance(db_path: &Path) -> String {
376376
let wal_path = db_path.with_extension("db-wal");
377377
let shm_path = db_path.with_extension("db-shm");
378-
let data_root = db_path.parent().unwrap_or_else(|| Path::new("."));
378+
let graph_parent = db_path.parent().unwrap_or_else(|| Path::new("."));
379+
let data_root = if graph_parent.file_name() == Some(std::ffi::OsStr::new("branches")) {
380+
graph_parent.parent().unwrap_or(graph_parent)
381+
} else {
382+
graph_parent
383+
};
379384
let mut graph_dirty = db_path.as_os_str().to_os_string();
380385
graph_dirty.push(".dirty");
381386
let graph_dirty = PathBuf::from(graph_dirty);
@@ -391,7 +396,8 @@ fn database_recovery_guidance(db_path: &Path) -> String {
391396
graph dirty sentinel: {}\n\
392397
legacy dirty sentinel (if present): {}\n\
393398
`sessions.db` is separate and must not be removed: {}\n\
394-
Facts are stored in the graph database; automatic rebuild is intentionally blocked because it cannot preserve them generically.\n\
399+
Facts are stored in the graph database; automatic default-store rebuild is intentionally blocked because it cannot preserve them generically.\n\
400+
Derived branch indexes are preserved under `recovery/` and rebuilt automatically from a healthy tracked ancestor.\n\
395401
Do not run `tracedecay init`, `tracedecay sync --force`, or `tracedecay wipe` until that recovery set is safely copied.\n\
396402
Report the preserved set at https://github.com/ScriptedAlchemy/tracedecay/issues for offline recovery.",
397403
db_path.display(),

src/doctor/tests.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,16 @@ fn database_recovery_guidance_names_the_preserved_recovery_set() {
236236
);
237237
assert!(guidance.contains("`sessions.db` is separate and must not be removed"));
238238
assert!(guidance.contains("Facts are stored in the graph database"));
239-
assert!(guidance.contains("automatic rebuild is intentionally blocked"));
239+
assert!(guidance.contains("automatic default-store rebuild is intentionally blocked"));
240+
assert!(guidance.contains("Derived branch indexes are preserved"));
241+
242+
let branch_db = PathBuf::from("/profile/projects/proj_test/branches/feature.db");
243+
let branch_guidance = database_recovery_guidance(&branch_db);
244+
let branches_root = branch_db.parent().unwrap();
245+
let data_root = branches_root.parent().unwrap();
246+
assert!(branch_guidance.contains(&data_root.join("dirty").display().to_string()));
247+
assert!(branch_guidance.contains(&data_root.join("sessions.db").display().to_string()));
248+
assert!(!branch_guidance.contains(&branches_root.join("sessions.db").display().to_string()));
240249
}
241250

242251
#[tokio::test]

0 commit comments

Comments
 (0)