fix: follow live logs and reconcile duplicate managed mods
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
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
This commit is contained in:
1 parent
c2bc7c2441
commit
19098f7159
10 files changed
+628
-125
No files matched your search
@@ -737,14 +737,35 @@ pub async fn get_generic_live_log_cursor(
|
||||
file.seek(SeekFrom::Start(display_start))
|
||||
.map_err(|e| IOError::with_path(e, &path))
|
||||
.await?; // Seek to cursor
|
||||
file.take(MAX_LOG_DISPLAY_BYTES as u64)
|
||||
// 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 cursor = metadata.len(); // Consume skipped output as well as the display window.
|
||||
|
||||
let credentials = Credentials::get_all(&state.pool)
|
||||
.await?
|
||||
@@ -827,6 +848,75 @@ mod tests {
|
||||
(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;
|
||||
@@ -890,9 +980,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn display_compaction_bounds_many_distinct_lines() {
|
||||
let log = (0..100_000)
|
||||
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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user