Compare commits

...
Author SHA1 Message Date
AxTps 0195cbf5ac fix: 修复 collect_crash_context 中凭据收集的类型不匹配
Sync source to CNB / Sync Git ref (push) Skipped
Credentials::get_all 返回 DashMap,直接 collect 会得到
Vec<(Uuid, Credentials)>,与 CrashContext.credentials 的
Vec<Credentials> 不符,导致编译失败。补上与 analyze_crash 一致的
.map(|credential| credential.1)。
2026-10-17 18:16:36 +08:00
AxTps ccb96a8cf1 fix: LogShare 采集改用轻量日志收集,修复崩溃日志上传失败
Sync source to CNB / Sync Git ref (push) Skipped
崩溃弹窗的 LogShare 摘要与 LogAgent 深度分析此前都会失败。原因是
logshare 的 prepare_upload 直接调用完整崩溃分析 analyze_crash,会
额外扫描 mod jar、比对 mod 快照并查询 Windows 事件,在这种大型实例
上容易出错或超时,错误被前端 uploadTicketForInstance 捕获成 null,
最终表现为无法上传/无法准备诊断。

对齐 Axolotl 的做法,新增轻量的 collect_crash_context /
collect_crash_logs,只做路径解析、候选收集、读取与脱敏,不触发
mod 扫描与快照副作用。SourceCandidate / SourceText 补充 upload_name
字段(采集时按目录加 crash-reports/ 前缀),logshare 的 prepare_upload
改调该轻量函数。
2026-10-13 22:01:02 +08:00
Disy 0f35a5f707 fix: 修复实时日志积压并更新至 1.0.2-beta7
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-12 20:00:25 +08:00
7 changed files with 219 additions and 64 deletions

No files matched your search

Generated
+2 -2
View File
@@ -6841,7 +6841,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus" name = "theseus"
version = "1.0.2-beta6" version = "1.0.2-beta7"
dependencies = [ dependencies = [
"ariadne", "ariadne",
"async-minecraft-ping", "async-minecraft-ping",
@@ -6945,7 +6945,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_gui" name = "theseus_gui"
version = "1.0.2-beta6" version = "1.0.2-beta7"
dependencies = [ dependencies = [
"ashpd", "ashpd",
"async_zip", "async_zip",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.2-beta6", "version": "1.0.2-beta7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
# The actual version is set by the theseus-build workflow on tagging # 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 edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher" description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red" homepage = "https://www.ghs.red"
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus" name = "theseus"
# The actual version is set by the theseus-build workflow on tagging # 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 edition.workspace = true
[dependencies] [dependencies]
@@ -109,6 +109,7 @@ pub struct CrashAnalysisMod {
struct SourceCandidate { struct SourceCandidate {
path: PathBuf, path: PathBuf,
filename: String, filename: String,
upload_name: String,
source_type: &'static str, source_type: &'static str,
modified: SystemTime, modified: SystemTime,
} }
@@ -116,10 +117,25 @@ struct SourceCandidate {
#[derive(Debug)] #[derive(Debug)]
struct SourceText { struct SourceText {
filename: String, filename: String,
upload_name: String,
source_type: &'static str, source_type: &'static str,
modified: SystemTime, modified: SystemTime,
content: String, content: String,
} }
pub(super) struct CollectedCrashLog {
pub name: String,
pub content: CensoredString,
}
pub(super) struct CollectedCrashLogs {
pub combined_log: CensoredString,
pub sources: Vec<CollectedCrashLog>,
}
struct CrashContext {
sources: Vec<SourceText>,
credentials: Vec<Credentials>,
}
struct Rule { struct Rule {
id: &'static str, id: &'static str,
@@ -1185,20 +1201,25 @@ async fn collect_candidates(
) -> crate::Result<Vec<SourceCandidate>> { ) -> crate::Result<Vec<SourceCandidate>> {
let mut candidates = Vec::new(); let mut candidates = Vec::new();
let mut locations = vec![ let mut locations = vec![
(directories.game_logs_dir(instance_root), "minecraft_log"), (
directories.game_logs_dir(instance_root),
"minecraft_log",
None,
),
( (
directories.game_crash_reports_dir(instance_root), directories.game_crash_reports_dir(instance_root),
"crash_report", "crash_report",
Some("crash-reports"),
), ),
(instance_root.to_path_buf(), "instance_log"), (instance_root.to_path_buf(), "instance_log", None),
]; ];
if let Some(dot_minecraft) = isolated_minecraft_root(instance_root) { if let Some(dot_minecraft) = isolated_minecraft_root(instance_root) {
// PCL-CE also searches the shared `.minecraft` root for JVM crash // PCL-CE also searches the shared `.minecraft` root for JVM crash
// logs when a version-isolated instance is active. // logs when a version-isolated instance is active.
locations.push((dot_minecraft, "minecraft_root_log")); locations.push((dot_minecraft, "minecraft_root_log", None));
} }
for (directory, default_type) in locations { for (directory, default_type, upload_prefix) in locations {
if !tokio::fs::try_exists(&directory).await? { if !tokio::fs::try_exists(&directory).await? {
continue; continue;
} }
@@ -1248,6 +1269,9 @@ async fn collect_candidates(
}; };
candidates.push(SourceCandidate { candidates.push(SourceCandidate {
path: entry.path(), path: entry.path(),
upload_name: upload_prefix
.map(|prefix| format!("{prefix}/{filename}"))
.unwrap_or_else(|| filename.clone()),
filename, filename,
source_type, source_type,
modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
@@ -1257,6 +1281,53 @@ async fn collect_candidates(
Ok(candidates) Ok(candidates)
} }
async fn collect_crash_context(
instance_id: &str,
) -> crate::Result<CrashContext> {
let state = State::get().await?;
let (instance_path, game_dir_override) =
resolve_instance_path(instance_id, &state).await?;
let instance_root = state
.directories
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
let candidates =
collect_candidates(&instance_root, &state.directories).await?;
let selected = select_run_candidates(candidates);
let sources = read_sources(selected).await;
let credentials = Credentials::get_all(&state.pool)
.await?
.into_iter()
.map(|credential| credential.1)
.collect::<Vec<_>>();
Ok(CrashContext {
sources,
credentials,
})
}
pub(super) async fn collect_crash_logs(
instance_id: &str,
) -> crate::Result<CollectedCrashLogs> {
let CrashContext {
sources,
credentials,
..
} = collect_crash_context(instance_id).await?;
let combined_log =
CensoredString::censor(build_combined_log(&sources), &credentials);
let sources = sources
.into_iter()
.map(|source| CollectedCrashLog {
name: source.upload_name,
content: CensoredString::censor(source.content, &credentials),
})
.collect();
Ok(CollectedCrashLogs {
combined_log,
sources,
})
}
fn isolated_minecraft_root(instance_root: &Path) -> Option<PathBuf> { fn isolated_minecraft_root(instance_root: &Path) -> Option<PathBuf> {
let versions_dir = instance_root.parent()?; let versions_dir = instance_root.parent()?;
if versions_dir if versions_dir
@@ -1320,6 +1391,7 @@ async fn read_sources(candidates: Vec<SourceCandidate>) -> Vec<SourceText> {
}; };
sources.push(SourceText { sources.push(SourceText {
filename: candidate.filename, filename: candidate.filename,
upload_name: candidate.upload_name,
source_type: candidate.source_type, source_type: candidate.source_type,
modified: candidate.modified, modified: candidate.modified,
content: String::from_utf8_lossy(bytes).into_owned(), content: String::from_utf8_lossy(bytes).into_owned(),
@@ -1779,6 +1851,7 @@ mod tests {
fn source(filename: &str, content: &str) -> SourceText { fn source(filename: &str, content: &str) -> SourceText {
SourceText { SourceText {
filename: filename.to_string(), filename: filename.to_string(),
upload_name: filename.to_string(),
source_type: "minecraft_log", source_type: "minecraft_log",
modified: SystemTime::now(), modified: SystemTime::now(),
content: content.to_string(), content: content.to_string(),
@@ -1802,6 +1875,7 @@ mod tests {
let candidates = vec![SourceCandidate { let candidates = vec![SourceCandidate {
path: PathBuf::from("launcher_log.txt"), path: PathBuf::from("launcher_log.txt"),
filename: "launcher_log.txt".to_string(), filename: "launcher_log.txt".to_string(),
upload_name: "launcher_log.txt".to_string(),
source_type: "minecraft_log", source_type: "minecraft_log",
modified: SystemTime::now() - Duration::from_secs(181), modified: SystemTime::now() - Duration::from_secs(181),
}]; }];
+1 -28
View File
@@ -4,8 +4,7 @@ use futures::StreamExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use super::CensoredString; use super::crash_analysis::collect_crash_logs;
use super::crash_analysis::analyze_crash;
use crate::State; use crate::State;
use crate::emit_logshare_ai_event; use crate::emit_logshare_ai_event;
@@ -200,32 +199,6 @@ fn source_identifier() -> String {
format!("{LOGSHARE_SOURCE_PREFIX}/{}", env!("CARGO_PKG_VERSION")) format!("{LOGSHARE_SOURCE_PREFIX}/{}", env!("CARGO_PKG_VERSION"))
} }
struct CollectedCrashLog {
name: String,
content: CensoredString,
}
struct CollectedCrashLogs {
combined_log: CensoredString,
sources: Vec<CollectedCrashLog>,
}
async fn collect_crash_logs(
instance_id: &str,
) -> crate::Result<CollectedCrashLogs> {
let analysis = analyze_crash(instance_id).await?;
Ok(CollectedCrashLogs {
combined_log: analysis.combined_log,
sources: analysis
.sources
.into_iter()
.map(|source| CollectedCrashLog {
name: source.filename,
content: source.content,
})
.collect(),
})
}
async fn prepare_upload(instance_id: &str) -> crate::Result<UploadBody> { async fn prepare_upload(instance_id: &str) -> crate::Result<UploadBody> {
let settings = get_log_share_settings().await?; let settings = get_log_share_settings().await?;
let logs = collect_crash_logs(instance_id).await?; let logs = collect_crash_logs(instance_id).await?;
+135 -27
View File
@@ -713,16 +713,37 @@ impl Process {
pending.push_str(&String::from_utf8_lossy(&chunk[..read])); pending.push_str(&String::from_utf8_lossy(&chunk[..read]));
// Drain every complete frame currently buffered. // Mods can write plain text to either pipe even when the
while let Some(frame) = take_next_log4j_frame(&mut pending) { // launcher selected XML logging. Forward it as soon as a line
Self::handle_log4j_frame( // is complete instead of retaining it until the game exits.
instance_id, while let Some(output) = take_next_log_output(&mut pending) {
instance_name, match output {
process_id, LogOutput::Xml(frame) => {
log_path.as_ref(), Self::handle_log4j_frame(
&frame, instance_id,
) instance_name,
.await; 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 // Guard against a runaway buffer if no frame delimiters ever
@@ -1360,32 +1381,119 @@ impl Process {
Ok(()) Ok(())
} }
} }
/// Cuts the next complete `<log4j:Event …>…</log4j:Event>` frame out of the #[derive(Debug, Eq, PartialEq)]
/// live buffer and returns it as an owned string, leaving any trailing partial enum LogOutput {
/// frame in place. Xml(String),
/// Legacy(String),
/// 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. /// Drains complete XML frames or plain text in stream order. Partial frames
fn take_next_log4j_frame(buffer: &mut String) -> Option<String> { /// and plain text lines remain buffered until their next chunk arrives.
fn take_next_log_output(buffer: &mut String) -> Option<LogOutput> {
const OPEN: &str = "<log4j:Event"; const OPEN: &str = "<log4j:Event";
const CLOSE: &str = "</log4j:Event>"; const CLOSE: &str = "</log4j:Event>";
let start = buffer.find(OPEN)?; match buffer.find(OPEN) {
// Discard anything before the frame (raw text, XML prolog, …). Some(0) => {
if start > 0 { let close = buffer.find(CLOSE)?;
buffer.drain(..start); 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)] #[cfg(test)]
mod post_upgrade_tests { mod post_upgrade_tests {
use super::*; use super::*;
#[test]
fn mixed_log_output_keeps_partial_xml_and_multiline_messages() {
let mut pending = "raw\n<log4j:Ev".to_string();
assert_eq!(
take_next_log_output(&mut pending),
Some(LogOutput::Legacy("raw\n".into()))
);
assert_eq!(take_next_log_output(&mut pending), None);
pending.push_str("ent><log4j:Message><![CDATA[first\nsecond");
assert_eq!(take_next_log_output(&mut pending), None);
pending.push_str("]]></log4j:Message></log4j:Event>\nafter\n");
assert_eq!(take_next_log_output(&mut pending), Some(LogOutput::Xml("<log4j:Event><log4j:Message><![CDATA[first\nsecond]]></log4j:Message></log4j:Event>".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<log4j:Event level=\"INFO\"><log4j:Message><![CDATA[xml output]]></log4j:Message></log4j:Event>\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] #[test]
fn live_log_snapshot_sequences_batches_and_preserves_position_after_clear() fn live_log_snapshot_sequences_batches_and_preserves_position_after_clear()
{ {