fix(settings): preserve RAM retries and find matching Java on PATH
This commit is contained in:
+57
-6
@@ -60,18 +60,28 @@ pub fn ensure_java(
|
||||
required_major: u8,
|
||||
on_progress: &ProgressCallback,
|
||||
) -> Result<JavaInstallation, EnsureJavaError> {
|
||||
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<Item = PathBuf>,
|
||||
required_major: u8,
|
||||
mut inspect: impl FnMut(PathBuf) -> Option<JavaInstallation>,
|
||||
) -> Option<JavaInstallation> {
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter_map(&mut inspect)
|
||||
.find(|installation| installation.major == required_major)
|
||||
}
|
||||
|
||||
fn candidates() -> Vec<PathBuf> {
|
||||
let executable = if cfg!(target_os = "windows") {
|
||||
"java.exe"
|
||||
@@ -127,7 +137,48 @@ fn parse_major(version: &str) -> Option<u8> {
|
||||
|
||||
#[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() {
|
||||
|
||||
@@ -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<LauncherSettings>())
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T>() {
|
||||
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<number>()
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -44,6 +44,19 @@ export function createSerialQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Retain the latest failed user choice even when UI rolls back to disk state. */
|
||||
export function createSaveIntent<T>() {
|
||||
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>[],
|
||||
|
||||
Reference in New Issue
Block a user