From 0f35a5f707dde06700209fa3b88ac7ecb6dec668 Mon Sep 17 00:00:00 2001 From: Disy Date: Mon, 12 Oct 2026 20:00:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E7=A7=AF=E5=8E=8B=E5=B9=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=87=B3=201.0.2-beta7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 4 +- apps/app-frontend/package.json | 2 +- apps/app/Cargo.toml | 2 +- packages/app-lib/Cargo.toml | 2 +- packages/app-lib/src/state/process.rs | 162 +++++++++++++++++++++----- 5 files changed, 140 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 33eef3a..0f52fb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6841,7 +6841,7 @@ dependencies = [ [[package]] name = "theseus" -version = "1.0.2-beta6" +version = "1.0.2-beta7" dependencies = [ "ariadne", "async-minecraft-ping", @@ -6945,7 +6945,7 @@ dependencies = [ [[package]] name = "theseus_gui" -version = "1.0.2-beta6" +version = "1.0.2-beta7" dependencies = [ "ashpd", "async_zip", diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 669199b..211886e 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -1,7 +1,7 @@ { "name": "@modrinth/app-frontend", "private": true, - "version": "1.0.2-beta6", + "version": "1.0.2-beta7", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml index d428e91..d5b4f48 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "theseus_gui" # The actual version is set by the theseus-build workflow on tagging -version = "1.0.2-beta6" +version = "1.0.2-beta7" edition.workspace = true description = "Axolotl Launcher is a cross-platform Minecraft launcher" homepage = "https://www.ghs.red" diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 05eb233..0e58320 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "theseus" # The actual version is set by the theseus-build workflow on tagging -version = "1.0.2-beta6" +version = "1.0.2-beta7" edition.workspace = true [dependencies] diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs index 5ca6ab4..30ea72b 100644 --- a/packages/app-lib/src/state/process.rs +++ b/packages/app-lib/src/state/process.rs @@ -713,16 +713,37 @@ impl Process { pending.push_str(&String::from_utf8_lossy(&chunk[..read])); - // Drain every complete frame currently buffered. - while let Some(frame) = take_next_log4j_frame(&mut pending) { - Self::handle_log4j_frame( - instance_id, - instance_name, - process_id, - log_path.as_ref(), - &frame, - ) - .await; + // Mods can write plain text to either pipe even when the + // launcher selected XML logging. Forward it as soon as a line + // is complete instead of retaining it until the game exits. + while let Some(output) = take_next_log_output(&mut pending) { + match output { + LogOutput::Xml(frame) => { + Self::handle_log4j_frame( + instance_id, + instance_name, + process_id, + log_path.as_ref(), + &frame, + ) + .await; + } + LogOutput::Legacy(text) => { + if let Err(e) = + Self::append_to_log_file(&log_path, &text) + { + tracing::warn!( + "Failed to write to log file: {e}" + ); + } + if !text.trim_end().is_empty() { + Self::emit_legacy_log( + instance_id, + text.trim_end(), + ); + } + } + } } // Guard against a runaway buffer if no frame delimiters ever @@ -1360,32 +1381,119 @@ impl Process { Ok(()) } } -/// Cuts the next complete `…` frame out of the -/// live buffer and returns it as an owned string, leaving any trailing partial -/// frame in place. -/// -/// Returns `None` when the buffer does not yet contain a full frame. This is a -/// plain string operation on purpose: it never poisons any parser state, so a -/// split or malformed frame on the live pipe cannot stop log forwarding. -fn take_next_log4j_frame(buffer: &mut String) -> Option { +#[derive(Debug, Eq, PartialEq)] +enum LogOutput { + Xml(String), + Legacy(String), +} + +/// Drains complete XML frames or plain text in stream order. Partial frames +/// and plain text lines remain buffered until their next chunk arrives. +fn take_next_log_output(buffer: &mut String) -> Option { const OPEN: &str = " 0 { - buffer.drain(..start); + match buffer.find(OPEN) { + Some(0) => { + let close = buffer.find(CLOSE)?; + let end = close + CLOSE.len(); + Some(LogOutput::Xml(buffer.drain(..end).collect())) + } + Some(start) => Some(LogOutput::Legacy(buffer.drain(..start).collect())), + None => { + let end = buffer.find('\n')? + 1; + Some(LogOutput::Legacy(buffer.drain(..end).collect())) + } } - let close = buffer.find(CLOSE)?; - let end = close + CLOSE.len(); - let frame = buffer[..end].to_string(); - buffer.drain(..end); - Some(frame) } #[cfg(test)] mod post_upgrade_tests { use super::*; + #[test] + fn mixed_log_output_keeps_partial_xml_and_multiline_messages() { + let mut pending = "raw\n\nafter\n"); + assert_eq!(take_next_log_output(&mut pending), Some(LogOutput::Xml("".into()))); + assert_eq!( + take_next_log_output(&mut pending), + Some(LogOutput::Legacy("\n".into())) + ); + assert_eq!( + take_next_log_output(&mut pending), + Some(LogOutput::Legacy("after\n".into())) + ); + assert!(pending.is_empty()); + } + + #[tokio::test] + async fn xml_logging_forwards_plain_text_before_pipe_closes() { + use tokio::io::AsyncWriteExt; + + let instance_id = Uuid::new_v4().to_string(); + let directory = tempfile::tempdir().unwrap(); + let log_path = directory.path().join("live.log"); + let (mut writer, reader) = tokio::io::duplex(1024); + let output = Process::process_output( + &instance_id, + "instance", + "Instance", + "process", + reader, + &log_path, + true, + ); + tokio::pin!(output); + writer.write_all(b"mod output\n").await.unwrap(); + let forwarded = async { + loop { + if get_log_buffer(&instance_id) == ["mod output"] { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }; + tokio::select! { + _ = &mut output => panic!("reader ended while writer is still open"), + result = tokio::time::timeout(std::time::Duration::from_secs(1), forwarded) => { + result.expect("plain text was buffered until EOF"); + } + } + assert_eq!(std::fs::read_to_string(&log_path).unwrap(), "mod output\n"); + drop(writer); + output.await; + remove_log_buffer(&instance_id); + } + + #[tokio::test] + async fn xml_logging_preserves_plain_text_before_xml_frames() { + let instance_id = Uuid::new_v4().to_string(); + let directory = tempfile::tempdir().unwrap(); + let log_path = directory.path().join("mixed.log"); + let input = "mod output\n\n"; + Process::process_output( + &instance_id, + "instance", + "Instance", + "process", + std::io::Cursor::new(input), + &log_path, + true, + ) + .await; + let lines = get_log_buffer(&instance_id); + assert_eq!(lines.first().map(String::as_str), Some("mod output")); + assert!(lines.iter().any(|line| line.contains("xml output"))); + remove_log_buffer(&instance_id); + } + #[test] fn live_log_snapshot_sequences_batches_and_preserves_position_after_clear() {