Conversation
…ome (#503, #501) - PluginInstallUtilsUninstallTest: uninstalling a loaded module must stop its @scheduled task (real TaskManager on MockBukkit's scheduler), drop it from the plugin list and delete its jar (#503); a jar whose delete fails must surface as a FileSystemException naming that jar (#501) - PluginInstallCommandsTest: the success reply carries no manual-delete instruction, and a failed delete is reported as a failure naming the jar (#501) All four fail against alpha 5095455. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…egister PluginInstallUtils#uninstallPlugin called plugin.unregisterSelf() directly, bypassing PluginManager#unregister, the framework's one full unload path. The module's @scheduled tasks, @PlayerCache beans, tab-completion completers, EventBus handlers, panel responders and @ConditionalOnConfig records all stayed live, and its context was never closed. - unload every matching module through PluginManager#unregister - remove it from the plugin list in a finally, since unregister() has closed its context even when the module's own unload hook throws - no list removal at all when nothing matches (was remove(null)) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
PluginInstallCommandsEnhancedTest#testUninstallPluginSuccess asserted the success reply carries the "File Location" line -- the defective reply #501 removes, since the jar has already been deleted by then. It now asserts that neither the location line nor a manual-delete instruction appears. Fails against the unfixed command. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…l-delete reply PluginInstallUtils#uninstallPlugin ignored File#delete()'s result and returned true whether or not the jar was removed, and the command's success reply told the operator to delete a jar that had in most cases already been deleted. - delete the jar with Files.delete; a failure surfaces as a FileSystemException naming the jar still on disk (no signature change: the method already declares IOException) - success reply: "Uninstalled! The module JAR file has been deleted.", with no File Location line - failed delete: a red failure line naming the jar, which loads again on restart - lang/en.json: replace the old success key, add the delete-failure key (zh.json carries no /upm keys and falls back to the Chinese key, as before) - UAT-CHECKLIST.md: amend ultitools.upm.uninstall (new reply, JAR save/restore) and neg-command-gone (cleanup step); add neg-scheduled-task-gone (#503 on a real server) and neg-delete-failed (#501); FEATURES.md: amend the uninstall row Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…nnot be deleted - PluginInstallUtilsUpdateOldJarTest: against a local catalogue and a real download, an old jar whose delete fails must surface as an UncheckedIOException wrapping a FileSystemException that names the jar; a writable folder still updates and reports success (control) - PluginInstallCommandsTest: /upm update replies with a failure naming the old jar instead of success; /upm update all counts it as failed and names the jar The three failure-path tests fail against the current code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…nnot be deleted PluginInstallUtils#updatePlugin ignored oldJar.delete()'s result and returned true either way, so /upm update reported success while the old jar stayed next to the new one and both loaded on the next start. - delete the old jar with Files.delete; a failure surfaces as an UncheckedIOException wrapping a FileSystemException that names the old jar (unchecked: the public method declares no checked exception, and its signature is unchanged) - /upm update: a red failure line naming the old jar instead of the success line - /upm update all: the same line per affected module, counted as failed, and the run continues with the remaining modules - lang/en.json: add the failure key - UAT-CHECKLIST.md: add ultitools.upm.update.neg-old-jar-delete-failed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…install task-gone row ultitools.upm.uninstall.neg-scheduled-task-gone used UltiChat's 5-minute announcement task, but UltiChat on master overrides unregisterSelf(), which is final in 6.3.0, and its wave-0 lifecycle-migration branch does not exist yet. The row now uses UltiCleaner built from fix/p17-w0-lifecycle-hooks (UltiKits/UltiCleaner#24): its CleanerService.tickItemClean task warns every 15 seconds with item.interval lowered to 15, and that build overrides no unload hook, so only PluginManager#unregister can stop it. Any other UltiCleaner build makes the row blocked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…er path PluginManager#unregisterSupersededVersions unloads the older copy of a module with existing.unregisterSelf() alone once a newer copy's registerSelf() returns true -- the same bypass of PluginManager#unregister as /upm uninstall (#503, issue comment 5713175816). - the older copy's @scheduled task stops (real TaskManager on MockBukkit's scheduler), its context is closed, and it leaves the plugin list while the newer copy stays - an older copy whose unload throws is still closed and removed, and does not fail the newer copy's already-activated load Both fail against the current code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…r#unregister unregisterSupersededVersions unloaded the older copy of a module with existing.unregisterSelf() alone once the newer copy's registerSelf() returned true, bypassing PluginManager#unregister: the older copy's @scheduled tasks, EventBus handlers, completers, panel responders and container stayed live, and it stayed in the plugin list next to its replacement. - collect the superseded copies, then unload each through unregister() and remove it from the plugin list (collected first, so the list is not modified while it is iterated) - a superseded copy whose unload throws is logged, as close() does, and removed either way: the newer copy is already active, so failing its load would leave it activated but unlisted - the javadoc invariant is unchanged: this still runs only after the new copy's registerSelf() returned true Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…inManager#unregister" This reverts commit 098be91.
…unregister path" This reverts commit d0e67f1.
Gate-1 review WR-02: uninstallPlugin deleted only the first jar whose plugin.yml name matched and replied success, while a second jar of the same module loads it again on restart. - two matching jars are both deleted - when both cannot be deleted, the FileSystemException names one jar and carries a suppressed FileSystemException for each further jar - the command reply names every jar still on disk All three fail against the current code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
Gate-1 review WR-02: uninstallPlugin returned after deleting the first jar whose plugin.yml name matched, so a second jar of the same module survived behind a success reply and loaded the module again on restart -- exactly the state a failed /upm update can leave. - collect every matching jar, attempt to delete each even after one fails, and return true only when all are gone - a failure names one jar via FileSystemException#getFile() and each further jar as a suppressed FileSystemException; the command reply lists all of them - en.json: success and failure keys reworded for one or more jars - UAT-CHECKLIST.md / FEATURES.md: uninstall rows amended to the new literals and to 'every JAR whose name matches' Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
Gate-1 review WR-03: uninstallPlugin unloads the module before looking for its jar, then returned false when no jar matched, so the command told the operator the name was misspelled although the module had been found by exactly that name. - uninstallPlugin reports that case as a NoSuchFileException naming the plugins folder, after unloading the module; nothing loaded and no jar still returns false - the command reply says the module was unloaded and no jar was found in the folder named, with no spelling hint The two failure-path tests fail against the current code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
Gate-1 review WR-03: uninstallPlugin unloads the matching module before it looks for the jar, then returned false when none matched, and the command replied "please check the spelling" about a name that had just matched a loaded module. - uninstallPlugin throws NoSuchFileException naming the plugins folder when a loaded module was unloaded but no jar carries its name; false stays reserved for "nothing loaded and no jar" - the command replies in yellow that the module was unloaded and no jar was found in that folder - en.json: new key; UAT-CHECKLIST.md: add ultitools.upm.uninstall.neg-no-jar Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…honest reply Gate-1 review WR-01: PluginManager#unregister rethrows a module's own unload failure, and uninstallPlugin let it escape before looking for the jar, so the jar stayed on disk and the command's IOException catches never ran -- the operator got only a generic command error. - uninstallPlugin: the module is delisted, its jar is still deleted, and the failure surfaces as uninstallPlugin's own IllegalStateException carrying the module's exception as cause (no suppressed jar failure when every jar went) - command: the reply reports the unload error together with the jar outcome -- all deleted, not deleted (naming the jar), or no jar found (naming the folder) All four fail against the current code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…ll delete the jar Gate-1 review WR-01: PluginManager#unregister rethrows a module's own unload failure (for example from onUnregister()). uninstallPlugin let it escape before the jar search, so the jar stayed on disk and the command's IOException catches never ran; the operator saw only a generic command error while the module would come back on restart. - uninstallPlugin collects a throwing unload (logged at SEVERE with the module's exception), still removes the module from the loaded modules, still deletes every matching jar, then throws IllegalStateException with the module's exception as cause and the jar outcome (FileSystemException naming the jars left, or NoSuchFileException naming the folder) attached as suppressed - the command replies with the unload error, then the jar outcome: all jars deleted, the jars that could not be deleted, or no jar found Decision -- the jar is still deleted. By the time the unload throws, unregister() has already cancelled the module's registrations, run its unregisterSelf() steps and closed its container, and the module has left the loaded list; nothing of it is left running to protect. Keeping the jar would only bring back on restart a module the operator explicitly asked to remove, and deleting the jar touches none of the module's data files. The operator is still told the unload threw and to look at the console. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
Gate-1 review CR-02: updatePlugin looks up one old jar by identify-string and skips the delete when that lookup returns the downloaded file, which a retry after a failed old-jar delete can do -- reporting success with two versions on disk. IN-02: /upm update all then ends with a bare "please restart". - two old jars of the module are both deleted, and the downloaded jar is kept - a retry with the new jar already on disk deletes the old jar (order-dependent on the old code, so it guards the retry rather than proving RED) - when several old jars cannot be deleted, the failure names every one of them - /upm update names every remaining old jar - /upm update all's summary, after an old-jar failure, asks for the old jars to be deleted before restarting instead of a bare restart instruction Four of these fail against the current code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
… one left Gate-1 review CR-02: updatePlugin looked up a single old jar before the download and skipped the delete when that jar was the downloaded file's name. On a retry after a failed old-jar delete, the folder already holds the new jar with the same identify-string, the lookup can return it, and /upm update replied success with the old jar still on disk -- the #505 failure one step later. - after the download, look up every jar whose identify-string matches, keep the downloaded file, and attempt to delete every other one; any failure throws UncheckedIOException wrapping a FileSystemException naming one remaining jar, with each further jar attached as suppressed - findPluginJar keeps its public single-result contract, now on top of a private helper returning every match - /upm update and /upm update all name every remaining old jar - review IN-02: /upm update all, after any old-jar failure, ends with a yellow summary asking for the listed old jars to be deleted before restarting instead of a bare "please restart" - en.json: failure key reworded for one or more jars, new summary key; UAT-CHECKLIST.md: update failure row amended to the new literal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…anges in COMPATIBILITY.md
Gate-1 review WR-05: two public static methods now fail where they used to report
success. Both are recorded under "Behavioral changes that need no migration
period" as corrections of behaviour that contradicted their own javadoc
("whether the uninstall succeeded", "true if update succeeded"), naming the old
behaviour, the new exception types and what each carries. The @throws javadoc on
both methods already describes the same outcomes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…ling case Gate-1 review IN-01: ultitools.upm.uninstall.neg-delete-failed and ultitools.upm.update.neg-old-jar-delete-failed named only "runs as root" as the blocked case, but the delete also succeeds under CAP_DAC_OVERRIDE or a permissive ACL, and GNU stat -c / chmod are Linux-specific. - both rows require a Linux host and add a write probe after chmod, run as the server process's own user; a succeeding probe (root, capability, ACL) or a non-Linux host makes the row blocked, not fail - ultitools.upm.uninstall, neg-scheduled-task-gone and neg-delete-failed require the modules folder to hold only regular .jar module files, since a directory or non-jar file there makes the jar search fail for an unrelated reason (#504) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…ed as an older jar Review r2 WR-01: updatePlugin keeps "the jar we just downloaded" by comparing file-name strings. On a case-insensitive filesystem the lower-cased download is written into an existing differently-cased file and listed under that stored name, so the download lands among the older jars, is deleted, and the command still replies success. Linux has no case aliasing, so a symbolic link models the same situation: the download name is a link to a differently-named existing file. The test serves a real jar, runs updatePlugin, and asserts the file behind both names survives with the downloaded bytes. It assumes symbolic links are available and says so if not. Fails against the current code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
Review r2 WR-01: after the download, updatePlugin kept a matching jar only when its name equalled the download's file name. Any second name for the same file -- a differently-cased stored name on NTFS or default APFS, a symbolic link, a short name -- put the freshly downloaded jar among the older jars; it was deleted with the real old jar and /upm update still replied success, so the module was gone after restart. - compare each candidate with the download path using Files.isSameFile, which removes the whole alias class instead of special-casing case folding - a candidate that cannot be compared because it has vanished is skipped (it is not the download and there is nothing to delete); any other candidate that cannot be compared is treated as an older jar, so its delete is attempted and a failure is reported rather than silently kept Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…e plus jar failure Review r2 IN-01: - retryWithNewJarAlreadyPresent_deletesTheOldJar is order-dependent: it passed against the defective code in this repository's RED run. Its display name and comment now say it is a regression guard only, and that twoOldJars_bothAreDeleted is the deterministically red test. - the composed branch of uninstallPlugin -- the module's unload throws AND a jar cannot be deleted -- was exercised only through a hand-built exception in the command tests. A real-path test now drives it with a throwing command cleanup and a read-only folder and asserts IllegalStateException with the module's exception as cause and exactly one suppressed FileSystemException naming the jar still on disk. Test-only; both tests pass on the current code (coverage, not a defect fix). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
…ect two claims Review r2 IN-02, IN-03, IN-05: - the two delete-failure rows no longer claim the write probe detects every way the server could still delete: the probe runs as the operating-system user the server process runs as (found with ps) and misses a capability granted only to the Java process, so a green success reply with the jars gone is recorded as blocked, not fail; the update row says the target file is pre-created by that same user, with that user's default ownership and mode - sweep of every /upm row that expects an exact reply for an unguarded extra line: the four uninstall rows now rule out a module whose own unload throws (the red "Uninstall error!" line or the console's "threw while unloading for uninstall" line makes the row blocked, and the framework outcome is named by its unit tests); ultitools.upm.update and update-all rule out an old-jar delete failure line - new row ultitools.upm.update-all.neg-old-jar-delete-failed for the yellow "Delete the old JAR files listed above before restarting." summary, using the existing delete-failure technique with exactly one module to update - FEATURES.md: the uninstall description names all four outcomes - COMPATIBILITY.md: after an unload failure, deletion of the jars is attempted, not "still deleted" Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
@codex review |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 552 |
🟢 Coverage 88.70% diff coverage · +0.76% coverage variation
Metric Results Coverage variation ✅ +0.76% coverage variation (-1.00%) Diff coverage ✅ 88.70% diff coverage Coverage variation details
Coverable lines Covered lines Coverage Common ancestor commit (5095455) 14419 11669 80.93% Head commit (46252a8) 15147 (+728) 12374 (+705) 81.69% (+0.76%) Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch:
<coverage of head commit> - <coverage of common ancestor commit>Diff coverage details
Coverable lines Covered lines Diff coverage Pull request (#508) 770 683 88.70% Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified:
<covered lines added or modified>/<coverable lines added or modified> * 100%
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
…508 Codacy (issueThreshold 0) reported six new issues, one instance per rule per file; every occurrence of each pattern is fixed here in one pass. - Opengrep InsecureStorage / FileAccess: the tests made the plugins folder read-only with PosixFilePermissions.fromString("r-xr-xr-x"), which the rules read as granting access to group and others. The tests only need the owner (the build user) to lose write permission, so all five sites in PluginInstallUtilsUninstallTest and PluginInstallUtilsUpdateOldJarTest now use "r-x------". The delete still fails, and the tests still run rather than skip. - markdownlint MD038 (spaces inside code spans): the two UAT rows describing the path separator wrote it as a code span holding a comma and a space; both now say "separated by a comma and a space" in prose. No production code changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL
|
Codex Review: Didn't find any major issues. Breezy! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
… owes, and an unreadable second copy Three ways the boot confirmation or the uninstall could report a thing that did not happen. The confirmation asks whether the module loaded. A module name does not answer that: two installed modules can carry the same name in their plugin.yml, and the one that loaded may not be the one this journal updated. The tests now hand the hook the identities the journal itself records, and the new one hands it a name that matches and an identity that does not - the update must be rolled back. A rollback with two old JARs to put back, one of whose paths is occupied, has not finished: the journal is what a later start would retry from, so it stays. An uninstall that finds the module's JAR and deletes it has still said nothing about a second copy it could not read, which loads the module again once the file is readable. It is reported like the case where nothing matched. The main-code change is in the next commit, so these fail here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 039c8f2e03
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // So the old JARs stay where they are and the journal stays with them, marked as awaiting | ||
| // that boot: confirmUpdatesAfterBoot deletes them once the module has loaded, or puts the | ||
| // old version back if it has not. | ||
| if (!markJournalAwaitingBoot(journal, identifyString)) { |
There was a problem hiding this comment.
Persist the confirmation phase before exposing the candidate
Fresh evidence beyond the handled journal-write failure is that abrupt termination bypasses this rollback branch: if the JVM stops after installNewVersion succeeds but before this call records the phase, the next boot sees an unmarked journal and an installed target, treats the set-aside JARs as leftovers, and deletes the journal in recoverJournal (lines 1552-1560). If the candidate then fails to load, the confirmation hook has no journal from which to restore the working version, defeating the transaction's crash-safety guarantee; record a recoverable phase before installing the target or preserve this installed-but-unmarked state for boot confirmation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and fixed in f021b4f9 as row A8 of the crash-window table now in the pull request body.
Rather than keep the phase and the install in one order, the state is read from what is on disk: a journal of a dead process whose recorded target is installed as a JAR of that module is the confirmation hook's business whether or not the marker was written. Pre-load recovery hands it over, confirmation accepts it, and the branch that used to call those set-aside JARs leftovers of a finished update no longer exists — an installed candidate is never "finished" until a boot has seen whether the module loads.
Pinned by ModuleUpdateRecoveryTest#installedButUnmarkedJournal_isLeftForBootConfirmation; reverting it fails that test (UltiTools-Reborn-505-MUTATION-sweep-a8-RED.log).
| try (java.io.Writer writer = Files.newBufferedWriter(temporary, java.nio.charset.StandardCharsets.UTF_8)) { | ||
| entries.store(writer, "UltiTools module update journal"); | ||
| } | ||
| Files.move(temporary, journal, StandardCopyOption.ATOMIC_MOVE); |
There was a problem hiding this comment.
Replace the existing journal portably
markJournalAwaitingBoot invokes this method after the journal already exists, so this atomic move must replace an existing target. With ATOMIC_MOVE, Files.move explicitly leaves existing-target behavior implementation-specific and permits providers to throw instead of replacing it; on such a filesystem every otherwise successful update reaches the marker-failure rollback path and can never become awaiting boot confirmation. Use a journal-replacement procedure that is guaranteed by the supported filesystem/provider contract.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, and fixed in f021b4f9 as row A18.
UpdateFileOperations now carries replace(source, target): it tries one atomic replace and, on AtomicMoveNotSupportedException, FileAlreadyExistsException or UnsupportedOperationException, removes the target and retries the rename atomically. A crash in that window leaves an installed candidate with no journal marker, which is row A8 above and is now read correctly — so the fallback's non-atomicity costs nothing.
Pinned by PluginInstallUtilsUpdateTransactionTest#journalRewriteOnAProviderThatRefusesAnAtomicReplace_stillMarksTheUpdate, which drives the update through a provider that refuses the replace; reverting the fallback fails it (…-MUTATION-sweep-replace-RED.log). A comment would not have caught it.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 039c8f2e03
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| synchronized (MODULE_OPERATIONS_IN_PROGRESS) { | ||
| if (MODULE_OPERATIONS_IN_PROGRESS.contains(moduleKey)) { | ||
| return UpdateOutcome.of(UpdateOutcome.Status.ALREADY_IN_PROGRESS); |
There was a problem hiding this comment.
Refuse updates already awaiting boot confirmation
After an update returns UPDATED, this in-memory guard is released even though its journal remains pending, and the cached /upm update entry allows the same command to be run again before restart. The second transaction moves the first candidate aside and creates another awaiting journal; if the candidate then fails to load, confirmUpdatesAfterBoot processes those UUID-named journals in arbitrary lexical order and can restore the rejected candidate, delete both journals, and leave boot choosing between it and the original JAR. Treat an awaiting journal for this identify-string as ALREADY_IN_PROGRESS until boot confirms or rolls it back.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Real, and fixed in f021b4f9 as row B1 of the "what is legal while a journal is unresolved" table.
runUpdateTransaction now refuses before it downloads anything when a journal in the staging directory names this module in a boot-confirmation phase — ALREADY_IN_PROGRESS, nothing changed. The in-memory guard still covers concurrent transactions; this is the disk answering for the module in the window between the transaction and the restart, which the in-memory guard cannot span. The reply says which of the two it is, in both language files, and UAT-CHECKLIST.md carries the row that produces it.
Pinned by PluginInstallUtilsUpdateTransactionTest#secondUpdateWhileOneAwaitsTheRestart_isRefused (…-MUTATION-sweep-b1-RED.log).
| if (!rolledBack) { | ||
| LOGGER.severe("Module " + name + " did not load from its update, and no previous version could be" | ||
| + " restored; the module is not installed. Its journal " + journalFile.getAbsolutePath() | ||
| + " was kept."); | ||
| return; |
There was a problem hiding this comment.
Finalize failed updates that have no previous JAR
When an update has no older matching JAR—such as a loaded module whose source JAR was removed, or a direct first-time call—and its candidate fails to load, deleting installed fully completes the rollback because pairs is empty. Nevertheless rolledBack remains false here, so the journal is retained and every subsequent boot repeats the same severe failure indefinitely. Delete the journal and report the module as uninstalled when there were no rollback pairs, while retaining it only when a recorded pair still needs restoration.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and fixed in f021b4f9 as row A15.
The rollback now separates "everything this journal recorded has been dealt with" from "a previous version is installed again". When every pair has settled the journal goes, even if nothing came back; the operator is then told the module had no previous version to put back and is not installed, rather than reading the same SEVERE at every start with nothing left to do. A pair that genuinely still needs restoring keeps the journal, which is row A13's case.
Pinned by ModuleUpdateRecoveryTest#rollbackWithNoPreviousVersion_deletesItsJournal (…-MUTATION-sweep-nopairs-RED.log).
| + " names no module or no installed file and was left in place"); | ||
| return; | ||
| } | ||
| List<String[]> pairs = journalPairs(entries, journalFile, new java.util.concurrent.atomic.AtomicBoolean()); |
There was a problem hiding this comment.
Preserve incomplete journals during boot confirmation
For an awaiting journal with a missing or unusable pair before a later aside.N entry, journalPairs signals the incomplete read through the supplied AtomicBoolean, but this call discards that signal. Confirmation can therefore process only the prefix and delete the journal—unconditionally on successful load, or after any prefix restore on failed load—stranding the later set-aside JAR with no recovery record. Inspect the skipped-pair flag and retain the journal whenever the pair list was incomplete.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and fixed in f021b4f9 as row C1 of the discarded-signals table — which was built by grepping the new paths for returned statuses rather than by recall, and this was the one being dropped.
confirmOneUpdate now keeps journalPairs' flag and passes it to both branches: the confirm path keeps the journal and says which JAR it could not read, and the rollback path treats the reading as unsettled, so the journal survives for a later start either way.
Pinned by ModuleUpdateRecoveryTest#confirmationOfAJournalWithAGap_keepsIt (…-MUTATION-sweep-c1-RED.log). The same flag is still discarded in leaveForBootConfirmation, deliberately and recorded as row C2: a pair it did not read is not marked accounted for, so the leftover pass names that JAR as belonging to a kept journal, which is the report that case needs.
…sh what a rollback started Three reports that could be made about something that did not happen. The confirmation hook asked whether a module of that name loaded. Two installed modules can carry the same plugin.yml name, and the one that loaded need not be the one the journal updated - the hook would then delete the set-aside JAR of an update that never loaded, which is the one copy known to work. It now asks about the identify-string the journal already records, and UltiTools hands it the identify-strings of the modules that loaded. The name stays where it belongs, in what the operator reads. A rollback with more than one old JAR to put back reported a completed rollback as soon as any one of them landed, and deleted the journal - stranding the rest in staging under names nothing looks for. Every pair now has to settle before the journal goes; while one has not, the journal stays so the next start can finish it, and the operator is told so. An uninstall that found the module's JAR said nothing about a JAR beside it that could not be read, so a second copy could load the module again once the file was readable, after a reported success. Those are now reported too. Not every unreadable file, though: an unrelated one must not stop an uninstall, and once the metadata cannot be read the file name is the only evidence there is, so the ones named like this module - by its name or by an identify-string of the instances being unloaded - are the ones that cannot be ruled out. When nothing could be identified at all, every unreadable JAR is still reported, as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… an uninstall reports Two lines that describe the behaviour the previous commit changed: the boot confirmation is keyed on the module's identify-string, not on the name an operator sees, and an uninstall reports an unreadable JAR beside the one it deleted when its name says it may be another copy of the same module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@codex review Head is
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 58666dec27
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!settled) { | ||
| LOGGER.severe("Module " + name + " did not load from its update, and not every previous version" | ||
| + " could be put back; its journal " + journalFile.getAbsolutePath() + " was kept so the" | ||
| + " next start can finish the rollback. The module is NOT available in this session."); | ||
| return; |
There was a problem hiding this comment.
Mark retained partial rollbacks as rollback work
Fresh evidence after the r23 partial-rollback fix is that this retained journal still has the awaiting-boot-confirmation phase. On the next start, pre-load recovery deliberately skips it; if any old JAR was restored successfully, that JAR loads with the same identify-string, so confirmOneUpdate treats the rollback as a successful update and deletes every still-unrestored aside plus the journal. Change the phase or otherwise ensure the next boot finishes rollback rather than confirming it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and fixed in f021b4f9 as row A13.
The journal now carries a third phase, rolling-back, written before the rollback touches anything. A start that finds it finishes the rollback regardless of what loaded — which is what makes a restored old JAR stop being mistaken for proof that the update loaded. Pre-load recovery leaves that phase alone exactly as it leaves awaiting-boot-confirmation.
Pinned by ModuleUpdateRecoveryTest#interruptedRollback_isFinishedAtTheNextBoot: two old JARs, one path occupied, then a second boot where the restored JAR does load. Reverting the phase write fails it (…-MUTATION-sweep-a13-RED.log). A failure to write that phase is row C4: logged at SEVERE, and the rollback still goes ahead, because leaving a module that cannot load installed is the worse outcome.
| List<File> suspects = namedLikeThisModule(unreadableJars, name, identifyStrings); | ||
| if (moduleUnloaded && !suspects.isEmpty()) { |
There was a problem hiding this comment.
Report suspect JARs when no instance was loaded
Fresh evidence beyond the r23 unreadable-second-copy test is that reporting is still conditional on moduleUnloaded. When a module failed to load this boot but /upm uninstall finds and deletes one readable matching JAR, a same-prefixed unreadable second copy leaves suspects nonempty while this condition is false; the command returns success, and that copy can load the module after it becomes readable. The suspect should be reported whenever a matching uninstall otherwise succeeds, not only when a live instance was unloaded.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Ruled on by the maintainer, and fixed in f021b4f9: a suspect JAR is reported whether or not a live instance was unloaded. You are right that a module can fail to load and therefore have no instance to unload, while its second copy is no less able to load it later.
This does not reopen r6's "an unrelated unreadable JAR must not stop an uninstall", because reporting is not refusing: what counts as a suspect is still narrowed by the only evidence left once the metadata cannot be read — the file name, matched against the module's name and the identify-strings of the instances being unloaded. Both conditions hold at once.
Pinned by PluginInstallUtilsUninstallTest#uninstall_reportsASuspectJarEvenWhenNothingWasUnloaded; restoring the moduleUnloaded condition fails it and the r23 test with it (…-MUTATION-sweep-suspects-RED.log), while r6's unreadableEntriesInTheModulesFolder_areSkipped stays green.
| List<Path[]> unrestored = moveBack(operations, movedAside); | ||
| deleteQuietly(operations, target); | ||
| deleteJournal(journal); |
There was a problem hiding this comment.
Remove the candidate before restoring its target path
Fresh evidence after the r22 marker-failure fix is the case where an existing module JAR already has the candidate's final filename, such as a same-version retry: that old JAR is among movedAside, but moveBack runs while the candidate still occupies its original target. The restore either fails because the destination exists, or atomically replaces the candidate; the following delete then removes whichever file is at the target, and the journal is deleted even if the old JAR remains stranded. Delete the candidate first, then restore, and retain the journal if any restore fails.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and fixed in f021b4f9 as row A16.
The candidate is deleted first, and the restore then runs through the same rollBack every other failure of this transaction uses — so the journal is kept when a JAR stays in staging instead of being deleted unconditionally. The shorter open-coded version this branch had is gone; there is one rollback path.
Pinned by PluginInstallUtilsUpdateTransactionTest#markerFailureRollback_whenTheOldJarCarriesTheCandidateName, which is the same-version retry you describe: the set-aside JAR's path is the candidate's path. Restoring first fails it (…-MUTATION-sweep-marker-order-RED.log).
…r own methods Both went past Codacy's NPath threshold with those fixes: the uninstall's JAR deletion at 384 and the pre-load recovery of one journal at 240, against a threshold of 200. Each now keeps what it decides and hands off what it then does - deleting the module's JARs and its staging state, and leaving a journal for the confirmation hook - to a method named after that. No behaviour change; the same tests cover both paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wrong The update's durable state was enumerated in one pass instead of one review round at a time: every gap between two consecutive durable writes, every operation an operator can run while a journal waits for a restart, and every status a helper returns that its caller drops. Nine rows came out wrong; these are the eight that are testable in isolation. Crash windows: - a crash between installing the new version and marking its journal left an installed candidate with an unmarked journal, which pre-load recovery deleted as a finished transaction - so nothing could roll it back; - a rollback interrupted after it had put one JAR back was read at the next start as an update to confirm, because the journal still said it was awaiting one, and the JARs the rollback still owed were deleted; - a rollback with nothing to put back kept its journal and repeated the same SEVERE at every start; - the in-transaction rollback restored the old JARs while the candidate still held the path one of them came from, which is a same-version retry. What is legal while a journal waits: a second update of the same module was accepted, so two journals awaited one restart and the one resolved second could restore the candidate the first had rejected. Discarded signals: confirmation read a journal's pairs while dropping the flag that says the reading was incomplete, and deleted the journal with a set-aside JAR past the gap still in staging. Portability: the journal rewrite replaces an existing file through an atomic move, whose behaviour over an existing target `Files.move` leaves to the provider. A provider that refuses it is now a test, not a hope. And the ruling that a suspect JAR is reported whether or not a live instance was unloaded - a module that failed to load this boot has no instance, and its second copy is no less able to load it later. The main-code changes are in the following commits, so this one does not compile on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each described a state the sweep placed somewhere else, so they cannot stand as written. "An interrupted update whose new version is in place restores nothing and drops its journal" is the crash window between the install and the phase write, and its journal is now the confirmation's - the case it described is pinned by installedButUnmarkedJournal_isLeftForBootConfirmation. "A journal still moving JARs" was written with another process's identity, which is what a crashed transaction leaves; a transaction that really is still moving JARs belongs to the process running now, so that is what it hands the hook. "The guard is released once the running update finishes" is still true of the in-memory guard and no longer enough to start a second update: the journal holds the module until a restart resolves it. The test resolves the journal, which is what the restart does, and then asserts the release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… pass Nine rows of the three tables came out wrong. They are fixed together because they are one mechanism - what a journal means to the start that finds it. A journal now carries the phase it is in, and the two boot hooks divide by that rather than by guesswork: - no phase: a transaction still moving JARs, unless its new version is already installed and the journal belongs to a dead process - the crash window between the install and the phase write, which pre-load recovery used to read as a finished update, delete, and so leave nothing to roll back. It is handed to the confirmation hook exactly as a marked journal is, and the leftover-of-an- installed-update branch it used to take no longer exists. - awaiting a boot: the confirmation decides it. - rolling back: written before the rollback touches anything, so a start that finds the rollback half-done finishes it instead of reading a restored old JAR as proof that the update loaded and deleting what the rollback still owes. The rollback also finishes when there was nothing to put back - an update of a module with no previous JAR no longer keeps a journal that repeats the same SEVERE at every start - and the confirmation keeps a journal whose pair list could not be read in full, which it had been dropping along with the flag that says so. An update is refused while one of the same module waits for a restart: two journals awaiting one restart cannot both be resolved correctly, since the one resolved second can restore the candidate the first rejected. The in-memory guard is unchanged; this is the disk state answering for the module between the transaction and the restart. The in-transaction rollback removes the candidate before restoring the JARs, because a same-version retry sets aside a JAR whose path the candidate now holds, and it keeps the journal when a JAR stays in staging - it routes through the same rollback as every other failure rather than repeating a shorter version of it. The journal rewrite no longer assumes an atomic move replaces an existing file. Files.move leaves that to the provider, so one that refuses is handled: the target is removed and the rename retried atomically. A crash in that window is the installed-but-unmarked state above, which is now read correctly. An uninstall clears the module's staging state before deleting its JARs. A crash between the two used to leave the JARs deleted and a journal that restores one at the next start, bringing back the module just removed; in this order a crash leaves the module installed, which is where it started. What is reported is unchanged, and the JAR deletion is logged, which is what makes the order observable. A JAR that could not be read and is named like this module is reported whether or not a live instance was unloaded: a module that failed to load this boot has no instance, and its second copy is no less able to load it later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…esolves it The refusal an operator now meets needs to say which of the two reasons it is, so the reply covers both: another update or uninstall is running, or one of this module's updates is waiting for a restart to confirm it. Both language files carry it, and UAT-CHECKLIST.md carries the row that produces it - run the update twice without restarting in between. COMPATIBILITY.md states the refusal and the two journal phases a start can meet: a new version already installed is left to the confirmation step whether or not the marker naming that state was written before the process died, and a rollback that did not finish is finished rather than read as an update to confirm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@codex review Head is Your findings map onto them: A8 (installed, journal unmarked), A18 (atomic replace refused by the provider), B1 (second update while one awaits a restart), A15 (rollback with nothing to put back), C1 (the dropped incomplete-read flag), A13 (interrupted rollback read as an update to confirm), A16 (candidate still holding the target path during the restore), plus the maintainer's ruling that a suspect JAR is reported whether or not a live instance was unloaded. Each thread carries its row and its commit. Please check the tables rather than rediscover their rows: a finding that is a row is welcome, and a finding that names a row the tables do not have means the enumeration is incomplete, which is the more useful thing to tell me.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7161ece18a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (isBootConfirmationPhase(entries)) { | ||
| leaveForBootConfirmation(journalFile, entries, reported); | ||
| return; |
There was a problem hiding this comment.
Finish rolling-back journals before scanning JARs
When the process stops after writing phase=rolling-back but before deleting the rejected candidate, this pre-load recovery branch defers the journal to confirmUpdatesAfterBoot, which runs only after pluginManager.init. The rejected candidate is therefore included in the boot classpath and may load again; only afterward is it deleted and the old JAR restored, so the restored version cannot load until yet another restart. Handle rolling-back journals in pre-load recovery rather than grouping them with updates awaiting a verdict.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and fixed in 2c2c01d5. This is row A12, which the table had and whose verdict was wrong — recorded as such in the pull request description.
I had reasoned only about consistency (either phase is a defined state that the next start resolves) and missed the cost: the confirmation hook runs after pluginManager.init, so the rejected JAR is on the class path for that whole session and the JAR that does work goes back only afterwards. A rolling-back journal carries a verdict made at an earlier start and needs nothing from the load phase, so pre-load recovery now finishes it — deletes the rejected candidate, puts the old JARs back, and deletes the journal — before the class path is built. The confirmation hook keeps its own rolling-back branch for a rollback that began after the modules had loaded, and both callers run one body so the two cannot drift.
Pinned by ModuleUpdateRecoveryTest#rollingBackJournal_isFinishedBeforeTheModulesLoad and by interruptedRollback_isFinishedAtTheNextBoot, which now runs the real boot order; reverting the pre-load branch fails both (UltiTools-Reborn-505-MUTATION-sweep-a12-RED.log).
| } catch (IOException | RuntimeException e) { | ||
| // Unreadable here is not a refusal: the uninstall path reports such a journal, and | ||
| // recovery names it at the next start. | ||
| LOGGER.log(Level.FINE, "Could not read " + journalFile + " while checking for an update" | ||
| + " awaiting a restart", e); | ||
| continue; |
There was a problem hiding this comment.
Refuse updates when an existing journal cannot be read
If an awaiting journal is temporarily unreadable or malformed, this continue treats it as though no update owns the module. A later /upm update can then move the first candidate aside and create a second journal; once the original journal becomes readable, boot has the same two-verdict rollback ambiguity that the new B1 guard is intended to prevent. Since the module identity cannot be ruled out here, conservatively refuse the update while any journal cannot be inspected.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, and fixed in 2c2c01d5. This is row C7, which the table had and disposed of wrongly: I recorded the continue as accepted because such a journal is reported elsewhere, which answers a different question than the one this check asks.
The check asks whether anything in staging forbids starting an update, and an unreadable journal cannot answer it — so it is now refused, reported as an unusable staging state naming the file, before anything is downloaded. That matches what the uninstall path already does with an unreadable journal of the module being removed (r9): the same evidence, the same conservative answer.
Pinned by PluginInstallUtilsUpdateTransactionTest#updateWhileAJournalCannotBeRead_isRefused; restoring the continue fails it (UltiTools-Reborn-505-MUTATION-sweep-c7-RED.log).
Codacy's NPath threshold is 200 and the sweep's rows pushed three methods past it: deciding whether a journal is this boot's business at 480, recovering one at 320, and running an update transaction at 288. Each decision now has a method named after the question it answers - whether this boot has to decide a journal, whether a journal is missing what recovery needs, and whether the module's runtime-name key can be claimed - so the callers read as the sequence they are. Also one test helper, which builds a path from its own @tempdir listing and says so where the marker has to sit to count. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4cb43c18f6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| try { | ||
| Files.deleteIfExists(installed); |
There was a problem hiding this comment.
Preserve restored JARs when retrying a rollback
When a rejected same-version retry has a recorded original path equal to target, the first rollback restores the working JAR to installed. If another pair or an incomplete journal keeps the transaction in rolling-back (or journal deletion fails), the next boot enters this method again and unconditionally deletes that restored working JAR; its aside is already gone, so the pair is then treated as settled and cannot be restored again. Make candidate removal idempotent or record that it has already occurred instead of deleting the target on every rollback retry.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and fixed in a3a80abb. The tables had no row for a retried rollback — the enumeration was incomplete, the row is now A21 in the pull request description, and this is where it came from.
The rollback deletes what sits at the rejected update's target path because that is the candidate — except after an attempt that already restored a set-aside JAR whose own path is that target, which is exactly the same-version retry. Its staging copy is then gone, so the next attempt would take the module's only remaining copy with nothing to restore it from. The pairs and the staging directory already say which case it is: a pair whose original equals the target and whose set-aside file no longer exists means the file there is restored, not rejected. No new state, and no marker to keep in step.
Pinned by ModuleUpdateRecoveryTest#rollbackRetried_keepsWhatTheFirstAttemptRestored — one restored same-name pair plus a second pair whose path is occupied, so the journal survives into a second attempt. Removing the check fails it (UltiTools-Reborn-505-MUTATION-sweep-a21-RED.log).
| Files.deleteIfExists(target); | ||
| Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); |
There was a problem hiding this comment.
Keep the old journal until its replacement is published
Fresh evidence after the earlier portable-replacement fix is that this fallback deletes the only existing journal before publishing its replacement. During markJournalAwaitingBoot, the candidate is already installed and the previous JARs are already in staging, so a process termination between these two calls—or a failure of the second atomic move—leaves no journal at all; the next boot consequently cannot roll back an unloadable candidate and treats the old JARs as unreferenced leftovers. Preserve the original journal until the replacement has been successfully published.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and fixed in a3a80abb. This is row A18, whose window I read wrongly: I wrote that a crash inside the fallback lands in row A8, and A8 needs a journal to exist, which is the one thing this window does not leave.
Rather than keep the old journal — one name, one slot, and no portable way to hold both — the recovery now knows what a .txn.tmp can be. Beside its journal it is a half-written file and is deleted as before. Alone, and complete, it is the replacement whose rename never finished: it is renamed into place and recovered, so the set-aside JARs keep their record and an unloadable candidate can still be rolled back. Alone and incomplete — a journal that was never finished being created — it is deleted, as before.
Pinned by ModuleUpdateRecoveryTest#completeTemporaryJournal_isAdoptedWhenTheJournalIsGone; deleting every temporary journal as stale fails it (UltiTools-Reborn-505-MUTATION-sweep-a18tmp-RED.log).
A rollback recorded in a journal needs nothing from the load phase, so waiting for the confirmation hook costs the module a whole session: the rejected JAR is on the class path while the modules load, and the JAR that does work is put back only afterwards. The row that called that correct is now a test that runs the real boot order and expects the restore before the load. And a journal that cannot be read at all cannot be ruled out as an update of this module awaiting a restart, which is the ambiguity the refusal exists to prevent - so the update is refused rather than started beside it. The main-code change is in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… refuse beside an unreadable journal Two rows of the sweep the confirmation round corrected. A journal in the rolling-back phase carries a verdict made at an earlier start, so nothing in it waits on the load phase. The pre-load hook now finishes it - the rejected JAR is deleted and the working one put back before the class path is built, so the module loads this session rather than the next. The confirmation hook keeps its own rolling-back branch for a rollback that started after the modules had loaded. An update is also refused while any journal in the staging directory cannot be read: nothing can show it is not this module's, and if it records an update awaiting a restart, starting beside it produces exactly the two-verdict boot the refusal exists to prevent. It is reported as an unusable staging state, naming the file, which is what the operator has to clear. Both rollback callers now run one body, so what the two hooks do cannot drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hose window it mis-read A rollback can be retried, and a same-version retry sets aside a JAR whose own path is the one the candidate was installed to. Once that JAR is back, the set-aside copy is gone - and a second attempt that deletes whatever sits at the target deletes the module's only remaining copy. The tables had no row for a retried rollback; this is it. And the fallback for a file store that refuses an atomic replace removes the old journal before renaming its replacement in. A process that dies in that window leaves a complete journal under a temporary name and none beside it, which the recovery deleted as stale - so the set-aside JARs became leftovers nothing named and an unloadable candidate could not be rolled back. The row exists (A18); what it claimed about that window did not hold. The main-code change is in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…name never finished The rollback deletes what sits at the rejected update's target path, which is the candidate - unless an earlier attempt already put a restored JAR there. That happens in a same-version retry, where a set-aside JAR's own path is the target: once it is back, its staging copy is gone, and deleting the target on the next attempt would take the module's only copy with nothing left to restore it from. The pairs and the staging directory say which case it is, so no new state is needed. Boot recovery no longer deletes every `.txn.tmp` as stale. One beside its journal still is; one with no journal and a complete record is the replacement half-published by the fallback for a file store that refuses an atomic replace, and it is renamed into place and recovered. A half-written one - the other way a temporary journal appears - reads as incomplete and is deleted as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2c2c01d52c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // The candidate goes first: a same-version retry sets aside a JAR whose path is the one | ||
| // the candidate now holds, and restoring onto it would fail or replace the wrong file | ||
| // (sweep row A-marker). rollBack then keeps the journal if any JAR stayed in staging. | ||
| deleteQuietly(operations, target); |
There was a problem hiding this comment.
Abort rollback when the candidate cannot be deleted
Fresh evidence after the earlier marker-write rollback fix is that deleteQuietly discards a failure to remove the installed candidate and rollback proceeds anyway. If the confirmation marker cannot be written and this deletion also fails, old JARs with different filenames can all be restored successfully, causing rollBack to delete the journal even though the candidate remains installed; the command reports failure, but the next restart sees both versions and may load the rejected candidate. Candidate deletion must be verified before restoring the old JARs, with the journal retained when it fails.
Useful? React with 👍 / 👎.
| markJournalRollingBack(journalFile, name); | ||
| completeRollback(journalFile, pluginsFolder, stagingFolder, name, target, pairs, incomplete, null); |
There was a problem hiding this comment.
Require a durable rolling-back phase before mutating files
If writing phase=rolling-back fails before replacing the existing journal, this call leaves the journal marked awaiting-boot-confirmation but immediately starts mutating files. A crash or restore failure after one old JAR has returned then lets that old module load on the next boot; confirmation interprets it as a successful update and deletes the still-unrestored set-aside JARs. Do not begin rollback unless its phase is durably recorded, or retain another state that pre-load recovery will unambiguously finish.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a3a80abb81
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Step 3: select the older JARs while the new version is not in the modules folder. This | ||
| // happens before validation, because those JARs are what the download must NOT be completed | ||
| // from: they are deleted by this transaction (Codex review r11). | ||
| List<File> olderJars = operations.findModuleJars(pluginsFolder, identifyString); |
There was a problem hiding this comment.
Coordinate direct installs with update transactions
When /upm update is downloading asynchronously, /upm install can install another version of the same identify-string because the install paths neither acquire MODULE_OPERATIONS_IN_PROGRESS nor check pending journals. Since this line snapshots the existing JARs before that download finishes, the concurrently installed JAR is absent from olderJars; the update can then return UPDATED while leaving both versions in the modules folder. Route direct installs through the same module guard and reject them while an update is running or awaiting boot confirmation.
Useful? React with 👍 / 👎.
| if (journal.exists() || !isCompleteJournal(entry)) { | ||
| deleteStaleDownload(entry); |
There was a problem hiding this comment.
Retain unreadable temporary journals
When the replacement fallback was interrupted after deleting the old .txn, the .txn.tmp can be the only record protecting the set-aside JARs. If that temporary file is merely unreadable during the next boot, isCompleteJournal returns false and this branch deletes it as though it were proven half-written; the old JARs are then orphaned and an unloadable candidate cannot be rolled back. Distinguish an unreadable temporary journal from a successfully parsed but incomplete one and retain the former.
Useful? React with 👍 / 👎.
Codacy's NPath threshold is 200 and the row-A12 and row-A8 branches took recoverJournal to 256. Deciding whether a journal belongs to the rollback that is already under way, to the confirmation hook, or to an update running in this process is now one method, and moving every set-aside JAR back is another. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a3e4a41128
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!rollingBack && loaded.contains(module)) { | ||
| confirmUpdate(journalFile, stagingFolder, name, pairs, skippedAPair.get()); |
There was a problem hiding this comment.
Confirm the updated artifact rather than any matching identity
Fresh evidence beyond the earlier shared-runtime-name thread is that findPluginJars skips an old JAR while it is unreadable, allowing the update to leave that copy in the modules folder. If it becomes readable at boot and the candidate itself fails to load, the old copy supplies the same identify-string, so this condition confirms the failed candidate, deletes the rollback material and journal, and leaves the rejected candidate installed. Confirmation needs to identify the loaded target/version rather than accepting any loaded version of the module.
Useful? React with 👍 / 👎.
| if (outcome.getStatus() == UpdateOutcome.Status.OLD_JAR_NOT_MOVED | ||
| || outcome.getStatus() == UpdateOutcome.Status.NEW_JAR_NOT_INSTALLED | ||
| || !outcome.getUnrestoredFiles().isEmpty()) { |
There was a problem hiding this comment.
Surface unsupported atomic moves through the boolean API
When an actual move throws AtomicMoveNotSupportedException and rollback restores everything, the transaction returns ATOMIC_MOVE_UNSUPPORTED with an empty unrestored list, but this condition excludes that status and updatePlugin silently returns false. That contradicts this public method's new contract that failed JAR moves throw an UncheckedIOException, and prevents legacy callers from receiving the filesystem-specific failure needed to diagnose why updates cannot work on that provider.
Useful? React with 👍 / 👎.
|
Closing in favour of #561. On 2026-09-29 the maintainer decided to finish #505 by observation rather than prediction: instead of deciding before a restart whether a downloaded JAR will load, 改由 #561 接替,本 PR 关闭。 维护者 2026-09-29 决定:#505 改用观察而不是预测来完成——不再在重启前判断下载的 JAR 能不能加载,而是 |
Summary
/upm uninstallunloads through the framework's one full unload path and reports what actually happened on disk;/upm updateinstalls a new version and lets the next start confirm or roll it back, rather than predicting whether the module will load. Companion to #500 (#495) in the same Phase 17 wave-0 real-machine session.#503:
/upm uninstallbypassedPluginManager#unregister.PluginInstallUtils#uninstallPlugincalledplugin.unregisterSelf()directly, so a module's@Scheduledtasks,@PlayerCachebeans, completers, EventBus handlers, panel responders and@ConditionalOnConfigrecords outlived the uninstall and its container was never closed. It now unloads every matching module throughPluginManager#unregisterand delists it.#501: the uninstall reply did not match the outcome. The success reply told the operator to delete a jar the command had already deleted, and the
delete()result was ignored. The reply now reflects the real outcome:plugin.ymlname:matches is deleted (not just the first one);#505:
/upm updatereported success for updates it did not complete. The update is a staged transaction whose last step is the next start:/upm updateand/upm uninstall, keyed by identify-string and by the module's runtime name);plugins/UltiTools/.upm-staging/under a unique name that does not end in.jar, outside the modules folder;plugin.ymlcontract — identify-string,name:, and the catalogue's latest version, compared as written so2.10stays2.10;awaiting-boot-confirmation, and the reply says the restart confirms the update.The next start completes it. After the modules have loaded, every journal awaiting confirmation is resolved by observing what happened. The question is asked of the module's identify-string, which the journal records — two modules can carry the same
plugin.ymlname:, and the one that loaded need not be the one this journal updated:If the journal cannot be marked as awaiting a boot — the disk fills, permissions change — the update undoes itself there and then, while both versions are still on disk, and reports a failure: without that record nothing could confirm or roll it back, and the reply had just promised the restart would.
The restored module is deliberately not registered in flight — registering after the load phase has its own hazards, and the restart boundary is stated rather than implied. A failure anywhere in this hook is logged and never stops the server, which is pinned by a test.
Why the load prediction was deleted. Earlier revisions of this branch tried to answer, before installing, whether a module would load from a download — first with a class-file parser, then with the loader's own scan run in a reconstructed environment. Nineteen review findings in a row were each one difference between that answer and what boot does. Two of them say why the shape cannot work:
UltiTools#collectModuleJarUrlsbuilds the class path fromFile.listFiles, which guarantees no order. There is no boot ordering to reproduce — the thing being predicted is not fixed.Bukkit.getLogger()and opens a class loader, and boot recovery runs before either exists; measured, adding the call made two recovery tests fail with nothing restored.Boot confirmation does not ask whether a module will load. It observes whether it did, so that class of divergence has nothing left to attach to. What remains of validation is the
plugin.ymlcontract, which is finite and documented and has nothing to diverge from.The durable-state enumeration
The update's state on disk is finite, so it is enumerated here once rather than one row per review round. Three tables: every gap between two consecutive durable writes, every operation an operator can run while a journal is unresolved, and every status the new code receives and drops. Twelve rows were wrong and are fixed in this pull request — eight found by the enumeration itself, and four the confirmation reviews corrected: two verdicts the enumeration got wrong (A12, C7), one window it read wrongly (A18), and one row it did not have at all (A21, a retried rollback). Each is pinned by a test that fails when the fix is reverted in a throwaway copy.
The durable writes, in execution order. An update: W1 create
.upm-staging/· W2 write the download as<id>-<version>-<uuid>.part· W3 create the journal (temp file, then rename) · W4 move older JAR k aside (k = 1..N) · W5 move the download in as<id>-<version>.jar· W6 rewrite the journal withphase=awaiting-boot-confirmation. At the next start, confirmation: W7 delete set-aside JAR k · W8 delete the journal; or rollback: W9 rewrite the journal withphase=rolling-back· W10 delete the installed JAR · W11 move set-aside JAR k back · W12 delete the journal. An uninstall: W13 delete the module's set-aside JARs and journals · W14 delete the module's JARs.Table A — crash windows
.partfile; deletes it.part; deletes it. The modules folder was never touched.txn.tmp; deletes itrolling-backjournal carries a verdict made at an earlier start and needs nothing from the load phase, so pre-load recovery finishes it; the class path is built after the restorephase=rolling-backis written before the rollback touches anything, and a start that finds it finishes the rollback rather than confirming itFiles.moveleaves replace-on-ATOMIC_MOVEto the provider, so one that refuses is handled by removing the target and retrying the rename; and a.txn.tmpwith no journal beside it is now adopted when it is complete, deleted only when it is half-written. The first fix's own window is closed by the second (Codex confirmation round)Table B — what is legal while a journal is unresolved
/upm updateof the same moduleALREADY_IN_PROGRESS, nothing downloaded and nothing changed/upm updateof a different module/upm update all/upm uninstallof the same module/upm installof the same module/ul reloadTable C — signals the code receives and drops
journalPairs' incomplete-read flagconfirmOneUpdatepassed a throwawayAtomicBooleanleaveForBootConfirmationdeleteStagedFile's failureconfirmUpdatelogs it and still deletes the journalmarkJournalRollingBack's failureFiles.deleteIfExists' boolean at five sites (stale download, temp journal, journal, installed target, staged download)deleteAllOrThrowrestoreSetAsideJar,recoverPair,markJournalAwaitingBoot,newVersionWasInstalledconfirmUpdatesAfterBootreturnsvoidUltiTools#initPluginModulesTwo rulings applied
ATOMIC_MOVEreplacing an existing target (row A18), and a provider that refuses it is a test, not a comment.Behaviour change for operators.
/upm updatenow repliesUpdate installed! Please restart the server: the restart confirms whether the module loads, and rolls back to the previous version if it does not.— and the restart may roll the update back, saying so on the console.FEATURES.mdcarries the newultitools.upm.update.boot-confirmationrow;UAT-CHECKLIST.mdcarriesultitools.upm.update.boot-confirmsandultitools.upm.update.boot-rolls-back.Compatibility. No existing public signature changes; one method and its result type are added (
PluginInstallUtils.updatePluginTransactionally(String)returningPluginInstallUtils.UpdateOutcome).COMPATIBILITY.mdrecords two changes under "Behavioral changes that need no migration period", as corrections of behaviour that contradicted the methods' own javadoc:uninstallPluginnow throwsFileSystemException(a jar that could not be deleted),NoSuchFileException(module unloaded, no jar) orIllegalStateException(the module's unload threw).uninstallPluginalso throwsPluginModuleExceptionwith the newErrorCode.PLUGIN_OPERATION_IN_PROGRESS, with nothing changed, while an update or uninstall of the same module is running. The module's keys are resolved from the loaded modules, from the jars in the modules folder, and from the journals of running updates — in the window between moving the old jar aside and moving the new one in, the journal is the only thing that names the module — and they are taken all-or-none under one monitor.updatePluginreturnsfalseonly when nothing changed (failed download, staging unavailable or on another file system, invalid download, newer jar present, refused concurrent operation), and throwsUncheckedIOExceptionwrapping aFileSystemException— with the move's exception as cause — when a move fails or a set-aside jar could not be moved back.updatePluginTransactionally,UpdateOutcomeand the boot-timerecoverInterruptedUpdatesare@ApiStatus.Internal. On Windows a loaded module's jar cannot be moved, so the update rolls back and says so.Reverted within this branch. Two commits (
098be913,d0e67f13) also routed the superseded-version unload inPluginManager#unregisterSupersededVersionsthroughunregister(existing);e7b5abadand79639271revert them. The independent review showed that change also stripped the newer copy's name-keyed completers, responders and EventBus handlers, because those registries are keyed by the module name both copies share. That leak and the registry collision are tracked as #506 (wave 4); the path is reachable only from code.PluginManager.javais unchanged fromalpha. The reason is also recorded on #503: #503 (comment)Routed, not fixed here:
uninstallPluginstill builds ajar:file:URL string. It fails on a non-jar entry, and (new trigger added to the issue) on a server path containing a space.unregister()does not delist, the async/upm listraces the list, and (comment added) module config entities stay registered and are saved at shutdown after uninstall.Doc-sync: not owed. Measured on
UltiTools-Dev-Docorigin/alpha(e02aa7e):git grep -i -c -E "uninstall|upm update|PluginInstallUtils" -- docs/srcreturns no hits. The only/upmmentions (git grep -i -c "upm ") are theupm list/upm installprose inguide/advanced/maven-plugin.mdand itszhcopy. Control:git grep -c UltiToolsPluginreturns hits, so the tree was read.中文摘要:
/upm uninstall改为通过PluginManager#unregister完整卸载(#503),并如实报告结果:所有同名 JAR 删除后才报成功,删除失败时逐一列出残留 JAR,模块已卸载但找不到 JAR 时单独提示,模块自身卸载抛异常时同时报告 JAR 结果(#501)。/upm update改为分阶段事务:同一模块的并发更新被拒绝;先下载到模块目录外的暂存目录并校验,再选出并移走旧 JAR、移入新 JAR,任何失败都会回滚且如实报告(#505)。之前在原地写入后再猜测旧 JAR 的做法连续四次修补都引出相邻缺陷,故整体重做。COMPATIBILITY.md记录了两个方法的行为变化。分支内曾修改“被取代版本卸载”路径,因会误删新版本按名称登记的补全器等而已回退,转入 #506;#504、#507 另行跟踪。第四轮审查后:暂存目录与模块目录不在同一文件系统时拒绝更新(只做原子重命名,不复制),启动时恢复中断更新留下的旧 JAR,更新与卸载共用同一模块锁,拒绝覆盖比目录版本更新的 JAR,失败原因写入回复。无需同步开发者文档。第五轮审查后:更新事务在移动 JAR 前写入日志文件,启动恢复只依据日志回移其记录的 JAR,无日志的残留 JAR 只报告不回移(因此已卸载的模块不会复活,崩溃也不会把模块降级到旧版本);日志记录写入它的 JVM,因此本进程仍在进行的事务不会被自行恢复;卸载的模块锁也从日志解析,并一次性全部获取;「未做任何更改」不再与随后列出的残留文件自相矛盾;跳过统计的中文措辞改为「另一项更新或卸载」。Issue closure
Closes #503
Closes #501
Closes #505
Verification
Run in the worktree at the final commit (
aa739534), JDK 21, every log written after that commit:mvn -B -nsu clean verify:Tests run: 5876, Failures: 0, Errors: 0, Skipped: 0, BUILD SUCCESS, japicmp gate passed (all entries for the touched classes are additions:ErrorCode.PLUGIN_OPERATION_IN_PROGRESS,updatePluginTransactionally,recoverInterruptedUpdates,UpdateOutcome). The POSIX-permission, symbolic-link and hard-link tests ran and were not skipped.mvn -B -nsu test -Dgroups=isolated -DexcludedGroups=:Tests run: 23, Failures: 0, Errors: 0, Skipped: 0, BUILD SUCCESS..github/scripts/check-cjk-scope.sh:0 CJK violation(s) across 0 file(s).design.xmlNPathComplexity rule (threshold 200) oversrc/main: 0 violations.Review rounds. Eight Codex rounds and six own-review rounds; every thread is answered (
unreplied threads: 0) and every review file's dispositions are filled. The later rounds hardened the recovery path: a restart-unique journal owner, a recorded committed phase, journals retained while anything they name is unresolved, leftovers never advertised as deletable while their journal survives, downloads validated against what the loader actually requires, and an uninstall that clears — and reports — the staging state that could otherwise resurrect the module.Static analysis. Codacy reports
Your pull request is up to standards!and CodeQLNo new alerts in code changed by this pull request. Both were red on the previous heads of this branch and are now clear:plugin.ymlreader added in review round 4, which built its parser with SnakeYAML's default constructor. It only composes a node tree, so no object is ever constructed from a module's file, but the parser is now built withSafeConstructor— measured to exist with aLoaderOptionsparameter in every SnakeYAML from 1.26 to 2.2, so no runtime fallback is needed.NPathComplexitymethods (51,840 / 4,800 / 340 against a threshold of 200), six fields declared below the methods that use them, two fully qualified names with an import already present, one Semgrep path-traversal hit on a test reading its own temporary directory, and one markdown code span with a trailing space inside it. The complexity was resolved by naming each step of the transaction and of recovery as its own method, not by suppression; the rollback sequence that existed in four copies is now one method.Red-when-reverted evidence
The work was test-first; no fix commit touches a test file. For each fix, the named tests fail with the fix reverted (assertion failures, not compile errors) and pass with it restored:
/upm uninstall bypasses PluginManager#unregister: scheduled tasks, EventBus handlers and the module context outlive the uninstall #503 uninstall unload path (
cb44d44e); /upm uninstall tells the operator to delete the module jar manually after it has already deleted it #501 reply and delete result (edba829f); /upm uninstall tells the operator to delete the module jar manually after it has already deleted it #501 every jar (7478be55); /upm uninstall tells the operator to delete the module jar manually after it has already deleted it #501 unloaded module with no jar (5b9c381e); /upm uninstall bypasses PluginManager#unregister: scheduled tasks, EventBus handlers and the module context outlive the uninstall #503 uninstall whose module unload throws (8ed656c5)./upm update reports success when the old module jar could not be deleted (updatePlugin ignores File#delete) #505 staged update transaction (
fd384224): an invalid download (invalidDownload_keepsTheOldJarAndReportsFailure,downloadOfAnotherModule_keepsTheOldJarAndReportsFailure) and an overlapping update / failed later transfer (overlappingUpdateOfTheSameModule_isRefusedAndNoJarIsTruncated,laterUpdateWithFailedTransfer_leavesTheInstalledJarIntact) — 2/2 red each./upm update reports success when the old module jar could not be deleted (updatePlugin ignores File#delete) #505 update replies (
056faa4a): 8/8 command outcome tests red.Review round 4 (each fix reverted at its own commit): non-http link (
134910d6, 1/1); same-file-system refusal and staging label (7bee9f8e, 3/3); unrestored original path and failure cause (760eb902, 4/4); raw plugin.yml version (73762181, 1/1); newer jar refusal (8a0dd765, 1/1); shared update/uninstall guard (4ae0b87c, 2/2); boot recovery (1b069df5, 4/6, the two others assert untouched states); command replies (0a1c5efb, 10/10);updatePluginthrows on an unrestored jar (86487c3a, 1/1).Review round 5 (
7869a9eb,3e9b4964,a76ac733,00506f2areverted together): 12/12 red — the eight journal-based recovery tests, the journal lifetime and uninstall-window tests, and the two reply tests.Six mutations pin the lines a revert cannot reach, each killing exactly the assertion written for it: restoring a set-aside jar that no journal names (3 tests), keeping the journal after a successful transaction (2), recovering this process's own journal (1), removing the per-journal isolation of the boot sweep (1), never releasing the uninstall's keys (3), and taking those keys one at a time instead of all-or-none (1). The last two were the releases review round 4 measured as unpinned.
The staged transaction's rollback and ordering paths are covered by
PluginInstallUtilsUpdateTransactionTestthrough a package-private file-operations seam, including a test that old jars are selected before the new version exists in the modules folder (the case-alias defect is excluded by construction, as it cannot be reproduced on a case-sensitive CI filesystem). Mutation runs confirm the tests catch removing the rollback move-back, skipping validation, and skipping the per-module guard, as well as reverting the uninstall call site tounregisterSelf()and dropping the jar outcome from an unload failure. Six further mutations found unpinned in review round 4 are each now caught by a test: the no-replace check before an atomic move, the guard key normalisation, the guard release infinally, the tolerance of a jar gone since selection, the loader's size/entry limit in validation, and deleting a partial download.Checklist
alpha, unless this is analpha->mainrelease promotionfile <path>before and after; this tree is mixed CRLF/LF).github/cjk-allowlist.txtUltiTools-Dev-Doc(branchalpha) if documented behaviour changed. Not owed; see the measurement above.FEATURES.mdandUAT-CHECKLIST.mdupdated for every feature change in this PR:ultitools.upm.uninstall,.neg-command-gone,.neg-scheduled-task-gone(now watching UltiCleaner's 15-second task),ultitools.upm.updateandupdate-all;ultitools.upm.uninstall.neg-delete-failed,.neg-no-jar,ultitools.upm.update.neg-folder-read-only,ultitools.upm.update-all.neg-folder-read-only,ultitools.upm.update.neg-interrupted-recovered-on-bootandultitools.upm.update.neg-leftover-without-journal-kept;/upm updatepreconditions performable: the staging directory is cleared by one ordered procedure instead of a loop that never terminates, and "both folders are on the same file system" is now measured withfindmntand recordedblockedwhen it does not hold.🤖 Generated with Claude Code
https://claude.ai/code/session_017BD9mGr6EsyRsEv3d2BizL