Skip to content

Commit 0a2c58a

Browse files
Merge remote-tracking branch 'origin/master' into pr-478-fmt
2 parents 77d264f + 5a381bc commit 0a2c58a

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
@@ -920,9 +920,9 @@ fn is_transient_daemon_connect_error(kind: std::io::ErrorKind) -> bool {
920920
)
921921
}
922922

923-
async fn connect_to_daemon_connection(connection: &DaemonConnection) -> Result<BrokerStream> {
924-
connect_with_restart_grace(
925-
connection,
923+
async fn connect_to_current_daemon(socket_path: &Path) -> Result<(DaemonConnection, BrokerStream)> {
924+
connect_with_restart_grace_resolving(
925+
|| client_connection(socket_path),
926926
DAEMON_RESTART_GRACE,
927927
DAEMON_RESTART_POLL_INTERVAL,
928928
)
@@ -938,10 +938,27 @@ async fn connect_with_restart_grace(
938938
grace: Duration,
939939
poll_interval: Duration,
940940
) -> Result<BrokerStream> {
941+
let (_, stream) =
942+
connect_with_restart_grace_resolving(|| Ok(connection.clone()), grace, poll_interval)
943+
.await?;
944+
Ok(stream)
945+
}
946+
947+
/// Resolves the current authority record on every connect attempt.
948+
///
949+
/// A daemon restart replaces both its endpoint authority epoch and auth token.
950+
/// Resolving only before the restart window would connect to the new socket
951+
/// with stale credentials after it rebinds.
952+
async fn connect_with_restart_grace_resolving(
953+
mut resolve: impl FnMut() -> Result<DaemonConnection>,
954+
grace: Duration,
955+
poll_interval: Duration,
956+
) -> Result<(DaemonConnection, BrokerStream)> {
941957
let deadline = tokio::time::Instant::now() + grace;
942958
loop {
959+
let connection = resolve()?;
943960
match BrokerStream::connect(&connection.endpoint).await {
944-
Ok(stream) => return Ok(stream),
961+
Ok(stream) => return Ok((connection, stream)),
945962
Err(TraceDecayError::Io(err)) => {
946963
if !is_transient_daemon_connect_error(err.kind())
947964
|| tokio::time::Instant::now() >= deadline
@@ -1314,8 +1331,7 @@ async fn send_daemon_request_line_with_liveness_poll(
13141331
line: &str,
13151332
liveness_poll_interval: Duration,
13161333
) -> Result<Vec<String>> {
1317-
let connection = client_connection(socket_path)?;
1318-
let stream = connect_to_daemon_connection(&connection).await?;
1334+
let (connection, stream) = connect_to_current_daemon(socket_path).await?;
13191335
let (reader, mut writer) = stream.into_split();
13201336

13211337
write_daemon_preamble(&mut writer, &connection, handshake).await?;
@@ -1513,8 +1529,7 @@ async fn call_tool_with_liveness_poll(
15131529
arguments: serde_json::Value,
15141530
liveness_poll_interval: Duration,
15151531
) -> Result<serde_json::Value> {
1516-
let connection = client_connection(socket_path)?;
1517-
let stream = connect_to_daemon_connection(&connection).await?;
1532+
let (connection, stream) = connect_to_current_daemon(socket_path).await?;
15181533
let (reader, mut writer) = stream.into_split();
15191534
let id = json!(1);
15201535
let request = JsonRpcRequest {

src/daemon/service.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,9 +1144,9 @@ mod tests {
11441144
#[cfg(unix)]
11451145
use tempfile::TempDir;
11461146

1147-
use super::{
1148-
DaemonServiceSpec, DaemonServiceState, LaunchctlFailureMode, LaunchdCommand, ServiceRunner,
1149-
};
1147+
use super::{DaemonServiceSpec, LaunchctlFailureMode, LaunchdCommand};
1148+
#[cfg(target_os = "linux")]
1149+
use super::{DaemonServiceState, ServiceRunner};
11501150
use crate::config::lock_user_data_dir_test_env;
11511151

11521152
struct EnvVarGuard {

src/daemon/tests.rs

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

1474+
#[cfg(unix)]
1475+
async fn answer_one_authenticated_proxy_request(
1476+
listener: tokio::net::UnixListener,
1477+
expected_token: &str,
1478+
generation: u64,
1479+
) {
1480+
let (stream, _addr) = listener.accept().await.expect("accept proxied client");
1481+
let (reader, mut writer) = stream.into_split();
1482+
let mut lines = tokio::io::BufReader::new(reader).lines();
1483+
let auth_line = lines
1484+
.next_line()
1485+
.await
1486+
.expect("read auth preface")
1487+
.expect("auth preface line");
1488+
let preface =
1489+
super::transport::DaemonAuthPreface::from_line(auth_line.trim()).expect("auth preface");
1490+
assert!(
1491+
preface.authenticate(expected_token),
1492+
"proxy must reload the current daemon authority token"
1493+
);
1494+
let handshake_line = lines
1495+
.next_line()
1496+
.await
1497+
.expect("read handshake")
1498+
.expect("handshake line");
1499+
DaemonHandshake::from_line(&handshake_line).expect("parse handshake");
1500+
let request_line = lines
1501+
.next_line()
1502+
.await
1503+
.expect("read request")
1504+
.expect("request line");
1505+
let request: Value = serde_json::from_str(&request_line).expect("request json");
1506+
let response = json!({
1507+
"jsonrpc": "2.0",
1508+
"id": request["id"],
1509+
"result": { "generation": generation }
1510+
});
1511+
writer
1512+
.write_all(
1513+
serde_json::to_string(&response)
1514+
.expect("response json")
1515+
.as_bytes(),
1516+
)
1517+
.await
1518+
.expect("write response");
1519+
writer.write_all(b"\n").await.expect("write newline");
1520+
writer.shutdown().await.expect("shutdown fake daemon");
1521+
}
1522+
14741523
#[cfg(unix)]
14751524
async fn daemon_round_trip(
14761525
engine: super::DaemonEngine,
@@ -2048,6 +2097,86 @@ async fn long_lived_proxy_reconnects_after_daemon_socket_rebind() {
20482097
await_test_task(daemon, "daemon rebind task").await;
20492098
}
20502099

2100+
#[cfg(unix)]
2101+
#[tokio::test]
2102+
async fn long_lived_proxy_reloads_rotated_auth_after_daemon_restart() {
2103+
let dir = TempDir::new().expect("temp dir");
2104+
let profile = dir.path().canonicalize().expect("canonical profile");
2105+
let socket = profile.join("daemon.sock");
2106+
let endpoint = super::transport::DaemonEndpoint::Unix(socket.clone());
2107+
let first_listener = tokio::net::UnixListener::bind(&socket).expect("bind first daemon socket");
2108+
let first_authority = super::authority::DaemonAuthority::acquire(&profile, &endpoint, "first")
2109+
.expect("first daemon authority");
2110+
let first_token = first_authority.auth_token().to_string();
2111+
let rebound_socket = socket.clone();
2112+
let rebound_profile = profile.clone();
2113+
let rebound_endpoint = endpoint.clone();
2114+
let (unbound_tx, unbound_rx) = tokio::sync::oneshot::channel();
2115+
let daemon = tokio::spawn(async move {
2116+
answer_one_authenticated_proxy_request(first_listener, &first_token, 1).await;
2117+
drop(first_authority);
2118+
std::fs::remove_file(&rebound_socket).expect("unlink first daemon socket");
2119+
unbound_tx.send(()).expect("notify daemon outage");
2120+
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2121+
2122+
let second_listener =
2123+
tokio::net::UnixListener::bind(&rebound_socket).expect("bind second daemon socket");
2124+
let second_authority = super::authority::DaemonAuthority::acquire(
2125+
&rebound_profile,
2126+
&rebound_endpoint,
2127+
"second",
2128+
)
2129+
.expect("second daemon authority");
2130+
let second_token = second_authority.auth_token().to_string();
2131+
assert_ne!(first_token, second_token, "daemon restart must rotate auth");
2132+
answer_one_authenticated_proxy_request(second_listener, &second_token, 2).await;
2133+
drop(second_authority);
2134+
});
2135+
2136+
let (mut transport, sender, mut receiver) = crate::mcp::transport::ChannelTransport::new();
2137+
let proxy_socket = socket.clone();
2138+
let proxy = tokio::spawn(async move {
2139+
super::proxy_transport_to_daemon(
2140+
&proxy_socket,
2141+
&test_handshake_defaults(),
2142+
None,
2143+
&mut transport,
2144+
)
2145+
.await
2146+
});
2147+
let request = |id| {
2148+
serde_json::to_string(&json!({
2149+
"jsonrpc": "2.0",
2150+
"id": id,
2151+
"method": "tools/list"
2152+
}))
2153+
.expect("request json")
2154+
};
2155+
2156+
sender.send(request(1)).expect("send first request");
2157+
let first = tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv())
2158+
.await
2159+
.expect("first response timed out")
2160+
.expect("first response");
2161+
let first: Value = serde_json::from_str(first.trim()).expect("first response json");
2162+
assert_eq!(first["result"]["generation"], json!(1));
2163+
2164+
unbound_rx.await.expect("first daemon should unlink socket");
2165+
sender.send(request(2)).expect("send second request");
2166+
let second = tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv())
2167+
.await
2168+
.expect("second response timed out")
2169+
.expect("second response");
2170+
let second: Value = serde_json::from_str(second.trim()).expect("second response json");
2171+
assert_eq!(second["result"]["generation"], json!(2));
2172+
2173+
drop(sender);
2174+
await_test_task(proxy, "rotating-auth proxy task")
2175+
.await
2176+
.expect("proxy transport");
2177+
await_test_task(daemon, "rotating-auth daemon task").await;
2178+
}
2179+
20512180
#[cfg(unix)]
20522181
#[tokio::test]
20532182
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)