new console
This commit is contained in:
@@ -32,6 +32,11 @@ pub async fn release_run(
|
||||
launcher.release_run(password, with_api, with_mcp).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn release_console(launcher: State<'_, Launcher>, since: usize) -> CmdResult<crate::release::ConsoleTail> {
|
||||
Ok(launcher.release_console(since))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult<Status> {
|
||||
Ok(launcher.status().await)
|
||||
|
||||
@@ -254,6 +254,8 @@ pub struct Launcher {
|
||||
stack_states: std::sync::Mutex<Vec<StackEvent>>,
|
||||
sign_in_cancel: Notify,
|
||||
install_cancel: std::sync::Mutex<Option<CancellationToken>>,
|
||||
/// What the running release has printed, for the console panel.
|
||||
release_console: crate::release::Shared,
|
||||
}
|
||||
|
||||
/// What an operator picked when installing from a drive: the site store an ODIAN drive
|
||||
@@ -414,6 +416,7 @@ impl Launcher {
|
||||
stack_states: std::sync::Mutex::new(Vec::new()),
|
||||
sign_in_cancel: Notify::new(),
|
||||
install_cancel: std::sync::Mutex::new(None),
|
||||
release_console: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2338,8 +2341,13 @@ impl Launcher {
|
||||
)
|
||||
}
|
||||
|
||||
/// Start a release in its own console. Returns once it has started, not when it is done.
|
||||
/// Start a release. Returns once it has started, not when it is done: its output arrives
|
||||
/// in the console panel, which reads `release_console` as it fills.
|
||||
pub async fn release_run(&self, password: String, with_api: bool, with_mcp: bool) -> Result<(), String> {
|
||||
// Two at once would interleave into one buffer and fight over the same build dirs.
|
||||
if self.release_console.lock().is_ok_and(|c| c.is_running()) {
|
||||
return Err("A release is already running: wait for it to finish.".to_string());
|
||||
}
|
||||
let plan = self.release_plan().await;
|
||||
if let Some(why) = plan.blocked {
|
||||
return Err(why);
|
||||
@@ -2373,10 +2381,20 @@ impl Launcher {
|
||||
with_api && plan.docker,
|
||||
with_mcp && plan.docker,
|
||||
&password,
|
||||
&self.release_console,
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Everything the release has printed since line `since`.
|
||||
pub fn release_console(&self, since: usize) -> crate::release::ConsoleTail {
|
||||
match self.release_console.lock() {
|
||||
Ok(c) => c.tail(since),
|
||||
// A poisoned lock must not take the window down with it.
|
||||
Err(e) => e.into_inner().tail(since),
|
||||
}
|
||||
}
|
||||
|
||||
// ── operator-data sync ──────────────────────────────────────────────────
|
||||
|
||||
/// Sync, unless this run is pretending to be another operator. Whatever it authored is
|
||||
|
||||
@@ -59,6 +59,7 @@ pub fn run() {
|
||||
commands::get_status,
|
||||
commands::release_plan,
|
||||
commands::release_run,
|
||||
commands::release_console,
|
||||
commands::get_market,
|
||||
commands::apply_market,
|
||||
commands::check_market,
|
||||
|
||||
+200
-7
@@ -9,7 +9,7 @@
|
||||
//! Comparing versions would not do: a version says what was cut, not from what.
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// What a release could do right now, and what stops it.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -160,7 +160,101 @@ pub fn plan(
|
||||
ReleasePlan { docker, online, scripts, items, blocked }
|
||||
}
|
||||
|
||||
/// Cut and publish, in its own console.
|
||||
/// The Release console's buffer: filled by the running release, read by the window.
|
||||
///
|
||||
/// It sits behind a plain (not async) lock because the threads draining the child's pipes
|
||||
/// have no async context to lock from.
|
||||
#[derive(Default)]
|
||||
pub struct Console {
|
||||
lines: Vec<String>,
|
||||
/// How many lines fell off the front, so indices stay stable while it trims.
|
||||
dropped: usize,
|
||||
running: bool,
|
||||
exit: Option<i32>,
|
||||
}
|
||||
|
||||
pub type Shared = std::sync::Arc<std::sync::Mutex<Console>>;
|
||||
|
||||
/// A release prints a great deal (cargo, npm, docker); keep the tail, not all of it.
|
||||
const MAX_LINES: usize = 4000;
|
||||
|
||||
/// What the window asks for: everything it has not seen yet.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ConsoleTail {
|
||||
/// Index of the first line returned.
|
||||
pub from: usize,
|
||||
pub lines: Vec<String>,
|
||||
pub total: usize,
|
||||
pub running: bool,
|
||||
pub exit: Option<i32>,
|
||||
}
|
||||
|
||||
impl Console {
|
||||
fn start(&mut self) {
|
||||
self.lines.clear();
|
||||
self.dropped = 0;
|
||||
self.running = true;
|
||||
self.exit = None;
|
||||
}
|
||||
|
||||
fn push(&mut self, line: String) {
|
||||
self.lines.push(line);
|
||||
if self.lines.len() > MAX_LINES {
|
||||
let cut = self.lines.len() - MAX_LINES;
|
||||
self.lines.drain(..cut);
|
||||
self.dropped += cut;
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, exit: Option<i32>) {
|
||||
self.running = false;
|
||||
self.exit = exit;
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.running
|
||||
}
|
||||
|
||||
/// Everything from `since` on. A `since` already trimmed away starts at the oldest line
|
||||
/// still held, rather than pretending the dropped ones are still coming.
|
||||
pub fn tail(&self, since: usize) -> ConsoleTail {
|
||||
let total = self.dropped + self.lines.len();
|
||||
let from = since.clamp(self.dropped, total);
|
||||
ConsoleTail {
|
||||
from,
|
||||
lines: self.lines[from - self.dropped..].to_vec(),
|
||||
total,
|
||||
running: self.running,
|
||||
exit: self.exit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain one of the child's streams into the console, a line at a time.
|
||||
fn pump(stream: impl std::io::Read, console: Shared) {
|
||||
use std::io::BufRead;
|
||||
let mut reader = std::io::BufReader::new(stream);
|
||||
let mut raw = Vec::new();
|
||||
loop {
|
||||
raw.clear();
|
||||
match reader.read_until(b'\n', &mut raw) {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(_) => {
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
let text = text.trim_end_matches('\n').trim_end_matches('\r');
|
||||
// A terminal shows only what follows the last carriage return, so a progress line
|
||||
// that kept rewriting itself does not become a thousand lines in the panel.
|
||||
let shown = text.rsplit('\r').next().unwrap_or(text);
|
||||
match console.lock() {
|
||||
Ok(mut c) => c.push(shown.to_string()),
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut and publish, into the launcher's own console panel.
|
||||
///
|
||||
/// The three scripts are run as a person runs them, in order, and each decides for itself
|
||||
/// whether it has anything to do: `release_pack.ps1` stops with "nothing changed" when the
|
||||
@@ -178,6 +272,7 @@ pub fn run_release(
|
||||
with_api: bool,
|
||||
with_mcp: bool,
|
||||
sudo: &str,
|
||||
console: &Shared,
|
||||
) -> Result<PathBuf, String> {
|
||||
let scripts = mcp_repo.join("scripts");
|
||||
let log = work_dir.join("release.log");
|
||||
@@ -229,24 +324,32 @@ pub fn run_release(
|
||||
|
||||
// Everything inside try/finally, and everything recorded. A step that throws used to
|
||||
// take the window with it - the release simply vanished, with nothing to read.
|
||||
//
|
||||
// No `pause` here any more: this runs into the launcher's own panel, where there is no
|
||||
// console to press Enter in. The exit code carries the verdict instead - swallowing a
|
||||
// failure and exiting 0 would make a failed release look like a finished one.
|
||||
let ps = format!(
|
||||
"$ErrorActionPreference = 'Stop'
|
||||
# The pipe gets UTF-8 or Windows' own French messages arrive as mojibake.
|
||||
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
|
||||
$pw = $env:ODIAN_NODE_SUDO
|
||||
$env:ODIAN_NODE_SUDO = $null
|
||||
Start-Transcript -Path '{}' -Force | Out-Null
|
||||
$ok = $true
|
||||
try {{
|
||||
{}
|
||||
Write-Host ''
|
||||
Write-Host 'Release finished.' -ForegroundColor Green
|
||||
Write-Host 'Release finished.'
|
||||
}} catch {{
|
||||
Write-Host ''
|
||||
Write-Host \"FAILED: $_\" -ForegroundColor Red
|
||||
Write-Host \"FAILED: $_\"
|
||||
$ok = $false
|
||||
}} finally {{
|
||||
Stop-Transcript | Out-Null
|
||||
Write-Host ''
|
||||
Write-Host 'Log: {}'
|
||||
pause
|
||||
}}
|
||||
if (-not $ok) {{ exit 1 }}
|
||||
",
|
||||
log.display(),
|
||||
body,
|
||||
@@ -260,15 +363,50 @@ pub fn run_release(
|
||||
let mut cmd = Command::new("powershell");
|
||||
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]).arg(&file);
|
||||
cmd.env("ODIAN_NODE_SUDO", sudo);
|
||||
// Nothing can type into this, so stdin is closed: a script that ever asks a question
|
||||
// then fails at once instead of hanging forever with no window to answer in.
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0000_0010); // CREATE_NEW_CONSOLE: the developer watches it run
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW: the output belongs in the panel
|
||||
}
|
||||
cmd.spawn().map_err(|e| format!("cannot start the release: {e}"))?;
|
||||
let mut child = cmd.spawn().map_err(|e| format!("cannot start the release: {e}"))?;
|
||||
console.lock().map_err(|_| "the release console is unusable")?.start();
|
||||
|
||||
// Both streams, kept separate: merging them with `2>&1` inside PowerShell wraps every
|
||||
// stderr line from a native exe in a NativeCommandError and makes a clean run look failed.
|
||||
let mut pumps = Vec::new();
|
||||
for stream in [child.stdout.take().map(Either::Out), child.stderr.take().map(Either::Err)] {
|
||||
if let Some(s) = stream {
|
||||
let c = console.clone();
|
||||
pumps.push(std::thread::spawn(move || match s {
|
||||
Either::Out(r) => pump(r, c),
|
||||
Either::Err(r) => pump(r, c),
|
||||
}));
|
||||
}
|
||||
}
|
||||
let c = console.clone();
|
||||
std::thread::spawn(move || {
|
||||
let code = child.wait().ok().and_then(|s| s.code());
|
||||
// Drain what is still sitting in the pipes BEFORE saying it finished: the window stops
|
||||
// asking once running goes false, and the last lines are the ones that say why.
|
||||
for p in pumps {
|
||||
let _ = p.join();
|
||||
}
|
||||
if let Ok(mut c) = c.lock() {
|
||||
c.finish(code);
|
||||
}
|
||||
});
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// The two pipes have different types; this lets one thread body handle either.
|
||||
enum Either {
|
||||
Out(std::process::ChildStdout),
|
||||
Err(std::process::ChildStderr),
|
||||
}
|
||||
|
||||
/// Where the Engine API deploy script is: beside the other release scripts, or in the
|
||||
/// release skill that carries it.
|
||||
fn api_script(scripts: &Path) -> Option<PathBuf> {
|
||||
@@ -363,4 +501,59 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_console_hands_over_only_what_the_window_has_not_seen() {
|
||||
let mut c = Console::default();
|
||||
c.start();
|
||||
for i in 0..3 {
|
||||
c.push(format!("line {i}"));
|
||||
}
|
||||
let first = c.tail(0);
|
||||
assert_eq!(first.from, 0);
|
||||
assert_eq!(first.total, 3);
|
||||
assert_eq!(first.lines.len(), 3);
|
||||
assert!(first.running, "a started console is running");
|
||||
|
||||
// Asking again from where it got to yields nothing, not the whole buffer again.
|
||||
let again = c.tail(first.total);
|
||||
assert!(again.lines.is_empty());
|
||||
assert_eq!(again.total, 3);
|
||||
|
||||
c.push("line 3".into());
|
||||
let more = c.tail(3);
|
||||
assert_eq!(more.lines, vec!["line 3".to_string()]);
|
||||
|
||||
c.finish(Some(1));
|
||||
let done = c.tail(4);
|
||||
assert!(!done.running);
|
||||
assert_eq!(done.exit, Some(1), "a failed release must not look finished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimming_a_long_release_keeps_indices_honest() {
|
||||
let mut c = Console::default();
|
||||
c.start();
|
||||
for i in 0..(MAX_LINES + 500) {
|
||||
c.push(format!("line {i}"));
|
||||
}
|
||||
let t = c.tail(0);
|
||||
assert_eq!(t.total, MAX_LINES + 500, "total counts every line ever printed");
|
||||
assert_eq!(t.lines.len(), MAX_LINES, "only the tail is kept");
|
||||
assert_eq!(t.from, 500, "it says where it really starts, not 0");
|
||||
assert_eq!(t.lines[0], "line 500");
|
||||
// A window that asked from beyond the end gets nothing rather than a panic.
|
||||
assert!(c.tail(t.total + 99).lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_progress_line_that_rewrites_itself_stays_one_line() {
|
||||
let stream: &[u8] = b"first\r\nbuilding 10%\rbuilding 90%\rbuilt\nplain\n";
|
||||
let c: Shared = Default::default();
|
||||
c.lock().unwrap().start();
|
||||
pump(stream, c.clone());
|
||||
let got = c.lock().unwrap().tail(0);
|
||||
// CRLF is an ending, not content; a lone CR overwrites, exactly as a terminal shows it.
|
||||
assert_eq!(got.lines, vec!["first".to_string(), "built".to_string(), "plain".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user