Files
Starlight_Lancher/packages/app-lib/src/api/logs.rs
T
Disy 19098f7159
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
fix: follow live logs and reconcile duplicate managed mods
2026-09-24 22:36:21 +08:00

1102 lines
35 KiB
Rust

use std::fmt::Write as _;
use std::io::{BufRead, Read, SeekFrom};
use std::path::Path;
use std::time::SystemTime;
use futures::TryFutureExt;
use serde::{Deserialize, Serialize};
use tokio::{
fs::File,
io::{AsyncReadExt, AsyncSeekExt},
};
use crate::{
State,
prelude::Credentials,
state::instances::adapters::sqlite::instance_rows,
util::io::{self, IOError},
};
mod crash_analysis;
pub use crash_analysis::{
CrashAnalysis, CrashAnalysisAiExplanation, CrashAnalysisAiSettings,
CrashAnalysisEvidence, CrashAnalysisFinding, CrashAnalysisMod,
CrashAnalysisSource, CrashModChange, CrashModChangeCounts, analyze_crash,
explain_crash_with_ai, get_crash_analysis_ai_settings,
save_successful_mod_snapshot, undo_added_mod,
update_crash_analysis_ai_settings,
};
#[derive(Serialize, Debug)]
pub struct Logs {
pub log_type: LogType,
pub filename: String,
pub age: u64,
pub output: Option<CensoredString>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq)]
pub enum LogType {
InfoLog,
CrashReport,
}
const LOG_COMPACTION_THRESHOLD: usize = 20;
const MAX_LOG_DISPLAY_BYTES: usize = 2 * 1024 * 1024;
const MAX_LOG_DISPLAY_LINE_BYTES: usize = 64 * 1024;
const LOG_DISPLAY_TRUNCATION_MARKER: &str =
"\n… [log display truncated by Axolotl] …\n";
#[derive(Serialize, Debug)]
pub struct LatestLogCursor {
pub cursor: u64,
pub output: CensoredString,
pub new_file: bool,
}
#[derive(Serialize, Debug)] // Not deserialize
#[serde(transparent)]
pub struct CensoredString(String);
impl CensoredString {
pub fn censor(mut s: String, credentials_list: &[Credentials]) -> Self {
let username = whoami::username();
s = s
.replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/")
.replace(&format!("\\{username}\\"), "\\{COMPUTER_USERNAME}\\");
for credentials in credentials_list {
// Use the offline profile to guarantee that this function does not cause
// Mojang API request, and is never delayed by a network request. The offline
// profile is optimistically updated on upsert from time to time anyway
if credentials.access_token.len() >= 8 {
s = s.replace(
&credentials.access_token,
"{MINECRAFT_ACCESS_TOKEN}",
);
}
s = s
.replace(
&credentials.offline_profile.name,
"{MINECRAFT_USERNAME}",
)
.replace(
&credentials.offline_profile.id.as_simple().to_string(),
"{MINECRAFT_UUID}",
)
.replace(
&credentials.offline_profile.id.as_hyphenated().to_string(),
"{MINECRAFT_UUID}",
);
}
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, Default)]
struct LogCompactionStats {
compacted_runs: usize,
compacted_lines: usize,
}
struct CompactedLog {
output: String,
stats: LogCompactionStats,
display_truncated: bool,
}
async fn resolve_instance_path(
instance: &str,
state: &State,
) -> crate::Result<(String, Option<String>)> {
let instance =
match instance_rows::get_instance_by_id(instance, &state.pool).await? {
Some(instance) => instance,
None => {
// Preserve the historical id-or-path lookup, with id winning.
match instance_rows::get_instance_by_path(instance, &state.pool)
.await?
{
Some(instance) => instance,
None => {
return Err(crate::ErrorKind::InputError(format!(
"Unknown instance id or path: {instance}"
))
.as_error());
}
}
}
};
// Directly associated instances have no profile folder of their own:
// their logs and crash reports live inside the externally managed
// installation, at the real game directory the launch runs from. That
// path is absolute, so `resolve_game_dir` returns it unchanged and
// `instance_logs_dir` / `crash_reports_dir` naturally land on
// `<game>/logs` / `<game>/crash-reports`.
if instance.is_direct_linked() {
if let Some(game_dir) = crate::launcher::linked_game_dir(&instance) {
return Ok((game_dir.to_string_lossy().into_owned(), None));
}
tracing::warn!(
instance_id = %instance.id,
"Directly linked instance metadata is incomplete; falling back \
to the recorded linked `.minecraft` root for log resolution"
);
if let Some(linked) = instance
.linked_dot_minecraft
.as_deref()
.map(str::trim)
.filter(|linked| !linked.is_empty())
{
return Ok((linked.to_string(), None));
}
}
Ok((instance.path, instance.game_dir_override))
}
fn split_line_ending(line: &str) -> (&str, &str) {
if let Some(line) = line.strip_suffix("\r\n") {
(line, "\r\n")
} else if let Some(line) = line.strip_suffix('\n') {
(line, "\n")
} else if let Some(line) = line.strip_suffix('\r') {
(line, "\r")
} else {
(line, "")
}
}
fn push_compacted_log_run(
output: &mut String,
stats: &mut LogCompactionStats,
line: &str,
line_ending: &str,
count: usize,
) {
if count >= LOG_COMPACTION_THRESHOLD {
output.push_str(line);
let _ =
write!(output, " (x{count} times - compacted by Axolotl Launcher)");
output.push_str(line_ending);
stats.compacted_runs += 1;
stats.compacted_lines += count;
} else {
for _ in 0..count {
output.push_str(line);
output.push_str(line_ending);
}
}
}
fn read_compacted_log<R: BufRead>(
reader: &mut R,
) -> std::io::Result<CompactedLog> {
let mut output = String::new();
let mut stats = LogCompactionStats::default();
let mut buffer = Vec::new();
let mut current_line: Option<String> = None;
let mut current_line_ending = String::new();
let mut current_count = 0usize;
loop {
buffer.clear();
let bytes_read = reader.read_until(b'\n', &mut buffer)?;
if bytes_read == 0 {
break;
}
let line = String::from_utf8_lossy(&buffer);
let (line, line_ending) = split_line_ending(&line);
match current_line.as_deref() {
Some(current) if current == line => {
current_count += 1;
if current_line_ending.is_empty() && !line_ending.is_empty() {
current_line_ending = line_ending.to_string();
}
}
_ => {
if let Some(current) = current_line.take() {
push_compacted_log_run(
&mut output,
&mut stats,
&current,
&current_line_ending,
current_count,
);
}
current_line = Some(line.to_string());
current_line_ending = line_ending.to_string();
current_count = 1;
}
}
}
if let Some(current) = current_line {
push_compacted_log_run(
&mut output,
&mut stats,
&current,
&current_line_ending,
current_count,
);
}
Ok(CompactedLog {
output,
stats,
display_truncated: false,
})
}
fn compact_duplicate_lines(input: &str) -> CompactedLog {
let mut reader = std::io::Cursor::new(input.as_bytes());
read_compacted_log(&mut reader)
.expect("compacting an in-memory log should not fail")
}
#[cfg(test)]
fn compact_log_for_display(input: &str) -> CompactedLog {
cap_log_for_display(compact_duplicate_lines(input), false)
}
fn truncate_display_text(value: &str, max_bytes: usize) -> String {
if value.len() <= max_bytes {
return value.to_string();
}
let retained_bytes =
max_bytes.saturating_sub(LOG_DISPLAY_TRUNCATION_MARKER.len());
let prefix_end = char_boundary_before(value, retained_bytes / 2);
let suffix_start = char_boundary_after(
value,
value.len().saturating_sub(retained_bytes - prefix_end),
);
format!(
"{}{}{}",
&value[..prefix_end],
LOG_DISPLAY_TRUNCATION_MARKER,
&value[suffix_start..]
)
}
fn char_boundary_before(value: &str, mut index: usize) -> usize {
index = index.min(value.len());
while index > 0 && !value.is_char_boundary(index) {
index -= 1;
}
index
}
fn char_boundary_after(value: &str, mut index: usize) -> usize {
index = index.min(value.len());
while index < value.len() && !value.is_char_boundary(index) {
index += 1;
}
index
}
fn cap_log_for_display(
mut compacted: CompactedLog,
source_truncated: bool,
) -> CompactedLog {
let mut output = String::with_capacity(
compacted.output.len().min(MAX_LOG_DISPLAY_BYTES),
);
let mut truncated = source_truncated;
for original_line in compacted.output.split_inclusive('\n') {
let line =
truncate_display_text(original_line, MAX_LOG_DISPLAY_LINE_BYTES);
truncated |= line.len() != original_line.len();
if output.len().saturating_add(line.len())
> MAX_LOG_DISPLAY_BYTES
.saturating_sub(LOG_DISPLAY_TRUNCATION_MARKER.len())
{
truncated = true;
break;
}
output.push_str(&line);
}
if truncated {
output.push_str(LOG_DISPLAY_TRUNCATION_MARKER);
}
compacted.output = output;
compacted.display_truncated = truncated;
compacted
}
fn read_limited_compacted_log<R: Read>(
reader: &mut R,
) -> std::io::Result<CompactedLog> {
let mut bytes = Vec::with_capacity(MAX_LOG_DISPLAY_BYTES);
reader
.take((MAX_LOG_DISPLAY_BYTES + 1) as u64)
.read_to_end(&mut bytes)?;
let source_truncated = bytes.len() > MAX_LOG_DISPLAY_BYTES;
bytes.truncate(MAX_LOG_DISPLAY_BYTES);
let input = String::from_utf8_lossy(&bytes);
Ok(cap_log_for_display(
compact_duplicate_lines(&input),
source_truncated,
))
}
fn format_count(count: usize) -> String {
let raw = count.to_string();
let mut formatted = String::with_capacity(raw.len() + raw.len() / 3);
for (index, character) in raw.chars().enumerate() {
if index > 0 && (raw.len() - index).is_multiple_of(3) {
formatted.push(',');
}
formatted.push(character);
}
formatted
}
async fn maybe_emit_log_compaction_warning(
file_name: &str,
stats: LogCompactionStats,
) {
if stats.compacted_runs == 0 {
return;
}
let _ = crate::event::emit::emit_warning(&format!(
"Axolotl Launcher has compacted {} repeated log lines in {} before displaying it for performance reasons.",
format_count(stats.compacted_lines),
file_name,
))
.await;
}
async fn maybe_emit_log_display_truncation_warning(
file_name: &str,
truncated: bool,
) {
if !truncated {
return;
}
let _ = crate::event::emit::emit_warning(&format!(
"Axolotl Launcher truncated {} before displaying it to keep the console responsive. The original log file remains unchanged.",
file_name,
))
.await;
}
impl Logs {
async fn build(
log_type: LogType,
age: SystemTime,
game_dir: &Path,
filename: String,
clear_contents: Option<bool>,
) -> crate::Result<Self> {
Ok(Self {
log_type,
age: age
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_else(|_| std::time::Duration::from_secs(0))
.as_secs(),
output: if clear_contents.unwrap_or(false) {
None
} else {
let state = State::get().await?;
Some(
get_output_by_filename_from_path(
&state, game_dir, log_type, &filename,
)
.await?,
)
},
filename,
})
}
}
#[tracing::instrument]
pub async fn get_logs_from_type(
instance_id: &str,
log_type: LogType,
clear_contents: Option<bool>,
logs: &mut Vec<crate::Result<Logs>>,
) -> crate::Result<()> {
let state = State::get().await?;
let (instance_path, game_dir_override) =
resolve_instance_path(instance_id, &state).await?;
let game_dir = state
.directories
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
let logs_folder = match log_type {
LogType::InfoLog => state.directories.game_logs_dir(&game_dir),
LogType::CrashReport => {
state.directories.game_crash_reports_dir(&game_dir)
}
};
if logs_folder.exists() {
for entry in std::fs::read_dir(&logs_folder)
.map_err(|e| IOError::with_path(e, &logs_folder))?
{
let entry: std::fs::DirEntry =
entry.map_err(|e| IOError::with_path(e, &logs_folder))?;
let age = entry
.metadata()?
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH);
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(file_name) = path.file_name() {
let file_name = file_name.to_string_lossy().to_string();
logs.push(
Logs::build(
log_type,
age,
&game_dir,
file_name,
clear_contents,
)
.await,
);
}
}
}
Ok(())
}
#[tracing::instrument]
pub async fn get_logs(
instance_id: &str,
clear_contents: Option<bool>,
) -> crate::Result<Vec<Logs>> {
let mut logs = Vec::new();
get_logs_from_type(
instance_id,
LogType::InfoLog,
clear_contents,
&mut logs,
)
.await?;
get_logs_from_type(
instance_id,
LogType::CrashReport,
clear_contents,
&mut logs,
)
.await?;
let mut logs = logs.into_iter().collect::<crate::Result<Vec<Logs>>>()?;
logs.sort_by(|a, b| b.age.cmp(&a.age).then(b.filename.cmp(&a.filename)));
Ok(logs)
}
#[tracing::instrument]
pub async fn get_logs_by_filename(
instance_id: &str,
log_type: LogType,
filename: String,
) -> crate::Result<Logs> {
let state = State::get().await?;
let (instance_path, game_dir_override) =
resolve_instance_path(instance_id, &state).await?;
let game_dir = state
.directories
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
let path = match log_type {
LogType::InfoLog => state.directories.game_logs_dir(&game_dir),
LogType::CrashReport => {
state.directories.game_crash_reports_dir(&game_dir)
}
}
.join(&filename);
let metadata = std::fs::metadata(&path)?;
let age = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
Logs::build(log_type, age, &game_dir, filename, Some(true)).await
}
async fn get_output_by_filename_from_path(
state: &State,
game_dir: &Path,
log_type: LogType,
file_name: &str,
) -> crate::Result<CensoredString> {
let logs_folder = match log_type {
LogType::InfoLog => state.directories.game_logs_dir(game_dir),
LogType::CrashReport => {
state.directories.game_crash_reports_dir(game_dir)
}
};
let path = logs_folder.join(file_name);
let credentials = Credentials::get_all(&state.pool)
.await?
.into_iter()
.map(|x| x.1)
.collect::<Vec<_>>();
if let Some(ext) = path.extension() {
if ext == "gz" {
let file = std::fs::File::open(&path)
.map_err(|e| IOError::with_path(e, &path))?;
let gz =
flate2::read::GzDecoder::new(std::io::BufReader::new(file));
let mut reader = std::io::BufReader::new(gz);
let compacted = read_limited_compacted_log(&mut reader)
.map_err(|e| IOError::with_path(e, &path))?;
maybe_emit_log_compaction_warning(file_name, compacted.stats).await;
maybe_emit_log_display_truncation_warning(
file_name,
compacted.display_truncated,
)
.await;
return Ok(CensoredString::censor(compacted.output, &credentials));
} else if ext == "log" || ext == "txt" {
let file = std::fs::File::open(&path)
.map_err(|e| IOError::with_path(e, &path))?;
let mut reader = std::io::BufReader::new(file);
let compacted = read_limited_compacted_log(&mut reader)
.map_err(|e| IOError::with_path(e, &path))?;
maybe_emit_log_compaction_warning(file_name, compacted.stats).await;
maybe_emit_log_display_truncation_warning(
file_name,
compacted.display_truncated,
)
.await;
return Ok(CensoredString::censor(compacted.output, &credentials));
}
}
Err(crate::ErrorKind::OtherError(format!(
"File extension not supported: {}",
path.display()
))
.into())
}
#[tracing::instrument]
pub async fn get_output_by_filename(
instance_id: &str,
log_type: LogType,
file_name: &str,
) -> crate::Result<CensoredString> {
let state = State::get().await?;
let (instance_path, game_dir_override) =
resolve_instance_path(instance_id, &state).await?;
let game_dir = state
.directories
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
get_output_by_filename_from_path(&state, &game_dir, log_type, file_name)
.await
}
#[tracing::instrument]
pub async fn delete_logs(instance_id: &str) -> crate::Result<()> {
let state = State::get().await?;
let (instance_path, game_dir_override) =
resolve_instance_path(instance_id, &state).await?;
let game_dir = state
.directories
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
let logs_folder = state.directories.game_logs_dir(&game_dir);
for entry in std::fs::read_dir(&logs_folder)
.map_err(|e| IOError::with_path(e, &logs_folder))?
{
let entry = entry.map_err(|e| IOError::with_path(e, &logs_folder))?;
let path = entry.path();
if path.is_dir() {
io::remove_dir_all(&path).await?;
}
}
Ok(())
}
#[tracing::instrument]
pub async fn delete_logs_by_filename(
instance_id: &str,
log_type: LogType,
filename: &str,
) -> crate::Result<()> {
let state = State::get().await?;
let (instance_path, game_dir_override) =
resolve_instance_path(instance_id, &state).await?;
let game_dir = state
.directories
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
let logs_folder = match log_type {
LogType::InfoLog => state.directories.game_logs_dir(&game_dir),
LogType::CrashReport => {
state.directories.game_crash_reports_dir(&game_dir)
}
};
let path = logs_folder.join(filename);
io::remove_file(&path).await?;
Ok(())
}
#[tracing::instrument]
pub async fn get_live_log_buffer(
instance_id: &str,
) -> crate::Result<CensoredString> {
let state = State::get().await?;
let lines = crate::state::get_log_buffer(instance_id);
let joined = lines.join("\n");
let compacted =
cap_log_for_display(compact_duplicate_lines(&joined), false);
let credentials = Credentials::get_all(&state.pool)
.await?
.into_iter()
.map(|x| x.1)
.collect::<Vec<_>>();
maybe_emit_log_compaction_warning("live log", compacted.stats).await;
maybe_emit_log_display_truncation_warning(
"live log",
compacted.display_truncated,
)
.await;
Ok(CensoredString::censor(compacted.output, &credentials))
}
pub fn clear_live_log_buffer(instance_id: &str) {
crate::state::remove_log_buffer(instance_id);
}
#[tracing::instrument]
pub async fn get_latest_log_cursor(
instance_id: &str,
cursor: u64, // 0 to start at beginning of file
) -> crate::Result<LatestLogCursor> {
get_generic_live_log_cursor(instance_id, "launcher_log.txt", cursor).await
}
#[tracing::instrument]
pub async fn get_generic_live_log_cursor(
instance_id: &str,
log_file_name: &str,
mut cursor: u64, // 0 to start at beginning of file
) -> crate::Result<LatestLogCursor> {
let state = State::get().await?;
let (instance_path, game_dir_override) =
resolve_instance_path(instance_id, &state).await?;
let game_dir = state
.directories
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
let logs_folder = state.directories.game_logs_dir(&game_dir);
let path = logs_folder.join(log_file_name);
if !path.exists() {
// Allow silent failure if latest.log doesn't exist (as the instance may have been launched, but not yet created the file)
return Ok(LatestLogCursor {
cursor: 0,
new_file: false,
output: CensoredString("".to_string()),
});
}
let mut file = File::open(&path)
.await
.map_err(|e| IOError::with_path(e, &path))?;
let metadata = file
.metadata()
.await
.map_err(|e| IOError::with_path(e, &path))?;
let mut new_file = false;
if cursor > metadata.len() {
// Cursor is greater than file length, reset cursor to 0
// Likely cause is that the file was rotated while the log was being read
cursor = 0;
new_file = true;
}
let requested_start = cursor;
let unread_bytes = metadata.len().saturating_sub(cursor);
let display_start = if unread_bytes > MAX_LOG_DISPLAY_BYTES as u64 {
metadata.len().saturating_sub(MAX_LOG_DISPLAY_BYTES as u64)
} else {
cursor
};
let source_truncated = display_start != requested_start;
let mut buffer = Vec::with_capacity(MAX_LOG_DISPLAY_BYTES);
file.seek(SeekFrom::Start(display_start))
.map_err(|e| IOError::with_path(e, &path))
.await?; // Seek to cursor
// Snapshot the read length: bytes appended during this read belong to the
// next request, not to a cursor captured before they existed.
file.take(metadata.len().saturating_sub(display_start))
.read_to_end(&mut buffer)
.map_err(|e| IOError::with_path(e, &path))
.await?; // Read to end of file
// Censor complete records, not halves of a credential split across file
// writes. After the process exits, also expose its unterminated final line.
if state.process_manager.get_all().iter().any(|process| {
process.instance_id == instance_id
|| process.instance_path == instance_path
}) {
if let Some(end) = buffer.iter().rposition(|byte| *byte == b'\n') {
buffer.truncate(end + 1);
} else if buffer.len() < MAX_LOG_DISPLAY_BYTES {
buffer.clear();
}
}
// Leave an incomplete UTF-8 character for the next read (e.g. a split CJK
// character). Invalid complete sequences still use lossy decoding.
if let Err(error) = std::str::from_utf8(&buffer)
&& error.error_len().is_none()
{
buffer.truncate(error.valid_up_to());
}
let cursor = display_start + buffer.len() as u64;
let output = String::from_utf8_lossy(&buffer); // Convert to String
let compacted =
cap_log_for_display(compact_duplicate_lines(&output), source_truncated);
let credentials = Credentials::get_all(&state.pool)
.await?
.into_iter()
.map(|x| x.1)
.collect::<Vec<_>>();
maybe_emit_log_compaction_warning(log_file_name, compacted.stats).await;
maybe_emit_log_display_truncation_warning(
log_file_name,
compacted.display_truncated,
)
.await;
let output = CensoredString::censor(compacted.output, &credentials);
Ok(LatestLogCursor {
cursor,
new_file,
output,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::CreateDirectLinkInstance;
use std::sync::Arc;
use tempfile::TempDir;
/// The launcher state is a process-wide singleton; initialize it once and
/// reuse it so `State::get()` resolves inside these APIs. The state root
/// is intentionally leaked (`.keep()`) because the shared state outlives
/// this function.
async fn global_state() -> Arc<State> {
if !State::initialized() {
let root = TempDir::new().unwrap().keep();
let _ =
State::init_for_test(root.to_string_lossy().to_string()).await;
}
State::get().await.unwrap()
}
/// Creates a directly associated instance whose linked `.minecraft`
/// lives in a fresh temp dir (generic dialect: launches from the linked
/// `.minecraft/versions/<id>` directory).
async fn create_direct_link_fixture(
label: &str,
) -> (TempDir, crate::state::InstanceMetadata) {
let state = global_state().await;
let minecraft = TempDir::new().unwrap();
let version_dir = minecraft.path().join("versions").join(label);
std::fs::create_dir_all(&version_dir).unwrap();
std::fs::write(
version_dir.join(format!("{label}.json")),
serde_json::to_vec_pretty(&serde_json::json!({
"id": label,
"inheritsFrom": "1.20.1",
"mainClass": "net.minecraft.client.main.Main"
}))
.unwrap(),
)
.unwrap();
let instance = crate::state::create_direct_link_instance(
CreateDirectLinkInstance {
name: None,
launcher_type:
crate::api::pack::import::ImportLauncherType::Generic,
base_path: minecraft.path().to_path_buf(),
instance_folder: format!("versions/{label}"),
instance_path: None,
game_dir_mode: None,
},
&state,
)
.await
.unwrap();
let metadata = crate::state::get_instance(&instance.id, &state.pool)
.await
.unwrap()
.expect("metadata for created fixture");
(minecraft, metadata)
}
#[tokio::test]
async fn live_cursor_follows_file_appends_and_split_utf8() {
let (_minecraft, metadata) =
create_direct_link_fixture("logs-live-cursor").await;
let state = global_state().await;
let (path, game_dir_override) =
resolve_instance_path(&metadata.instance.id, &state)
.await
.unwrap();
let game_dir = state
.directories
.resolve_game_dir(&path, game_dir_override.as_deref());
let logs = state.directories.game_logs_dir(&game_dir);
std::fs::create_dir_all(&logs).unwrap();
let path = logs.join("latest.log");
std::fs::write(&path, b"startup\n").unwrap();
let first =
get_generic_live_log_cursor(&metadata.instance.id, "latest.log", 0)
.await
.unwrap();
assert_eq!(first.output.0, "startup\n");
let mut output = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
let message = "进入世界\n".as_bytes();
// A file write ends in the middle of a Chinese character.
std::io::Write::write_all(&mut output, &message[..2]).unwrap();
let partial = get_generic_live_log_cursor(
&metadata.instance.id,
"latest.log",
first.cursor,
)
.await
.unwrap();
assert_eq!(partial.cursor, first.cursor);
assert!(partial.output.0.is_empty());
std::io::Write::write_all(&mut output, &message[2..]).unwrap();
let next = get_generic_live_log_cursor(
&metadata.instance.id,
"latest.log",
partial.cursor,
)
.await
.unwrap();
assert_eq!(next.output.0, "进入世界\n");
let idle = get_generic_live_log_cursor(
&metadata.instance.id,
"latest.log",
next.cursor,
)
.await
.unwrap();
assert!(idle.output.0.is_empty());
drop(output);
std::fs::write(&path, b"restart\n").unwrap();
let restarted = get_generic_live_log_cursor(
&metadata.instance.id,
"latest.log",
next.cursor,
)
.await
.unwrap();
assert!(restarted.new_file);
assert_eq!(restarted.output.0, "restart\n");
}
#[tokio::test]
async fn direct_link_instance_resolves_to_linked_game_dir() {
let state = global_state().await;
let (minecraft, metadata) =
create_direct_link_fixture("logs-resolve").await;
let (resolved, game_dir_override) =
resolve_instance_path(&metadata.instance.id, &state)
.await
.unwrap();
// The generic dialect resolves to the isolated version directory; the
// absolute path keeps helpers inside the linked installation instead
// of a ghost `profiles/<path>` folder.
assert_eq!(
resolved,
minecraft
.path()
.join("versions")
.join("logs-resolve")
.to_string_lossy()
);
assert!(game_dir_override.is_none());
assert_eq!(
state.directories.instance_logs_dir(&resolved),
minecraft
.path()
.join("versions")
.join("logs-resolve")
.join("logs")
);
assert_eq!(
state.directories.crash_reports_dir(&resolved),
minecraft
.path()
.join("versions")
.join("logs-resolve")
.join("crash-reports")
);
assert!(
!state
.directories
.instances_dir()
.join(&metadata.instance.path)
.exists(),
"no profile directory may be created for a direct-link instance"
);
}
#[test]
fn display_compaction_bounds_oversized_nbt_line() {
let log = format!("NBT: {}", "你".repeat(100_000));
let compacted = compact_log_for_display(&log);
assert!(compacted.display_truncated);
assert!(compacted.output.len() <= MAX_LOG_DISPLAY_BYTES);
assert!(compacted.output.contains("truncated by Axolotl"));
assert!(compacted.output.is_char_boundary(compacted.output.len()));
}
#[test]
fn display_compaction_bounds_many_distinct_lines() {
let log = (0..300_000)
.map(|index| format!("line-{index}\n"))
.collect::<String>();
assert!(log.len() > MAX_LOG_DISPLAY_BYTES);
let compacted = compact_log_for_display(&log);
assert!(compacted.display_truncated);
assert!(compacted.output.len() <= MAX_LOG_DISPLAY_BYTES);
}
#[tokio::test]
async fn ordinary_instance_still_resolves_to_relative_path() {
let state = global_state().await;
let metadata = crate::api::instance::create(
format!("logs-normal {}", uuid::Uuid::new_v4()),
"1.20.1".to_string(),
crate::state::ModLoader::Vanilla,
None,
None,
crate::state::InstanceLink::Unmanaged,
None,
None,
)
.await
.unwrap();
let (resolved, game_dir_override) =
resolve_instance_path(&metadata.instance.id, &state)
.await
.unwrap();
assert_eq!(
resolved, metadata.instance.path,
"ordinary instances keep resolving through their relative profile path"
);
assert!(game_dir_override.is_none());
}
#[tokio::test]
async fn direct_link_instance_logs_are_enumerated_from_linked_root() {
let state = global_state().await;
let (minecraft, metadata) =
create_direct_link_fixture("logs-enum").await;
let version_dir = minecraft.path().join("versions").join("logs-enum");
std::fs::create_dir_all(version_dir.join("logs")).unwrap();
std::fs::write(
version_dir.join("logs").join("latest.log"),
b"[12:00:00] [Render thread/INFO]: Setting user\n",
)
.unwrap();
std::fs::create_dir_all(version_dir.join("crash-reports")).unwrap();
std::fs::write(
version_dir
.join("crash-reports")
.join("crash-2026-01-02_03.04.05-server.txt"),
b"---- Minecraft Crash Report ----\n",
)
.unwrap();
let logs = get_logs(&metadata.instance.id, None).await.unwrap();
let filenames = logs
.iter()
.map(|log| log.filename.as_str())
.collect::<Vec<_>>();
assert!(
filenames.contains(&"latest.log"),
"logs must be enumerated from the linked root: {filenames:?}"
);
assert!(
filenames.contains(&"crash-2026-01-02_03.04.05-server.txt"),
"crash reports must be enumerated from the linked root: {filenames:?}"
);
// The log browser must never consult the nonexistent profile folder.
assert!(
!state
.directories
.instances_dir()
.join(&metadata.instance.path)
.exists()
);
}
#[tokio::test]
async fn direct_link_crash_analysis_finds_linked_crash_reports() {
let _state = global_state().await;
let (minecraft, metadata) =
create_direct_link_fixture("logs-crash").await;
let version_dir = minecraft.path().join("versions").join("logs-crash");
std::fs::create_dir_all(version_dir.join("logs")).unwrap();
std::fs::write(
version_dir.join("logs").join("latest.log"),
b"[12:00:00] [Render thread/INFO]: Setting user\n",
)
.unwrap();
std::fs::create_dir_all(version_dir.join("crash-reports")).unwrap();
std::fs::write(
version_dir
.join("crash-reports")
.join("crash-2026-01-02_03.04.05-server.txt"),
b"---- Minecraft Crash Report ----\n\
// Who set us up the TNT?\n\
java.lang.OutOfMemoryError: Java heap space\n",
)
.unwrap();
let analysis = crate::api::logs::analyze_crash(&metadata.instance.id)
.await
.unwrap();
assert!(
analysis
.sources
.iter()
.any(|source| source.source_type == "crash_report"),
"crash analysis must discover reports under the linked root"
);
}
}