Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 61 additions & 11 deletions crates/tracedecay-cli/src/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,15 +554,15 @@ pub enum UpgradeOutcome {
/// binary: `which_tracedecay()`'s current-exe-first order can point
/// at the OLD binary (e.g. a stale Homebrew keg) after an upgrade.
binary: Option<PathBuf>,
/// Version of the freshly installed binary: the release-manifest
/// version for GitHub-release installs, the linked binary's
/// self-reported version for package-manager installs. Daemon restore
/// Version of the freshly installed binary, read from its `--version`.
/// That string is the identity the daemon advertises
/// (`{release}+{commit}`), not the GitHub release tag. Daemon restore
/// validates this version, the binary it actually restarts, instead
/// of the one that was running before the upgrade. `None` only when
/// the manager's install could not be interrogated; restore
/// verification then validates the pre-upgrade version and, if a new
/// daemon really was installed, fails with a typed identity mismatch
/// rather than silently passing.
/// the install could not be interrogated; restore verification then
/// validates the pre-upgrade version and, if a new daemon really was
/// installed, fails with a typed identity mismatch rather than
/// silently passing.
version: Option<String>,
},
/// Already on the latest version. The binary was not replaced.
Expand Down Expand Up @@ -794,11 +794,40 @@ fn run_versioned_upgrade(current: &str, is_beta: bool) -> Result<UpgradeOutcome>
eprintln!("Upgrading v{current} → v{latest}...");
let binary = install_upgrade_version(latest, is_beta)?;
record_previous_version();
// The tag is what GitHub published. The daemon we just installed
// advertises `build_version()` (`{release}+{commit}`), and readiness
// refuses anything else by opening a handshake on every poll. Reporting
// the tag is what made `tracedecay update` probe that live daemon until
// the maintenance window expired. Two builds of one release differ only
// by commit, so the tag is not a stand-in.
let version = release_install_identity(binary.as_deref());
eprintln!("\x1b[32m✔\x1b[0m Successfully upgraded to v{latest}!");
Ok(UpgradeOutcome::Installed {
binary,
version: Some(latest.to_owned()),
})
Ok(UpgradeOutcome::Installed { binary, version })
}

/// The identity the restarted daemon will advertise, read from the binary
/// that was just installed.
///
/// A missing path or a `--version` that is not evidence is an unknown
/// version, never the release tag. Inventing the tag is the probe storm.
fn release_install_identity(binary: Option<&Path>) -> Option<String> {
let Some(path) = binary else {
eprintln!(
" \x1b[33mwarning:\x1b[0m could not locate the installed binary; \
daemon restore will validate the pre-upgrade version"
);
return None;
};
match installed_binary_version(path) {
Ok(version) => Some(version),
Err(reason) => {
eprintln!(
" \x1b[33mwarning:\x1b[0m could not read the installed binary's version \
({reason}); daemon restore will validate the pre-upgrade version"
);
None
}
}
}

/// Atomically replaces `target` with the contents of `source`: the bytes are
Expand Down Expand Up @@ -1289,6 +1318,27 @@ mod tests {
));
}

#[test]
fn a_github_install_reports_the_binary_identity_not_the_release_tag() {
let dir = tempfile::tempdir().unwrap();
let build = "0.1.0-beta.47+84598a0b9c841b914565f46b20bb6c765706e8e5";
let binary = script(dir.path(), &format!("printf 'tracedecay {build}\\n'"));

assert_eq!(
super::super::release_install_identity(Some(&binary)).as_deref(),
Some(build)
);
}

#[test]
fn an_unreadable_install_is_unknown_rather_than_the_release_tag() {
let dir = tempfile::tempdir().unwrap();
let binary = script(dir.path(), "printf 'not a version\\n'");

assert_eq!(super::super::release_install_identity(Some(&binary)), None);
assert_eq!(super::super::release_install_identity(None), None);
}

#[test]
fn a_child_that_overfills_its_stdout_pipe_is_refused_without_a_deadlock() {
let dir = tempfile::tempdir().unwrap();
Expand Down
12 changes: 12 additions & 0 deletions crates/tracedecay-daemon-control/src/service/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,18 @@ mod identity_classification_tests {
DaemonProtocolState::IdentityMismatch { .. }
));
}

/// Once the release path reports the installed binary's own `--version`,
/// both sides name the same commit and readiness takes the exact-match
/// path rather than relying on build-metadata precedence.
#[test]
fn the_advertised_build_is_ready_against_itself() {
let build = format!("0.1.0-beta.47+{SHA}");
assert_eq!(
classify_daemon_protocol_identity(Ok(identity(&build)), &build),
DaemonProtocolState::Ready
);
}
}

#[cfg(test)]
Expand Down
13 changes: 13 additions & 0 deletions crates/tracedecay-daemon-control/src/service/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,19 @@ fn strict_restoration_requires_readiness_only_for_running_state() {
super::probe::DaemonSocketState::Connectable,
&super::probe::DaemonProtocolState::Unresponsive("not TraceDecay".to_string()),
));
// A live daemon that answered initialize is still not restored while the
// build it names is a different release than the one just installed. The
// maintenance wait keeps probing until this becomes Ready.
assert!(!super::restored_service_matches(
DaemonServiceState::RunningEnabled,
DaemonServiceState::RunningEnabled,
super::probe::DaemonSocketState::Connectable,
&super::probe::DaemonProtocolState::IdentityMismatch {
name: Some("tracedecay".to_owned()),
version: Some("0.1.0-beta.46+84598a0b9c841b914565f46b20bb6c765706e8e5".to_owned()),
expected_version: "0.1.0-beta.47+84598a0b9c841b914565f46b20bb6c765706e8e5".to_owned(),
},
));
}

#[cfg(unix)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,11 @@ impl SessionTemporalRefreshProjector for CanonicalSessionTemporalProjector {
// Empty remaining range is a durable no-op: terminalize with an
// empty complete progress batch instead of deferring forever.
Ok(None) => canonical_noop_complete_effect(&recovery),
Err(error) if error.is_storage() => Err(
SessionTemporalRefreshProjectorError::retryable(format!(
Err(error) if error.is_storage() => {
Err(SessionTemporalRefreshProjectorError::retryable(format!(
"source_busy: {error}"
)),
),
)))
}
Err(_) => Err(SessionTemporalRefreshProjectorError::terminal(
"projector_failed",
)),
Expand Down
29 changes: 14 additions & 15 deletions crates/tracedecay-session-temporal-store/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,7 @@ pub(super) async fn read_observations(
// ceiling. Split until a single observation remains; that
// observation is then a typed storage failure, not a retry
// that looks like a busy source.
if observation_prefetch_exceeded_materialization_limit(&error) && chunk.len() > 1
{
if observation_prefetch_exceeded_materialization_limit(&error) && chunk.len() > 1 {
let mid = start + chunk.len() / 2;
pending.push((mid, end));
pending.push((start, mid));
Expand Down Expand Up @@ -289,11 +288,18 @@ fn observation_prefetch_exceeded_materialization_limit(error: &SessionStoreError
}
}

/// The error `read_observation` raises for an id the store does not hold, reused
/// by callers that resolve prefetched observations out of a batch map.
pub(super) fn missing_observation(observation_id: &CanonicalObservationIdV1) -> SessionStoreError {
storage_message(
PERSIST_OPERATION,
format!("source observation {} is missing", observation_id.as_str()),
)
}

#[cfg(test)]
mod tests {
use super::{
PERSIST_OPERATION, observation_prefetch_exceeded_materialization_limit, storage,
};
use super::{PERSIST_OPERATION, observation_prefetch_exceeded_materialization_limit, storage};

#[test]
fn materialization_limit_is_the_prefetch_split_signal() {
Expand All @@ -308,15 +314,8 @@ mod tests {
assert!(observation_prefetch_exceeded_materialization_limit(
&exceeded
));
assert!(!observation_prefetch_exceeded_materialization_limit(&locked));
assert!(!observation_prefetch_exceeded_materialization_limit(
&locked
));
}
}

/// The error `read_observation` raises for an id the store does not hold, reused
/// by callers that resolve prefetched observations out of a batch map.
pub(super) fn missing_observation(observation_id: &CanonicalObservationIdV1) -> SessionStoreError {
storage_message(
PERSIST_OPERATION,
format!("source observation {} is missing", observation_id.as_str()),
)
}
Loading