mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(bootstrap): decode localized PS stderr and refresh mutable install cache
Windows PowerShell 5.1 emits ParserError text in the console ANSI code page, but the GUI bootstrap aborted BufReader::lines() on the first non-UTF-8 byte and Retry kept reusing a poisoned install-main.ps1 for branch pins. Decode child output with a real Windows-1252 fallback, write a UTF-8 BOM on cached .ps1 files for -File, and refresh mutable branch/tag caches on each run (immutable SHAs stay cached). Fixes #67193
This commit is contained in:
parent
14add28785
commit
4ce1994159
3 changed files with 221 additions and 45 deletions
|
|
@ -70,6 +70,29 @@ fn is_valid_commit(s: &str) -> bool {
|
|||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Resolver cache plan for a pin that already has a local path computed.
|
||||
///
|
||||
/// Immutable commit pins reuse cache forever. Mutable branch/tag pins always
|
||||
/// refresh, and only fall back to a stale cache when the refresh fails.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CachePlan {
|
||||
/// On-disk hit for an immutable pin — skip the network.
|
||||
Reuse,
|
||||
/// Download (or re-download). `stale_ok` means a failed refresh may return
|
||||
/// the existing cache file (mutable pins with a prior download).
|
||||
Fetch { stale_ok: bool },
|
||||
}
|
||||
|
||||
pub(crate) fn cache_plan(immutable: bool, cached_exists: bool) -> CachePlan {
|
||||
if immutable && cached_exists {
|
||||
CachePlan::Reuse
|
||||
} else {
|
||||
CachePlan::Fetch {
|
||||
stale_ok: !immutable && cached_exists,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the install script to use for this run.
|
||||
///
|
||||
/// `pin` is the commit-or-branch from either Hermes-Setup's build-time
|
||||
|
|
@ -100,9 +123,13 @@ pub async fn resolve(
|
|||
// 2. (Not implemented) bundled fallback.
|
||||
|
||||
// 3. Network. Pin must be a real commit or a branch ref.
|
||||
let commit_or_ref = match (&pin.commit, &pin.branch) {
|
||||
(Some(c), _) if is_valid_commit(c) => c.clone(),
|
||||
(_, Some(b)) if !b.trim().is_empty() => b.clone(),
|
||||
//
|
||||
// Commit SHAs are immutable — permanent cache reuse is safe.
|
||||
// Branch/tag pins are moving refs: always try to refresh so "Retry install"
|
||||
// cannot keep reusing a poisoned install-main.ps1 forever (#67193).
|
||||
let (commit_or_ref, immutable) = match (&pin.commit, &pin.branch) {
|
||||
(Some(c), _) if is_valid_commit(c) => (c.clone(), true),
|
||||
(_, Some(b)) if !b.trim().is_empty() => (b.clone(), false),
|
||||
(Some(other), _) => {
|
||||
return Err(anyhow!(
|
||||
"install script pin commit `{other}` is not a valid git SHA"
|
||||
|
|
@ -116,36 +143,60 @@ pub async fn resolve(
|
|||
};
|
||||
|
||||
let cached = cached_path(kind, &commit_or_ref);
|
||||
if cached.exists() {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] using cached {} for {}",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
return Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Cached,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
match cache_plan(immutable, cached.exists()) {
|
||||
CachePlan::Reuse => {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] using cached {} for {}",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
return Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Cached,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
}
|
||||
CachePlan::Fetch { stale_ok } => {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] downloading {} for {} {} from GitHub",
|
||||
kind.filename(),
|
||||
if immutable {
|
||||
"commit"
|
||||
} else {
|
||||
"mutable ref"
|
||||
},
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
|
||||
match download(kind, &commit_or_ref, &cached).await {
|
||||
Ok(()) => {
|
||||
emit_log(&format!("[bootstrap] cached to {}", cached.display()));
|
||||
Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Downloaded,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
})
|
||||
}
|
||||
Err(err) if stale_ok => {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] WARNING: refresh failed for mutable ref {}; using stale cached {} at {}: {err:#}",
|
||||
truncate_ref(&commit_or_ref),
|
||||
kind.filename(),
|
||||
cached.display()
|
||||
));
|
||||
Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Cached,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
})
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit_log(&format!(
|
||||
"[bootstrap] downloading {} for {} from GitHub",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
|
||||
download(kind, &commit_or_ref, &cached).await?;
|
||||
|
||||
emit_log(&format!("[bootstrap] cached to {}", cached.display()));
|
||||
|
||||
Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Downloaded,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
|
@ -185,6 +236,33 @@ fn truncate_ref(s: &str) -> &str {
|
|||
}
|
||||
}
|
||||
|
||||
/// UTF-8 BOM. Windows PowerShell 5.1 reads a BOM-less `.ps1` using the system
|
||||
/// ANSI code page; a leading BOM is what tells it the file is UTF-8. The
|
||||
/// `irm | iex` / `[scriptblock]::Create` path strips BOMs on purpose, but the
|
||||
/// GUI bootstrap runs the *cached file* via `-File`, so we write the opposite
|
||||
/// (#67193).
|
||||
const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
|
||||
|
||||
/// Prepare bytes for the on-disk bootstrap cache.
|
||||
///
|
||||
/// `.ps1` files get a UTF-8 BOM (unless one is already present). `.sh` files
|
||||
/// are left unchanged — a BOM would break `#!/bin/bash`.
|
||||
pub(crate) fn prepare_cached_script_bytes(kind: ScriptKind, bytes: &[u8]) -> Vec<u8> {
|
||||
match kind {
|
||||
ScriptKind::Ps1 => {
|
||||
if bytes.starts_with(UTF8_BOM) {
|
||||
bytes.to_vec()
|
||||
} else {
|
||||
let mut out = Vec::with_capacity(UTF8_BOM.len() + bytes.len());
|
||||
out.extend_from_slice(UTF8_BOM);
|
||||
out.extend_from_slice(bytes);
|
||||
out
|
||||
}
|
||||
}
|
||||
ScriptKind::Sh => bytes.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads to `dest_path` via reqwest with rustls. Atomically renames
|
||||
/// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache.
|
||||
async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> {
|
||||
|
|
@ -228,6 +306,7 @@ async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Re
|
|||
.bytes()
|
||||
.await
|
||||
.with_context(|| format!("reading body of {url}"))?;
|
||||
let bytes = prepare_cached_script_bytes(kind, &bytes);
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp_path)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -13,6 +13,94 @@ use tokio::io::{AsyncBufReadExt, BufReader};
|
|||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// CP1252 mapping for bytes `0x80..=0x9F` (the range that differs from Latin-1).
|
||||
/// Undefined slots keep the C1 control code points, matching Windows-1252
|
||||
/// best-fit behavior used by `encoding_rs::WINDOWS_1252`.
|
||||
const CP1252_80_9F: [char; 32] = [
|
||||
'\u{20AC}', // 0x80 €
|
||||
'\u{0081}', // 0x81
|
||||
'\u{201A}', // 0x82 ‚
|
||||
'\u{0192}', // 0x83 ƒ
|
||||
'\u{201E}', // 0x84 „
|
||||
'\u{2026}', // 0x85 …
|
||||
'\u{2020}', // 0x86 †
|
||||
'\u{2021}', // 0x87 ‡
|
||||
'\u{02C6}', // 0x88 ˆ
|
||||
'\u{2030}', // 0x89 ‰
|
||||
'\u{0160}', // 0x8A Š
|
||||
'\u{2039}', // 0x8B ‹
|
||||
'\u{0152}', // 0x8C Œ
|
||||
'\u{008D}', // 0x8D
|
||||
'\u{017D}', // 0x8E Ž
|
||||
'\u{008F}', // 0x8F
|
||||
'\u{0090}', // 0x90
|
||||
'\u{2018}', // 0x91 ‘
|
||||
'\u{2019}', // 0x92 ’
|
||||
'\u{201C}', // 0x93 “
|
||||
'\u{201D}', // 0x94 ”
|
||||
'\u{2022}', // 0x95 •
|
||||
'\u{2013}', // 0x96 –
|
||||
'\u{2014}', // 0x97 —
|
||||
'\u{02DC}', // 0x98 ˜
|
||||
'\u{2122}', // 0x99 ™
|
||||
'\u{0161}', // 0x9A š
|
||||
'\u{203A}', // 0x9B ›
|
||||
'\u{0153}', // 0x9C œ
|
||||
'\u{009D}', // 0x9D
|
||||
'\u{017E}', // 0x9E ž
|
||||
'\u{0178}', // 0x9F Ÿ
|
||||
];
|
||||
|
||||
fn decode_cp1252_byte(b: u8) -> char {
|
||||
match b {
|
||||
0x00..=0x7F => b as char,
|
||||
0x80..=0x9F => CP1252_80_9F[(b - 0x80) as usize],
|
||||
// 0xA0..=0xFF match Unicode Latin-1 / Windows-1252.
|
||||
_ => b as char,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one stdout/stderr line from a child process.
|
||||
///
|
||||
/// Tokio's `BufReader::lines()` requires valid UTF-8 and aborts the line (with
|
||||
/// `stream did not contain valid UTF-8`) at the first accented byte. Windows
|
||||
/// PowerShell 5.1 emits localized ParserError text in the console ANSI code
|
||||
/// page (often CP1252), so Portuguese/Spanish/etc. users only saw a truncated
|
||||
/// `No` instead of `Não foi fornecido o terminador...` (#67193).
|
||||
///
|
||||
/// Prefer UTF-8 when the bytes are valid; otherwise decode as Windows-1252 so
|
||||
/// both Western-European letters and CP1252-only punctuation (e.g. `0x91` →
|
||||
/// U+2018) survive rather than disappearing into a read-error warning.
|
||||
pub(crate) fn decode_console_bytes(bytes: &[u8]) -> String {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => bytes.iter().copied().map(decode_cp1252_byte).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one line (LF or CRLF) and decode it with [`decode_console_bytes`].
|
||||
/// Returns `Ok(None)` on EOF with no bytes pending.
|
||||
pub(crate) async fn read_decoded_line<R>(
|
||||
reader: &mut R,
|
||||
buf: &mut Vec<u8>,
|
||||
) -> std::io::Result<Option<String>>
|
||||
where
|
||||
R: AsyncBufReadExt + Unpin,
|
||||
{
|
||||
buf.clear();
|
||||
let n = reader.read_until(b'\n', buf).await?;
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
}
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
Ok(Some(decode_console_bytes(buf)))
|
||||
}
|
||||
|
||||
/// Hooks the caller installs to receive output.
|
||||
pub struct StreamSink {
|
||||
pub on_stdout_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
|
|
@ -77,8 +165,13 @@ pub async fn run_script(
|
|||
let stdout = child.stdout.take().expect("stdout was piped");
|
||||
let stderr = child.stderr.take().expect("stderr was piped");
|
||||
|
||||
let mut stdout_reader = BufReader::new(stdout).lines();
|
||||
let mut stderr_reader = BufReader::new(stderr).lines();
|
||||
// Byte-oriented readers + [`decode_console_bytes`]: do NOT use
|
||||
// `BufReader::lines()`, which requires valid UTF-8 and hides localized
|
||||
// PowerShell errors on non-English Windows (#67193).
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
let mut stdout_buf = Vec::new();
|
||||
let mut stderr_buf = Vec::new();
|
||||
|
||||
let mut combined_stdout = String::new();
|
||||
let mut combined_stderr = String::new();
|
||||
|
|
@ -87,7 +180,7 @@ pub async fn run_script(
|
|||
// Loop: poll stdout, stderr, cancel, and child exit concurrently.
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = stdout_reader.next_line() => {
|
||||
line = read_decoded_line(&mut stdout_reader, &mut stdout_buf) => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stdout_line)(&l);
|
||||
|
|
@ -104,7 +197,7 @@ pub async fn run_script(
|
|||
}
|
||||
}
|
||||
}
|
||||
line = stderr_reader.next_line() => {
|
||||
line = read_decoded_line(&mut stderr_reader, &mut stderr_buf) => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stderr_line)(&l);
|
||||
|
|
@ -130,12 +223,12 @@ pub async fn run_script(
|
|||
}
|
||||
|
||||
// Drain remaining lines after the loop exited.
|
||||
while let Ok(Some(l)) = stdout_reader.next_line().await {
|
||||
while let Ok(Some(l)) = read_decoded_line(&mut stdout_reader, &mut stdout_buf).await {
|
||||
(sink.on_stdout_line)(&l);
|
||||
combined_stdout.push_str(&l);
|
||||
combined_stdout.push('\n');
|
||||
}
|
||||
while let Ok(Some(l)) = stderr_reader.next_line().await {
|
||||
while let Ok(Some(l)) = read_decoded_line(&mut stderr_reader, &mut stderr_buf).await {
|
||||
(sink.on_stderr_line)(&l);
|
||||
combined_stderr.push_str(&l);
|
||||
combined_stderr.push('\n');
|
||||
|
|
|
|||
|
|
@ -31,10 +31,11 @@ use std::time::{Duration, Instant};
|
|||
|
||||
use anyhow::{anyhow, Result};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
|
||||
use crate::powershell::read_decoded_line;
|
||||
|
||||
/// `hermes update` exit code meaning "another hermes process is holding the
|
||||
/// venv shim open / dirty precondition" — see _cmd_update_impl in
|
||||
|
|
@ -662,28 +663,31 @@ async fn run_streamed(
|
|||
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
let stderr = child.stderr.take().expect("stderr piped");
|
||||
let mut out = BufReader::new(stdout).lines();
|
||||
let mut err = BufReader::new(stderr).lines();
|
||||
// Same non-UTF-8-safe decode path as powershell::run_script (#67193).
|
||||
let mut out = BufReader::new(stdout);
|
||||
let mut err = BufReader::new(stderr);
|
||||
let mut out_buf = Vec::new();
|
||||
let mut err_buf = Vec::new();
|
||||
|
||||
let stage_owned = stage.map(|s| s.to_string());
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = out.next_line() => match line {
|
||||
line = read_decoded_line(&mut out, &mut out_buf) => match line {
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l),
|
||||
Ok(None) => break,
|
||||
Err(e) => { tracing::warn!("stdout read error: {e}"); break; }
|
||||
},
|
||||
line = err.next_line() => match line {
|
||||
line = read_decoded_line(&mut err, &mut err_buf) => match line {
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l),
|
||||
Ok(None) => {}
|
||||
Err(e) => { tracing::warn!("stderr read error: {e}"); }
|
||||
},
|
||||
}
|
||||
}
|
||||
while let Ok(Some(l)) = out.next_line().await {
|
||||
while let Ok(Some(l)) = read_decoded_line(&mut out, &mut out_buf).await {
|
||||
emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l);
|
||||
}
|
||||
while let Ok(Some(l)) = err.next_line().await {
|
||||
while let Ok(Some(l)) = read_decoded_line(&mut err, &mut err_buf).await {
|
||||
emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue