Skip to content
Open
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
29 changes: 29 additions & 0 deletions crates/bsk-cli/src/cli/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,10 @@ fn describe_update(
}
UpdateResult::Skipped => {
details.push(match record.skip_reason {
Some(SkipReason::ActiveSessions) => format!(
"bsk {} is available; automatic installation was postponed because agent sessions are active",
record.target_version
),
Some(SkipReason::HostManaged) => format!(
"bsk {} is available; this daemon belongs to its terminal or supervisor",
record.target_version
Expand All @@ -365,6 +369,10 @@ fn describe_update(
});
if !superseded {
hints.push(match record.skip_reason {
Some(SkipReason::ActiveSessions) => {
"the daemon retries on its next update check once sessions are idle"
.to_string()
}
Some(SkipReason::HostManaged) => {
"run `bsk update`, then restart the daemon in its terminal or supervisor"
.to_string()
Expand Down Expand Up @@ -923,6 +931,27 @@ mod m2_tests {
);
}

#[test]
fn active_sessions_postpone_updates_without_installation_advice() {
let record = UpdateRecord::skipped(
UpdateSource::Daemon,
&"0.3.2".parse().unwrap(),
std::path::Path::new("bsk"),
SkipReason::ActiveSessions,
None,
);
let check = update_check(Some(&record), Some("0.3.1"));
assert!(
check
.detail
.contains("postponed because agent sessions are active")
);
assert!(!check.detail.contains("cannot write"));
let hint = check.hint.unwrap();
assert!(hint.contains("next update check"));
assert!(!hint.contains("installer or package manager"));
}

#[test]
fn auto_update_check_lists_leftover_files_without_warning() {
let leftovers = [update::Leftover {
Expand Down
116 changes: 93 additions & 23 deletions crates/bsk-cli/src/cli/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,20 +514,37 @@ pub(crate) fn install_verified(
lock: UpdateLock,
cancelled: &dyn Fn() -> bool,
) -> Result<Installed> {
let binary = match download_candidate_binary(candidate, client) {
Ok(binary) => binary,
Err(err) => {
record.fail(&err, Recovery::Unchanged);
return Err(err);
}
};
let binary = download_verified(candidate, client, record)?;
install_downloaded(candidate, target, &binary, record, lock, cancelled)
}

fn download_verified(
candidate: &UpdateCandidate,
client: &reqwest::blocking::Client,
record: &mut UpdateRecord,
) -> Result<Vec<u8>> {
download_candidate_binary(candidate, client).inspect_err(|err| {
record.fail(err, Recovery::Unchanged);
})
}

/// Install an already verified download. The daemon holds session admission
/// closed before entering this step, through self-check and handover or rollback.
pub(crate) fn install_downloaded(
candidate: &UpdateCandidate,
target: &Path,
binary: &[u8],
record: &mut UpdateRecord,
lock: UpdateLock,
cancelled: &dyn Fn() -> bool,
) -> Result<Installed> {
if cancelled() {
let err = anyhow::anyhow!("the daemon stopped before the update was installed");
record.fail(&err, Recovery::Unchanged);
return Err(err);
}
record.enter(UpdateStage::Install);
let installed = match install_binary(target, &binary, lock) {
let installed = match install_binary(target, binary, lock) {
Ok(installed) => installed,
Err(err) => {
let recovery = match err.downcast_ref::<PreviousNotRestored>() {
Expand All @@ -550,16 +567,13 @@ pub(crate) fn install_verified(
Ok(installed)
}

/// Daemon-side install with the daemon's own HTTP client.
pub(crate) fn self_install_candidate(
/// Download before closing session admission, using the daemon's HTTP client.
pub(crate) fn self_download_candidate(
candidate: &UpdateCandidate,
target: &Path,
record: &mut UpdateRecord,
lock: UpdateLock,
cancelled: &dyn Fn() -> bool,
) -> Result<Installed> {
) -> Result<Vec<u8>> {
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
install_verified(candidate, target, &client, record, lock, cancelled)
download_verified(candidate, &client, record)
}

/// Where to turn when bsk cannot write next to its own executable.
Expand Down Expand Up @@ -603,6 +617,12 @@ pub(crate) enum AutoUpdatePolicy {
NotWritable,
}

/// Installation may be postponed when a session starts during the download.
pub(crate) enum InstallOutcome<T> {
Installed(T),
PostponedSessions(usize),
}

/// Outcome of one daemon auto-update step (see [`auto_update_step`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AutoUpdateOutcome<T> {
Expand Down Expand Up @@ -634,7 +654,7 @@ pub(crate) fn auto_update_step<T>(
active_sessions: usize,
last_attempt: Option<&UpdateRecord>,
now_epoch_secs: u64,
install: impl FnOnce(&UpdateCandidate) -> Result<T>,
install: impl FnOnce(&UpdateCandidate) -> Result<InstallOutcome<T>>,
) -> Result<AutoUpdateOutcome<T>> {
let Some(candidate) = candidate else {
return Ok(AutoUpdateOutcome::UpToDate);
Expand All @@ -657,8 +677,12 @@ pub(crate) fn auto_update_step<T>(
{
return Ok(AutoUpdateOutcome::Deferred { latest, until });
}
let installed = install(candidate)?;
Ok(AutoUpdateOutcome::Installed { latest, installed })
Ok(match install(candidate)? {
InstallOutcome::Installed(installed) => AutoUpdateOutcome::Installed { latest, installed },
InstallOutcome::PostponedSessions(sessions) => {
AutoUpdateOutcome::PostponedSessions { latest, sessions }
}
})
}

pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
Expand Down Expand Up @@ -908,7 +932,9 @@ fn cached_update_hint(
let action = match skipped {
Some((SkipReason::NotWritable, record)) => HintAction::Installer(&record.executable),
Some((SkipReason::HostManaged, _)) => HintAction::CommandThenHost,
None => HintAction::from_auto_update(cache.auto_update.unwrap_or(auto_update)),
None | Some((SkipReason::ActiveSessions, _)) => {
HintAction::from_auto_update(cache.auto_update.unwrap_or(auto_update))
}
};
Ok(update_hint_for_cache(&cache, current_version, action))
}
Expand Down Expand Up @@ -2031,7 +2057,7 @@ mod tests {
}
}

fn no_install(_: &UpdateCandidate) -> Result<()> {
fn no_install(_: &UpdateCandidate) -> Result<InstallOutcome<()>> {
panic!("install must not run")
}

Expand Down Expand Up @@ -2107,7 +2133,7 @@ mod tests {
|candidate: &UpdateCandidate| {
installs.set(installs.get() + 1);
assert_eq!(candidate.latest, LATEST);
Ok("installed")
Ok(InstallOutcome::Installed("installed"))
},
)
.unwrap();
Expand Down Expand Up @@ -2145,7 +2171,7 @@ mod tests {
0,
Some(last),
now,
|_| Ok(()),
|_| Ok(InstallOutcome::Installed(())),
)
.unwrap()
};
Expand All @@ -2161,6 +2187,50 @@ mod tests {
);
}

#[test]
fn auto_update_step_postpones_a_download_without_failure_backoff() {
let candidate = test_candidate();
let outcome = auto_update_step(
Some(&candidate),
AutoUpdatePolicy::Install,
0,
None,
1_000,
|_| Ok(InstallOutcome::<()>::PostponedSessions(1)),
)
.unwrap();
assert_eq!(
outcome,
AutoUpdateOutcome::PostponedSessions {
latest: LATEST,
sessions: 1
}
);
let skipped = UpdateRecord::skipped(
UpdateSource::Daemon,
&LATEST,
Path::new("bsk"),
SkipReason::ActiveSessions,
None,
);
assert_eq!(skipped.retry_blocked_until(&LATEST, 1_001), None);
assert_eq!(
auto_update_step(
Some(&candidate),
AutoUpdatePolicy::Install,
0,
Some(&skipped),
1_001,
|_| Ok(InstallOutcome::Installed(())),
)
.unwrap(),
AutoUpdateOutcome::Installed {
latest: LATEST,
installed: ()
}
);
}

#[test]
fn auto_update_step_propagates_install_errors() {
let candidate = test_candidate();
Expand All @@ -2170,7 +2240,7 @@ mod tests {
0,
None,
1_000,
|_| -> Result<()> { bail!("boom") },
|_| -> Result<InstallOutcome<()>> { bail!("boom") },
);
assert!(result.is_err());
}
Expand Down
10 changes: 10 additions & 0 deletions crates/bsk-cli/src/cli/update/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub enum UpdateResult {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SkipReason {
/// A session started while the release was being downloaded.
ActiveSessions,
/// The daemon belongs to a terminal or supervisor, which must restart it.
HostManaged,
/// The directory holding the executable does not accept new files.
Expand Down Expand Up @@ -148,6 +150,14 @@ impl UpdateRecord {
self.save();
}

pub(crate) fn postpone_for_sessions(&mut self) {
self.result = UpdateResult::Skipped;
self.skip_reason = Some(SkipReason::ActiveSessions);
self.retry_after_epoch_secs = None;
self.updated_at_epoch_secs = now_epoch_secs();
self.save();
}

pub(crate) fn succeed(&mut self, daemon: Option<(u32, String)>) {
self.result = UpdateResult::Succeeded;
self.previous_executable = None;
Expand Down
8 changes: 8 additions & 0 deletions crates/bsk-cli/src/daemon/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1055,8 +1055,16 @@ pub(super) async fn handle_session_start(
}
}

#[test]
fn update_admission_error_uses_the_existing_protocol_code() {
let error = map_start_error(StartSessionError::DaemonUpdating);
assert_eq!(error.code, ErrorCode::ProtocolError);
assert!(error.message.contains("retry session start"));
}

fn map_start_error(err: StartSessionError) -> RpcError {
let code = match &err {
StartSessionError::DaemonUpdating => ErrorCode::ProtocolError,
StartSessionError::NoBrowserConnected => ErrorCode::NoBrowserConnected,
StartSessionError::MultipleBrowsersOnline { .. } => ErrorCode::MultipleBrowsersOnline,
StartSessionError::BrowserNotFound => ErrorCode::NotFound,
Expand Down
Loading
Loading