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 = "";
- let start = buffer.find(OPEN)?;
- // Discard anything before the frame (raw text, XML prolog, …).
- if start > 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()
{