From cd5c675637693c3a1d4b5e16ebf9ac6a2cb8b9c5 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 18:57:21 +0300 Subject: [PATCH] fix(settings): preserve RAM retries and find matching Java on PATH --- src-tauri/src/java.rs | 63 ++++++++++++++++++++++++++++++++++---- src/hooks/useSettings.ts | 19 +++++++----- src/services/async.test.ts | 49 ++++++++++++++++++++++++++++- src/services/async.ts | 13 ++++++++ 4 files changed, 130 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/java.rs b/src-tauri/src/java.rs index e96e086..6015d24 100644 --- a/src-tauri/src/java.rs +++ b/src-tauri/src/java.rs @@ -60,18 +60,28 @@ pub fn ensure_java( required_major: u8, on_progress: &ProgressCallback, ) -> Result { - if let Some(installation) = detect() { - if installation.major == required_major { - on_progress(1, 1); - return Ok(installation); - } + if let Some(installation) = find_matching(candidates(), required_major, check_candidate) { + on_progress(1, 1); + return Ok(installation); } let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress) .map_err(EnsureJavaError::Provisioning)?; check_candidate(executable.clone()) + .filter(|installation| installation.major == required_major) .ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable)) } +fn find_matching( + candidates: impl IntoIterator, + required_major: u8, + mut inspect: impl FnMut(PathBuf) -> Option, +) -> Option { + candidates + .into_iter() + .filter_map(&mut inspect) + .find(|installation| installation.major == required_major) +} + fn candidates() -> Vec { let executable = if cfg!(target_os = "windows") { "java.exe" @@ -127,7 +137,48 @@ fn parse_major(version: &str) -> Option { #[cfg(test)] mod tests { - use super::{parse_major, parse_version}; + use super::*; + + #[test] + fn matching_path_runtime_is_not_hidden_by_wrong_java_home() { + let candidates = ["JAVA_HOME", "PATH"].map(PathBuf::from); + let found = find_matching(candidates, 21, |path| { + let major = if path == Path::new("JAVA_HOME") { + 17 + } else { + 21 + }; + Some(JavaInstallation { + executable: path.display().to_string(), + major, + version: major.to_string(), + }) + }) + .unwrap(); + assert_eq!(found.executable, "PATH"); + } + + #[test] + fn matching_home_remains_preferred_and_unusable_candidates_are_skipped() { + let found = find_matching(["broken", "home", "path"].map(PathBuf::from), 21, |path| { + assert_ne!(path, Path::new("path"), "must stop at matching JAVA_HOME"); + (path != Path::new("broken")).then(|| JavaInstallation { + executable: path.display().to_string(), + major: 21, + version: "21".into(), + }) + }) + .unwrap(); + assert_eq!(found.executable, "home"); + assert!(find_matching([PathBuf::from("newer")], 21, |path| Some( + JavaInstallation { + executable: path.display().to_string(), + major: 25, + version: "25".into() + } + )) + .is_none()); + } #[test] fn parses_modern_java_version() { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 2bbcd18..a2b5aa6 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { createSerialQueue, errorMessage } from '../services/async' +import { createSaveIntent, createSerialQueue, errorMessage } from '../services/async' import { isNative, native } from '../services/native' import { defaultSettings } from '../state/settings' import type { LauncherSettings } from '../types/launcher' @@ -12,7 +12,7 @@ export function useSettings() { const [loadAttempt, setLoadAttempt] = useState(0) const current = useRef(settings) const durable = useRef(settings) - const revision = useRef(0) + const intent = useRef(createSaveIntent()) const queue = useRef(createSerialQueue()) useEffect(() => { @@ -38,21 +38,23 @@ export function useSettings() { setSettings(next) setError(null) if (!isNative()) return - const requestRevision = ++revision.current + const request = intent.current.begin(next) setSaving(true) void queue.current.enqueue(() => native.saveSettings(next)).then((value) => { durable.current = value - if (revision.current === requestRevision) { + intent.current.succeeded(request) + if (intent.current.isLatest(request)) { current.current = value setSettings(value) } }).catch((reason: unknown) => { - if (revision.current !== requestRevision) return + if (!intent.current.isLatest(request)) return + intent.current.failed(request) current.current = durable.current setSettings(durable.current) setError(errorMessage(reason, 'Не удалось сохранить настройки')) }).finally(() => { - if (revision.current === requestRevision) setSaving(false) + if (intent.current.isLatest(request)) setSaving(false) }) } @@ -61,7 +63,10 @@ export function useSettings() { updateRam: (memoryGb: number) => save({ memoryMb: memoryGb * 1024 }), retry: () => { if (!loaded) setLoadAttempt((attempt) => attempt + 1) - else save({}) + else { + const failed = intent.current.retryValue() + if (failed) save(failed) + } }, } } diff --git a/src/services/async.test.ts b/src/services/async.test.ts index 4e1c7e4..09ccde3 100644 --- a/src/services/async.test.ts +++ b/src/services/async.test.ts @@ -1,6 +1,6 @@ import { deepStrictEqual, equal, rejects } from 'node:assert/strict' import { test } from 'node:test' -import { createRequestScope, createSerialQueue, createSubscription, errorMessage, singleFlight } from './async.ts' +import { createSaveIntent, createRequestScope, createSerialQueue, createSubscription, errorMessage, singleFlight } from './async.ts' function deferred() { let resolve!: (value: T) => void @@ -111,3 +111,50 @@ test('logout or a newer challenge invalidates a delayed account/link response', equal(belongsToNewChallenge(), true) equal(belongsToAccount(), false) }) + + +test('RAM retry retains the failed 8 GB choice after rollback to durable 6 GB', async () => { + const intent = createSaveIntent<{ memoryMb: number }>() + const queue = createSerialQueue() + let current = { memoryMb: 6 * 1024 } + let durable = current + const writes: number[] = [] + const save = async (next: typeof current, fail: boolean) => { + current = next + const request = intent.begin(next) + try { + const value = await queue.enqueue(async () => { + writes.push(next.memoryMb) + if (fail) throw new Error('disk full') + return next + }) + durable = value + intent.succeeded(request) + } catch { + intent.failed(request) + current = durable + } + } + await save({ memoryMb: 8 * 1024 }, true) + equal(current.memoryMb, 6 * 1024) + await save(intent.retryValue()!, false) + deepStrictEqual(writes, [8 * 1024, 8 * 1024]) + equal(durable.memoryMb, 8 * 1024) + equal(intent.retryValue(), null) +}) + +test('older settings failure cannot replace a newer choice or remain retryable after success', () => { + const intent = createSaveIntent() + const older = intent.begin(8) + const newer = intent.begin(10) + intent.failed(older) + equal(intent.retryValue(), null) + intent.failed(newer) + equal(intent.retryValue(), 10) + const latest = intent.begin(12) + intent.succeeded(older) + intent.failed(latest) + equal(intent.retryValue(), 12) + intent.succeeded(latest) + equal(intent.retryValue(), null) +}) diff --git a/src/services/async.ts b/src/services/async.ts index 2cd3f1d..5e630b6 100644 --- a/src/services/async.ts +++ b/src/services/async.ts @@ -44,6 +44,19 @@ export function createSerialQueue() { } } +/** Retain the latest failed user choice even when UI rolls back to disk state. */ +export function createSaveIntent() { + let revision = 0 + let failed: T | null = null + return { + begin: (value: T) => { failed = null; return { revision: ++revision, value } }, + isLatest: (request: { revision: number }) => request.revision === revision, + succeeded: (request: { revision: number }) => { if (request.revision === revision) failed = null }, + failed: (request: { revision: number; value: T }) => { if (request.revision === revision) failed = request.value }, + retryValue: () => failed, + } +} + /** Handles unmount before asynchronous native listener registration finishes. */ export function createSubscription( registrations: readonly Promise<() => void>[],