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

This commit is contained in:
Disy committed 2026-09-24 22:36:21 +08:00
1 parent c2bc7c2441
commit 19098f7159
10 files changed
+628 -125

No files matched your search

+94 -3
View File
@@ -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);
+18 -17
View File
@@ -426,9 +426,7 @@ pub async fn default_publication() -> crate::Result<Publication> {
})
}
pub async fn create(
game_dir_root: Option<String>,
) -> crate::Result<String> {
pub async fn create(game_dir_root: Option<String>) -> crate::Result<String> {
let publication = default_publication().await?;
let runtime = &publication.manifest.runtime;
let state = State::get().await?;
@@ -1036,14 +1034,8 @@ async fn synchronize_with_progress(
resolved_external.push(resolved.clone());
files.push(resolved);
}
let duplicate_mods = tagged::merge(
&root,
&cache,
&mut files,
&mut sources,
&tagged_manifest,
)
.await?;
tagged::merge(&root, &cache, &mut files, &mut sources, &tagged_manifest)
.await?;
validate(&files)?;
if let Some(old) = &previous {
validate(&old.files)?;
@@ -1139,12 +1131,6 @@ async fn synchronize_with_progress(
});
}
}
for action in duplicate_mods {
preserved.retain(|path| path != &action.path);
if !actions.iter().any(|existing| existing.path == action.path) {
actions.push(action);
}
}
let mut tagged_downloads = Vec::new();
pending_downloads.retain(|_, (file, url, object)| {
if tagged_manifest.contains(&file.path) {
@@ -1305,6 +1291,21 @@ async fn synchronize_with_progress(
)
.await?;
ensure_session(&auth).await?;
let duplicate_mods = tagged::reconcile_staged(
&root,
&cache,
&mut files,
&mut sources,
&tagged_manifest,
&mut actions,
)
.await?;
for action in duplicate_mods {
preserved.retain(|path| path != &action.path);
if !actions.iter().any(|existing| existing.path == action.path) {
actions.push(action);
}
}
if actions.is_empty()
&& !runtime_changed
&& metadata.instance.install_stage == InstanceInstallStage::Installed
+254 -7
View File
@@ -97,6 +97,214 @@ mod tests {
}
}
fn write_mod(root: &Path, name: &str, id: &str) -> PathBuf {
let path = root.join("mods").join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut zip =
zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
zip.set_comment(name.to_owned());
zip.start_file(
"fabric.mod.json",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(
format!(r#"{{"schemaVersion":1,"id":"{id}","version":"1"}}"#)
.as_bytes(),
)
.unwrap();
zip.finish().unwrap();
path
}
#[tokio::test]
async fn removes_orphaned_managed_mods_even_when_no_tags_are_selected() {
let root = tempfile::tempdir().unwrap();
let stale = write_mod(root.path(), "example.starlight.jar", "example");
let personal = write_mod(root.path(), "personal.jar", "personal");
let actions = merge(
root.path(),
&root.path().join("cache"),
&mut vec![],
&mut BTreeMap::new(),
&TaggedManifest {
files: vec![],
replaces: vec![],
},
)
.await
.unwrap();
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].path, "mods/example.starlight.jar");
assert!(actions[0].next_hash.is_none());
// Planning must not touch the files; application uses the normal backup transaction.
assert!(stale.is_file());
assert!(personal.is_file());
let backup = ".starlight-pack-backup-orphan-test";
apply_files(root.path(), &root.path().join("cache"), backup, &actions)
.await
.unwrap();
assert!(!stale.exists());
assert!(
root.path()
.join(backup)
.join("mods/example.starlight.jar")
.is_file()
);
assert!(personal.is_file());
restore_files(root.path(), backup, &actions).await.unwrap();
assert!(stale.is_file());
}
#[tokio::test]
async fn replaces_installed_pack_mod_when_identity_and_cache_are_missing() {
let root = tempfile::tempdir().unwrap();
let local = write_mod(root.path(), "example-old.jar", "example");
let mut files = vec![PackFile {
mod_ids: vec![],
external: None,
path: "mods/example-old.jar".into(),
sha256: hash(&local).await.unwrap().unwrap(),
size: std::fs::metadata(&local).unwrap().len(),
force: true,
preserve: false,
}];
let actions = merge(
root.path(),
&root.path().join("cache"),
&mut files,
&mut BTreeMap::new(),
&manifest(),
)
.await
.unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "mods/example.starlight.jar");
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].path, "mods/example-old.jar");
}
#[tokio::test]
async fn fresh_install_reconciles_newly_downloaded_jars_before_applying() {
let root = tempfile::tempdir().unwrap();
let fixture = tempfile::tempdir().unwrap();
let cache = root.path().join("cache");
std::fs::create_dir(&cache).unwrap();
let old_jar = write_mod(fixture.path(), "old.jar", "example");
let old = PackFile {
mod_ids: vec![],
external: None,
path: "mods/old.jar".into(),
sha256: hash(&old_jar).await.unwrap().unwrap(),
size: std::fs::metadata(&old_jar).unwrap().len(),
force: true,
preserve: false,
};
let mut files = vec![old.clone()];
let mut sources = BTreeMap::new();
let replacement = write_mod(fixture.path(), "new.jar", "example");
let mut tagged = manifest();
tagged.files[0].sha256 = hash(&replacement).await.unwrap().unwrap();
tagged.files[0].size = std::fs::metadata(&replacement).unwrap().len();
merge(root.path(), &cache, &mut files, &mut sources, &tagged)
.await
.unwrap();
assert_eq!(files.len(), 2, "uncached legacy manifests have no IDs yet");
let mut actions: Vec<_> = files
.iter()
.map(|file| Action {
path: file.path.clone(),
old_hash: None,
next_hash: Some(file.sha256.clone()),
})
.collect();
// Downloads complete in the cache, not the live mods directory.
std::fs::copy(&old_jar, cache.join(&old.sha256)).unwrap();
std::fs::copy(&replacement, cache.join(&tagged.files[0].sha256))
.unwrap();
let duplicates = reconcile_staged(
root.path(),
&cache,
&mut files,
&mut sources,
&tagged,
&mut actions,
)
.await
.unwrap();
assert!(duplicates.is_empty());
assert_eq!(files.len(), 1);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].path, "mods/example.starlight.jar");
apply_files(
root.path(),
&cache,
".starlight-pack-backup-fresh-test",
&actions,
)
.await
.unwrap();
assert!(root.path().join("mods/example.starlight.jar").is_file());
assert!(!root.path().join("mods/old.jar").exists());
}
#[tokio::test]
async fn desired_managed_file_is_kept_without_selected_tags() {
let root = tempfile::tempdir().unwrap();
let local = write_mod(root.path(), "example.starlight.jar", "example");
let mut files = vec![PackFile {
mod_ids: vec!["example".into()],
external: None,
path: "mods/example.starlight.jar".into(),
sha256: hash(&local).await.unwrap().unwrap(),
size: std::fs::metadata(&local).unwrap().len(),
force: true,
preserve: false,
}];
let actions = merge(
root.path(),
&root.path().join("cache"),
&mut files,
&mut BTreeMap::new(),
&TaggedManifest {
files: vec![],
replaces: vec![],
},
)
.await
.unwrap();
assert!(actions.is_empty());
assert_eq!(files.len(), 1);
assert!(local.is_file());
}
#[tokio::test]
async fn modified_local_file_is_not_used_as_the_pack_identity() {
let root = tempfile::tempdir().unwrap();
let local = write_mod(root.path(), "personal.jar", "example");
let mut files = vec![PackFile {
mod_ids: vec![],
external: None,
path: "mods/personal.jar".into(),
sha256: "b".repeat(64),
size: 10,
force: true,
preserve: false,
}];
let actions = merge(
root.path(),
&root.path().join("cache"),
&mut files,
&mut BTreeMap::new(),
&manifest(),
)
.await
.unwrap();
assert!(actions.is_empty());
assert_eq!(files.len(), 2);
assert!(local.is_file());
}
#[tokio::test]
async fn replaces_external_mod_using_saved_identity_when_cache_is_missing()
{
@@ -298,9 +506,6 @@ pub(super) async fn merge(
.iter()
.flat_map(|file| file.mod_ids.iter().map(String::as_str))
.collect();
if ids.is_empty() {
return Ok(Vec::new());
}
// The modpack is the installation baseline. A tagged snapshot with the
// same content already passes verification, even when the skin site uses
@@ -321,10 +526,13 @@ pub(super) async fn merge(
let mut replaced: HashSet<String> = manifest
.replaces
.iter()
.filter(|path| !satisfied_pack_paths.contains(*path))
.filter(|path| !ids.is_empty() && !satisfied_pack_paths.contains(*path))
.cloned()
.collect();
for file in files.iter().filter(|file| is_mod_path(&file.path)) {
for file in files
.iter()
.filter(|file| !ids.is_empty() && is_mod_path(&file.path))
{
if satisfied_pack_paths.contains(&file.path)
|| replaced.contains(&file.path)
{
@@ -334,7 +542,19 @@ pub(super) async fn merge(
let declared = if !file.mod_ids.is_empty() {
file.mod_ids.clone()
} else {
mod_ids(object).await?
let cached = mod_ids(object).await?;
if cached.is_empty() {
let local = target(root, &file.path)?;
// An installed pack is still identifiable after its cache was
// cleared. Do not use a user-modified JAR as the pack identity.
if hash(&local).await?.as_deref() == Some(&file.sha256) {
mod_ids(local).await?
} else {
cached
}
} else {
cached
}
};
if replaces_ids(&declared, &ids)? {
replaced.insert(file.path.clone());
@@ -383,7 +603,14 @@ pub(super) async fn merge(
continue;
}
let local = target(root, &path)?;
if replaces_ids(&mod_ids(local.clone()).await?, &ids)? {
// This suffix is the launcher's managed namespace. Clean orphaned
// tagged JARs even if the user no longer has any selected tags or
// the old binding did not record them. Ordinary user JARs require
// a confirmed identity overlap and retain the multi-Mod guard.
if path.to_ascii_lowercase().ends_with(".starlight.jar")
|| (!ids.is_empty()
&& replaces_ids(&mod_ids(local.clone()).await?, &ids)?)
{
duplicate_actions.push(Action {
path,
old_hash: hash(&local).await?,
@@ -395,6 +622,26 @@ pub(super) async fn merge(
Ok(duplicate_actions)
}
/// The first pass may not know IDs of uncached pack JARs. Reconcile once they
/// are staged, before journaling or modifying the instance, and drop installs
/// of pack files now superseded by a tagged Mod.
pub(super) async fn reconcile_staged(
root: &Path,
cache: &Path,
files: &mut Vec<PackFile>,
sources: &mut BTreeMap<String, String>,
manifest: &TaggedManifest,
actions: &mut Vec<Action>,
) -> crate::Result<Vec<Action>> {
let duplicates = merge(root, cache, files, sources, manifest).await?;
let desired: HashSet<_> =
files.iter().map(|file| file.path.as_str()).collect();
actions.retain(|action| {
action.next_hash.is_none() || desired.contains(action.path.as_str())
});
Ok(duplicates)
}
pub(super) async fn download(
instance_id: &str,
instance_name: &str,
@@ -19,21 +19,15 @@
/>
</div>
<!--
Native virtualization: every line is rendered, but `content-visibility:
auto` lets the browser skip layout/paint for off-screen lines, while
`contain-intrinsic-size` gives the skipped elements a placeholder size
so the scrollbar stays stable. This avoids the manual height estimation
that used to make tall (wrapped / highlighted) lines overlap.
-->
<!-- Keep rows in normal flow, without cached off-screen sizes. Font size,
wrapping and container width can all change while a row is off screen. -->
<div v-else class="log-viewport-spacer relative w-full min-w-max">
<div
v-for="item in lines"
:key="item.originalIndex"
:data-line="item.originalIndex + 1"
class="log-line log-line-cv flex items-stretch whitespace-pre"
class="log-line flex items-stretch whitespace-pre"
:class="entryClass(item.line)"
:style="lineStyle"
>
<span
class="flex shrink-0 w-[52px] items-center justify-end leading-none text-right text-secondary bg-surface-3 border-r border-solid border-surface-3 select-none overflow-hidden"
@@ -63,7 +57,7 @@
// 逐行正则高亮来自 ./composables/log-highlight.ts(移植自 logParser.worker.ts)。
// LogShare-Web-UI 为 MIT License, Copyright (c) 2024 LogShare.CN Team,详见 packages/ui/COPYING.md。
import { ChevronDownIcon } from '@modrinth/assets'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
@@ -99,20 +93,6 @@ const props = withDefaults(
const viewportRef = ref<HTMLElement | null>(null)
const stickToBottom = ref(true)
// Placeholder row height for `contain-intrinsic-size`. Native
// `content-visibility: auto` replaces this with the real measured height once a
// line enters the viewport, so it only needs to be a reasonable estimate to
// keep the scrollbar from jumping. Wrapped lines can be taller, so bias higher.
const intrinsicLineHeight = computed(() => {
const single = Math.round(props.fontSize * 1.4)
return props.wrap ? single * 2 : single
})
const lineStyle = computed(() => ({
'content-visibility': 'auto',
'contain-intrinsic-size': `auto ${intrinsicLineHeight.value}px`,
}))
function entryClass(line: LogLine): string {
if (line.level === 'error') return 'entry-error'
if (line.level === 'warn') return 'entry-warning'
@@ -214,7 +194,7 @@ defineExpose({
}
.log-viewport-wrap .log-line {
white-space: normal;
white-space: pre-wrap;
}
.log-viewport-wrap .log-line-content {
@@ -247,8 +227,8 @@ defineExpose({
/* ===== LogShare token 高亮(LogsAnalysis.css 移植,前景色用主题变量) ===== */
.level {
white-space: pre-wrap;
.log-viewport .level {
white-space: inherit;
word-break: break-all;
overflow-wrap: anywhere;
}