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
88 changes: 78 additions & 10 deletions crates/tracedecay-cli/src/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,15 +554,16 @@ 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
/// 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.
/// Protocol identity of the freshly installed binary, the string
/// `tracedecay --version` prints and the daemon advertises as
/// `build_version()`. That is `{release}+{sha}[.dirty]`, not the
/// GitHub release tag. Daemon restore compares it to the answering
/// process with `versions_name_same_build`, which ignores build
/// metadata a side omits, so a bare release tag would accept ANY
/// build of that release. `None` when the binary could not be read:
/// restore then keeps the pre-upgrade identity and fails with a
/// typed mismatch if a different daemon starts, rather than
/// widening the window to a whole release.
version: Option<String>,
},
/// Already on the latest version. The binary was not replaced.
Expand Down Expand Up @@ -796,8 +797,12 @@ fn run_versioned_upgrade(current: &str, is_beta: bool) -> Result<UpgradeOutcome>
record_previous_version();
eprintln!("\x1b[32m✔\x1b[0m Successfully upgraded to v{latest}!");
Ok(UpgradeOutcome::Installed {
// The release tag is the bare semver the operator downloaded. The
// daemon advertises `{release}+{sha}`, and a bare tag matches every
// build of that release, so the window must expect the binary's own
// identity rather than `latest`.
version: installed_protocol_identity(binary.as_deref()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject binaries that do not match the downloaded release

When a release asset is accidentally built from a different version than its tag—for example, the v1.2.4 archive contains a stale 1.2.3+sha binary—this now adopts 1.2.3+sha as the expected identity, so the restarted daemon is accepted even though the command reports a successful upgrade to v1.2.4. Previously the bare latest value caused readiness to expose this packaging mismatch. Validate that the self-reported identity's release component equals latest before accepting its build metadata, preserving the binding between hosted release provenance and installed bytes.

AGENTS.md reference: AGENTS.md:L135-L140

Useful? React with 👍 / 👎.

binary,
version: Some(latest.to_owned()),
})
}

Expand Down Expand Up @@ -949,6 +954,38 @@ fn installed_binary_version(path: &Path) -> std::result::Result<String, VersionP
installed_binary_version_within(path, VERSION_PROBE_DEADLINE)
}

/// Protocol identity of a binary this process just published.
///
/// Readiness and the handshake compare this value to the daemon's
/// `build_version()` with `versions_name_same_build` (5e03dfa9c5), which
/// treats a side that names no commit as less specific rather than
/// different. A GitHub release tag is only the release
/// (`0.1.0-beta.47`); the binary the tag installs names itself
/// `0.1.0-beta.47+<sha>`. Reporting the tag therefore made the maintenance
/// window accept any build of that release, disabling the same-release
/// different-commit skew detection 367a44ad00 added. An unreadable binary
/// is `None`, never the tag: a less specific identity is what widened the
/// window in the first place.
fn installed_protocol_identity(binary: Option<&Path>) -> Option<String> {
match binary.map(installed_binary_version) {
Some(Ok(version)) => Some(version),
Some(Err(reason)) => {
eprintln!(
" \x1b[33mwarning:\x1b[0m could not read the installed binary's protocol identity \
({reason}); daemon restore will not substitute the release tag"
);
None
}
None => {
eprintln!(
" \x1b[33mwarning:\x1b[0m installed binary path is unknown; daemon restore will \
not substitute the release tag"
);
None
}
}
}

fn installed_binary_version_within(
path: &Path,
deadline: Duration,
Expand Down Expand Up @@ -1235,6 +1272,7 @@ mod tests {

use super::super::{
VersionProbeError, installed_binary_version, installed_binary_version_within,
installed_protocol_identity,
};

fn script(dir: &Path, body: &str) -> PathBuf {
Expand All @@ -1252,6 +1290,36 @@ mod tests {
assert_eq!(installed_binary_version(&binary).unwrap(), "1.2.3+abcdef");
}

/// The `tracedecay update` failure: the release tag is `0.1.0-beta.47`
/// and the binary that tag installs names itself with `+<sha>`. A
/// bare tag names every build of that release, so the maintenance
/// window must be handed the binary's own identity.
#[test]
fn a_direct_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!(
installed_protocol_identity(Some(&binary)).as_deref(),
Some(build)
);

let bare = script(dir.path(), "printf 'tracedecay 0.1.0-beta.47\\n'");
assert_eq!(
installed_protocol_identity(Some(&bare)).as_deref(),
Some("0.1.0-beta.47")
);

let missing = dir.path().join("absent");
assert_eq!(
installed_protocol_identity(Some(&missing)),
None,
"an unreadable binary must not fall back to the release tag"
);
assert_eq!(installed_protocol_identity(None), None);
}

#[test]
fn failed_exits_and_malformed_output_are_not_version_evidence() {
let dir = tempfile::tempdir().unwrap();
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