diff --git a/crates/tracedecay/src/daemon.rs b/crates/tracedecay/src/daemon.rs index 13f830244b..7a6746506c 100644 --- a/crates/tracedecay/src/daemon.rs +++ b/crates/tracedecay/src/daemon.rs @@ -77,6 +77,18 @@ const MAX_CACHED_PROJECT_SERVERS: usize = 8; const MAX_TRACKED_PROJECT_OPEN_TASKS: usize = MAX_CACHED_PROJECT_SERVERS; const MAX_CACHED_PROJECT_OPEN_FAILURES: usize = 64; const PROJECT_OPEN_REQUEST_DEADLINE: Duration = Duration::from_millis(500); + +/// Instant a foreground request stops waiting for an open it has already claimed. +/// +/// Measured from the claim, not from connection arrival. Route enrollment and +/// git discovery have their own bounds. Charging them to this deadline made a +/// restart's first request answer warming for a store whose open had already +/// recorded `reset_required`: the connection spent the budget before it joined +/// the watch, so the publication wait returned immediately and never read the +/// refusal. +fn project_open_publication_deadline(claimed_at: tokio::time::Instant) -> tokio::time::Instant { + claimed_at + PROJECT_OPEN_REQUEST_DEADLINE +} /// One budget for every blocking repository probe a route resolution runs. /// /// Route resolution reads the repository's topology, enrollment marker, and diff --git a/crates/tracedecay/src/daemon/engine.rs b/crates/tracedecay/src/daemon/engine.rs index 30f76cdc89..b629ba31bd 100644 --- a/crates/tracedecay/src/daemon/engine.rs +++ b/crates/tracedecay/src/daemon/engine.rs @@ -680,9 +680,10 @@ impl DaemonEngine { // warm-up runs. The open task remains tracked and continues in the // background after this bounded wait expires. let mut retry_init = handshake.allow_init; - let publication_deadline = tokio::time::Instant::now() + PROJECT_OPEN_REQUEST_DEADLINE; loop { let claim = Box::pin(self.begin_project_open(handshake.clone(), None)).await?; + let publication_deadline = + project_open_publication_deadline(tokio::time::Instant::now()); let result = match claim { ProjectOpenTaskClaim::InFlight(state) => { let recorded = state.clone(); diff --git a/crates/tracedecay/src/daemon/project_open_orchestration.rs b/crates/tracedecay/src/daemon/project_open_orchestration.rs index 98745f7194..3f1426f0e5 100644 --- a/crates/tracedecay/src/daemon/project_open_orchestration.rs +++ b/crates/tracedecay/src/daemon/project_open_orchestration.rs @@ -17,13 +17,11 @@ pub(super) async fn wait_for_project_open_publication( where Publication: std::future::Future>, { - // The bound is a plain deadline: a waiter resumed after it elapsed still - // needs one more await, `route_bound_project_server`, before its - // publication loop can read the route's terminal state, so an elapsed - // deadline preempts a failure that was already recorded and the caller - // would see warming for a route that is no longer opening. Callers repair - // that with `prefer_recorded_open_failure` against the claim's own watch - // channel instead of weakening the bound. + // The bound is a plain deadline, measured from the open claim. A waiter + // resumed after it elapsed may still be inside `route_bound_project_server` + // and would otherwise answer warming for a refusal already on the watch. + // Callers repair that with `prefer_recorded_open_failure` against the + // claim's own watch channel instead of weakening the bound. hotpath::future!( tokio::time::timeout_at(deadline, publication), label = "daemon.project.open.publication_wait" @@ -483,7 +481,6 @@ pub(super) async fn portable_project_server_for_request( // warm-up runs. The open task remains tracked and continues in the // background after this bounded wait expires. let mut retry_init = handshake.allow_init; - let publication_deadline = tokio::time::Instant::now() + PROJECT_OPEN_REQUEST_DEADLINE; loop { let claim = Box::pin(begin_portable_project_open( lifecycle.clone(), @@ -499,6 +496,10 @@ pub(super) async fn portable_project_server_for_request( project_open_attempts.clone(), )) .await; + // The bound starts here, after the open is claimed. Starting it at + // connection arrival let route enrollment spend it, and the request + // then answered warming for a refusal already on the watch. + let publication_deadline = project_open_publication_deadline(tokio::time::Instant::now()); let result = match claim { ProjectOpenTaskClaim::InFlight(state) => { let recorded = state.clone(); diff --git a/crates/tracedecay/src/daemon/tests/bootstrap.rs b/crates/tracedecay/src/daemon/tests/bootstrap.rs index 7426df8ba7..c2c1d7c7af 100644 --- a/crates/tracedecay/src/daemon/tests/bootstrap.rs +++ b/crates/tracedecay/src/daemon/tests/bootstrap.rs @@ -2249,10 +2249,9 @@ async fn explicit_init_retries_after_joining_an_ordinary_missing_database_open() let _database_scope = enter_test_daemon_database_scope(&profile_root, "registered missing-db init retry"); let engine = test_daemon_engine_for_profile(&profile_root); - // Each request arms its publication bound on its first poll, before route - // enrollment resolves. Opening and migrating the profile database on that - // path would spend the whole bound before either request subscribes to the - // open below, so the runtime is warmed the way daemon bootstrap warms it. + // Profile open sits before the publication bound (the bound starts when + // the open is claimed). Warm it so the 150 ms join window below is spent + // on the controlled open, not on the first profile migration. prewarm_test_profile_runtime(&engine.store_administration).await; let ordinary_handshake = DaemonHandshake { project_path: Some(project.clone()), @@ -2264,13 +2263,10 @@ async fn explicit_init_retries_after_joining_an_ordinary_missing_database_open() allow_init: true, ..ordinary_handshake.clone() }; - // The bound also covers everything a request does *before* it can join an - // open, route enrollment, git discovery, the registered layout, so a - // first-touch request can spend the whole bound before it ever subscribes - // to the open below and would then mint its own. One ordinary request - // ahead of the fixture resolves that path for this exact route, and its - // refusal is the same missing-index failure the joined waiter classifies - // later. + // One ordinary request ahead of the fixture resolves this exact route, + // and its refusal is the same missing-index failure the joined waiter + // classifies later. The publication bound no longer includes that + // enrollment, so the request can subscribe before the bound is spent. // It is retried the way a client retries the warming hint, so the route is // left with no open of its own before the fixture takes it over. let warmup_give_up = tokio::time::Instant::now() + std::time::Duration::from_secs(30);