Skip to content

fix(upm): commit a module update only after the next start shows it loaded (#505, #513, #518, #517) - #561

Open
wisdommen wants to merge 91 commits into
alphafrom
fix/p17-fu-module-files
Open

wisdommen wants to merge 91 commits into
alphafrom
fix/p17-fu-module-files

Conversation

@wisdommen

@wisdommen wisdommen commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

This pull request targets alpha deliberately (integration branch for 6.3.0), not the default main. /upm update no longer replaces a module's JAR while the server runs and no longer reports "Update successful" for an update that did not happen: it stages the new JAR, the next start swaps the files before any module class loads, and the update is kept only when that start shows the module actually loaded from the new JAR at the new version. An uninstall JAR that cannot be deleted now (Windows holds it open) is deleted at the next start, and the modules folder is computed in one place. Plan 17-44 adds module identity on disk: module JARs are discovered in file-name order for both the scan and the class loader (#476); the loader records which JARs declare each main class, /upm uninstall deletes every one of them (#516), and one start-up warning names every duplicate copy (UltiTools-Dev-Doc#96); an uninstall cancels the module's staged update by the identity it resolved, on every outcome (Codex round 10).

本 PR 有意指向 alpha(6.3.0 集成分支)。/upm update 不再在服务器运行时替换模块 JAR,也不再对没有发生的更新报告「更新成功」:命令只暂存新 JAR,下次启动在加载任何模块类之前换文件,只有这次启动确实从新 JAR 以新版本加载了该模块,更新才保留,否则恢复旧版本。卸载时删不掉的 JAR(Windows 占用)改为下次启动再删;模块目录只在一处计算。计划 17-44 补上磁盘上的模块身份:扫描与类加载器都按文件名顺序发现模块 JAR(#476);加载器记录每个主类由哪些 JAR 声明,/upm uninstall 删除其中每一个(#516);重复副本只在启动时输出一条列出所有副本的警告(UltiTools-Dev-Doc#96);卸载在任何结果下都按解析出的模块身份取消暂存的更新(Codex 第 10 轮)。

Threat model

This pull request defends the module-file transactions against crashes, restarts, the operator's normal commands and panel actions (/upm install, /upm update, /upm update all, /upm uninstall, restarts from the panel), and concurrency among those. Findings whose trigger lies outside that scope — a Java security manager denying file access, hand-edited framework-internal files, a third party changing the modules folder or the transaction folder while a transaction is pending, and the like — are not fixed in this pull request; they are tracked in its follow-up issue, #565. The foreign-file guard (d76b3d79: the transaction only moves, replaces or deletes files whose SHA-256 matches the record, otherwise it holds the record as NEEDS_OPERATOR) stays, because it is done and tested; it is not a claim that such scenarios are in scope. Review is run locally under the maintainer's 2026-10-01 rule (at most two local Codex runs; P1 always fixed, P2 fixed only inside this threat model).

本 PR 的威胁模型:崩溃、重启、管理员的正常命令与面板操作,以及这些操作之间的并发。超出此范围的发现(安全管理器、手工修改框架内部文件、交易进行中第三方改动模块目录等)不在本 PR 修复,统一记录在后续 issue #565 中。外来文件守卫已完成并有测试,予以保留。

Issue closure

Closing keywords do not fire for pull requests into alpha; these issues are closed by hand after the merge.

Closes #505
Closes #513
Closes #518
Closes #517
Closes #516
Closes #476

Why observation, not prediction

Before this change, /upm update downloaded the new JAR into the modules folder, called File#delete() on the old one while ignoring the result, and reported success. A failed delete left two versions to race at the next start (#505); a JAR that could not load was only discovered after the old one was gone (#513).

PR #508 tried to close #505 by predicting, before the restart, whether a downloaded JAR would load: does it declare name:, is the main class present, readable, concrete, named correctly, within the first 1,000 classes, with its superclass inside the JAR, and so on. Each fix exposed the next way the prediction differed from the real loader. Over 29 review rounds it collected 51 findings, 13 of which named the previous round's fix as their cause; replacing the hand-written class-header parser with the loader's own scan only moved the same divergence one level up (loader topology, parent-first ranges, URL order).

On 2026-09-29 the maintainer decided to stop predicting and observe instead: the next start shows whether the module loaded. This pull request implements that. No code in it loads, scans or parses the classes of a downloaded JAR; staging reads only its plugin.yml (identity and version) and applies the existing size/entry guard. PR #508 is closed in favour of this one; its branch is kept for its history.

What changes

Issue Change
#505, #513 New ModuleFileTransactions (@ApiStatus.Internal). /upm update downloads into <server root>/.ultikits/upm-transactions/, writes a PENDING record atomically, and replies "staged, takes effect at the next start". At the next start, before new URLClassLoader(...), the old JAR is moved aside (kept) and the new one moved in (APPLIED). After PluginManager#init returns — or throws, which counts as not loaded — the update commits only if the module is loaded from the new JAR at the new version; otherwise the old JAR is restored, the new one removed, and one line names both versions.
#505 Truthful failures: a move that fails leaves the modules folder as it was, logs one SEVERE line naming the file and the error, keeps the record as FAILED, and the next /upm update of that module reports it first. One pending update per module; update-all stages each module independently; an uninstall cancels that module's staged update.
#505 Crash safety: every state change is written before the step that depends on it; the new JAR is recognised by the SHA-256 recorded at staging, never by name; a rollback deletes only that file and only while a kept old JAR exists. Each crash window has a named test (after the record is written; after the old JAR moved; after the new JAR moved; after APPLIED; after the commit or rollback decision). An APPLIED record found at a start (the previous start ended before deciding) is rolled back before loading.
#505 Record confinement: records name files only by a .jar file name, resolved and canonicalised against the one folder each belongs to; .., separators, links leaving the folder, symbolic-link working folders and missing fields make the record refused with a warning.
#505 (maintainer, 2026-09-30) The records live under <server root>/.ultikits/upm-transactions/, beside the credential store, found as the data folder's grandparent — not under plugins/UltiTools/, which the panel's file interface can write when file writing is enabled (a forged removal record could have deleted a module JAR). The panel file interface is unchanged. The start-up swap stays an atomic rename: when plugins/ is on another file system the rename is refused, nothing in the modules folder changes, the record is FAILED, and one SEVERE line names both folders. There is deliberately no copy fallback.
#518 When /upm uninstall cannot delete a JAR, the deletion is recorded (with size, modification time and SHA-256) and the reply says it will be deleted at the next start; that start deletes it before any module loads, only if the file is unchanged, and logs it. An install through /upm of the same file name forgets the recorded deletion.
#517 ModuleFileTransactions#modulesFolder (data folder + plugins) is the one computation; the class loader, the start-up scan (which read user.dir), install, update and uninstall all use it. The issue's premise was corrected by measurement: only the scan read user.dir.
#476 (17-44) ModuleFileTransactions#moduleJars lists the modules folder once, sorted by file name (plain String#compareTo); the start-up scan (PluginManager#discoverModuleClasses) and the module class loader (UltiTools#collectModuleJarUrls) both read it, and the uninstall's listing and findPluginJar use the same order. Before, both took File#listFiles() order, which is hash order on ext4.
#516 (17-44) The start-up scan records, per plugin.yml main: class, every JAR that declares it and the JAR the class loaded from (ModuleJarIndex, @ApiStatus.Internal) — the loader's own read, not a second scan. /upm uninstall treats an entry whose plugin.yml main: names a target's main class as the module's, whatever name: it declares, so a renamed second copy is deleted and named; a main class another loaded module has is excluded; a file is judged by what it declares at uninstall time. No class is read out of any archive.
UltiTools-Dev-Doc#96 (17-44) After the scan, each main class two or more JARs declare gets one WARNING (catalogue line in en.json/zh.json) naming every copy and the JAR the classes load from. A copy whose main class loads from another JAR in the modules folder that declares the same main: is still refused, now reported by that warning instead of a per-copy SEVERE line; a class borrowed from a JAR that does not declare it keeps its SEVERE refusal. The legacy-order log line and javadoc now say file-name order.
Codex round 10 (17-44) The uninstall itself cancels, in a finally on every outcome but a refusal, the module's staged updates and running update downloads, matched on the identity it resolved before touching the folder: the unloaded instances' identify-strings and runtime names, the name keys, their code-source JARs, the JARs the scan recorded for their main class, and the entries it identified. Every form excludes what another loaded module also has. /upm uninstall reports the cancelled versions on every exit. ModuleFileTransactions#cancelStagedUpdates(RemovedModule) replaces the name-and-JAR form (which never shipped).
Follow-up 19, B-I-03 (17-44) Maintainer decision "refuse at update time": once the new JAR's name is known and before anything is downloaded, /upm update refuses when another JAR in the modules folder declares the module's main: (from the loader's ModuleJarIndex; the old JAR's plugin.yml as fallback) and sorts before the new JAR's name — that copy would load instead and every update would roll back. Nothing is downloaded, recorded or touched; a dedicated en/zh reason names the new JAR and every such copy. Each copy is judged by its current plugin.yml, so the retry after removal needs no restart; a copy that sorts after does not stop the update; the observation stays the backstop for a copy added after staging (ebe7e862, b3f8146b, rows 02e3a546).

Decisions applied

  • Observation, not prediction (maintainer, 2026-09-29) — see above.
  • Where the records live (maintainer, 2026-09-30): 「搬到服务器根目录的 .ultikits 下」 — move them to <server root>/.ultikits/upm-transactions/; do not change the panel file interface; a plugins/ folder on another file system is reported honestly, never partially updated. A copy+verify+delete fallback was allowed only if it provably kept every crash window's guarantee; it was not added.
  • D-17 (maintainer, 2026-09-29): 6.3.0 ships with no known defect.
  • Plan 17-44 (orchestrator, 2026-09-30): the Codex round-10 finding is fixed on this branch by 17-44, whose scope is module identity on disk, so the matching is designed once. Identity is decided only from plugin.yml and a loaded instance's code source; no class is read out of a candidate archive.

Checklist rows amended

FEATURES.md: ultitools.upm.update, ultitools.upm.update-all, ultitools.upm.uninstall (deferred deletion), ultitools.boot.deferred-removal, ultitools.boot.module-update-apply, ultitools.boot.module-update-observe.

FEATURES.md also states the shared-JAR refusal, the replaced-old-JAR abandon and the unlistable-folder report (unit-pinned).

17-44: FEATURES.md ultitools.upm.uninstall (main:-based identity, identity-based cancellation), new ultitools.boot.duplicate-module-jars, ultitools.boot.plugin-load-order-legacy (file-name order), reconciliation note (nine event rows). UAT-CHECKLIST.md new rows ultitools.upm.uninstall.second-copy, ultitools.upm.uninstall.cancels-staged-update, ultitools.boot.duplicate-module-jars, ultitools.boot.duplicate-module-jars.neg-none; ultitools.boot.plugin-load-order-legacy quotes the reworded line; every row relying on an enforced chmod now requires a non-root server process (gate 1). Every cited precondition row is earlier in the file. Follow-up 19: FEATURES.md ultitools.upm.update (the refusal), new UAT-CHECKLIST.md row ultitools.upm.update.neg-copy-loads-first (before ultitools.upm.update, whose staging then follows the copy's removal).

17-43: UAT-CHECKLIST.md, in file order: ultitools.upm.install-version, ultitools.upm.uninstall.deferred, ultitools.upm.update, ultitools.upm.update-all, ultitools.upm.update.neg-already-staged, ultitools.boot.deferred-removal, ultitools.boot.module-update-apply, ultitools.boot.module-update-apply.neg-apply-failed, ultitools.boot.module-update-apply.neg-cross-file-system (new: modules folder on a tmpfs), ultitools.boot.module-update-apply.neg-unconfirmed, ultitools.boot.module-update-observe, ultitools.boot.module-update-observe.neg-rolled-back. Every row names .ultikits/upm-transactions/; the existing /upm update and /upm uninstall rows were swept for the eight checklist defect classes.

Red-when-reverted evidence

Each behaviour has a test(...) commit, then a fix commit. With only the fix reverted, the named tests fail:

Change Test RED with fix reverted
#505 tracer (1bbc352b) ModuleUpdateTransactionTest compilation error (the class does not exist yet)
#505 expand (e5fc3fcd) ModuleUpdateRecoveryTest compilation error
#518 (f0c0a898) ModuleRemovalDeferredTest compilation error
#517 (a2887ee5) ModulesFolderSingleSourceTest 3 of 4 fail
commit record (b6e77039) ModuleUpdateRecoveryTest$UnrecordableDecision 1 of 1 fails
abandoned update (51490842) ModuleUpdateRecoveryTest compilation error
uninstall vs staged update (d381c13e) ModuleRemovalDeferredCommandTest, ModuleUpdateUninstallTest compilation error
staging lock (af925ff7) ModuleUpdateRecoveryTest, ModuleUpdateStagingTest compilation error
record confinement (c9a0ab96) ModuleUpdateRecoveryTest$Confinement 4 of 6 fail
records location + cross-file-system (6a2386ab) ModuleUpdateTransactionTest, ModuleUpdateRecoveryTest, ModulesFolderSingleSourceTest compilation error (the new catalogue key does not exist); with only that key kept, 4 of 32 fail (location ×2, structural ×1, cross-file-system ×1)
Codex 1: shared JAR, replaced old JAR (d99731d4) ModuleUpdateStagingTest, ModuleUpdateRecoveryTest with only the new keys kept, 3 of 35 fail (the same-bytes control passes, as it should)
Codex 2: unlistable folders (be4760ef) ModuleUpdateRecoveryTest, ModuleUpdateStagingTest with only the new key kept, 4 of 41 fail
Codex 3: denied listings (60847e7b) ModuleUpdateRecoveryTest 2 of 33 fail
Codex 4: staged JAR hash (cf10b491) ModuleUpdateRecoveryTest with only the new key kept, 3 of 36 fail
Codex 4: transaction boundary (b293e223) ModuleUpdateRecoveryTest$UncheckedExceptionInOneTransaction with the boundary catch and the new-JAR undo removed, 4 of 4 fail
Codex 4: record past its apply (4108101b) ModuleUpdateRecoveryTest$UncheckedExceptionPastTheApply 1 of 1 fails
Codex 5: malformed record (5d019894) ModuleUpdateUninstallTest 2 of 7 fail
Codex 6: declared version (ff837fe1) ModuleUpdateStagingTest with only the new key kept, 1 of 11 fails
Codex 7: incomplete record (be31008e) ModuleUpdateStagingTest 1 of 12 fails
Codex 8: same-identity shared JAR (c708e793) ModuleUpdateStagingTest 1 of 13 fails
Codex 9: uninstall during a download (2ed6dd78) ModuleUpdateStagingTest with only the new key kept, 2 of 16 fail
#476 file-name order (fc19baa6) ModuleJarOrderTest, PluginManagerDiscoveryOrderTest 4 of 4 fail
#516 main:-based uninstall (7a0e819f) ModuleJarIndexUninstallTest compilation error (the index does not exist); with the index kept and only the main: match disabled, 2 of 5 fail
UltiTools-Dev-Doc#96 duplicate warning (14deceb6) PluginManagerClassScanningTest 1 of 21 fails
Codex 10: cancellation by resolved identity (3a833e93) UninstallCancelsByIdentityTest compilation error; with the call disabled, 4 of 5 fail; with the pre-fix matching (typed argument and removed JAR names), 2 of 5 fail — exactly the unlistable-folder cases
17-44 self-review: shared identify-string (fc9407f8) UninstallCancelsByIdentityTest 1 of 6 fails
17-44 gate 1 I-04: report on every exit (8bcf5057) ModuleUpdateCommandTest 1 of 8 fails
17-44 gate 1 A-P2: shared runtime name (f56ee026) UninstallCancelsByIdentityTest 1 of 7 fails
17-44 follow-up 19, B-I-03 (b3f8146b) ModuleUpdateDuplicateCopyTest compilation error; with the check switched off, 5 of 9 fail
Codex round 12: ambiguous identify-string (ebc13975) ModuleUpdateStagingTest compilation error; with the check switched off, 1 of 17 fails
Codex round 13: denied file access (ab93f344) ModuleUpdateDeniedRecordWriteTest compilation error; with the three catches off, 5 of 5 fail
Codex round 14: recovery invariant (fda50c27) ModuleUpdateRecoveryInvariantTest (86 cases) 3 of 86 fail before the fix; with the later undo points reverted, 6 of 86
Codex round 16: staging race and external JAR names (f80dd639) ModuleUpdateStagingTest, UninstallCancelsByIdentityTest compilation error; with each fix reverted, 2 of 2 fail
Codex round 17: copy off the class path (88472f91) ModuleUpdateDuplicateCopyTest 1 of 18 fails before the fix

Full mvn -B clean verify -DexcludedGroups= at aa8ca8b6: 6371 tests, 0 failures (at 28fe0208: 6111); CJK scope gate 0 violations. (At 2ed6dd78, before 17-44: 6091.) A #504 regression check (UninstallStrayEntriesRegressionTest: stray entries, a space in the server path, every archive closed) passes before and after, as expected — #504 was fixed on alpha by 2fef89e6.

Review

Gate 1: two independent reviewers plus an executor self-review raised 1 blocker, 11 warnings and 6 informational findings (distinct). All are fixed on this branch, each behavioural fix test-first with a revert proof. The blocker (records writable through the panel file interface) was closed in two steps: .jar-only record names and no rollback without a kept JAR (c9a0ab96), then the maintainer's decision to move the records under <server root>/.ultikits/ (6a2386ab). Round-trip check: one finding was caused by an earlier fix on this branch (a new "abandon" signal whose consumer, the order of removals before updates, was implicit); every meeting point of an uninstall and a staged update was listed and closed in one commit (d381c13e). No rule was revised twice.

Gate 1, second section (17-44, whole branch): two independent read-only reviewers plus an executor self-review; blocker 0, warning 3, info 4. Fixed: the round-10 cancellation matched identify-strings and runtime names without the bystander exclusion (self-review and reviewer A, the same class; swept over every form the removed-module identity carries in f56ee026), a cancelled update unreported when the uninstall ends in an unexpected exception (8bcf5057), a UAT row that loses the module's JAR on a root server (swept over every chmod-based row), a sort-order precondition, and stale "a module needs no plugin.yml" text. B-I-03 (an old duplicate copy that sorts first makes every update roll back) was first left unchanged and is now fixed per maintainer follow-up 19: /upm update refuses before download (table above).

Gate 2 (third-party review)

  • Codacy: the first analysis reported 31 findings over the whole branch (NPath complexity ×4, field order ×7, fully-qualified names ×2, test file permissions ×12, path construction ×6). Cleared without behaviour change in 2ebd65cb and 86185ce7 (methods split, owner-only test permissions, justified suppressions at the four path helpers every record path goes through and at two test sites). Codacy is green from be4760ef.
  • Codex round 1 (6a2386ab): P1 shared JAR, P2 replaced old JAR — fixed in d99731d4.
  • Codex round 2 (d99731d4): P2 unlistable records folder — fixed in be4760ef, swept to the kept-JAR folder readers.
  • Codex round 3 (be4760ef): P2 a denied listing in the temporary-record clean-up — a sweep miss of round 2, fixed at every listing in 60847e7b.
  • Codex round 4 (60847e7b): two P2 threads. Per-transaction containment is closed by changing path — one boundary catch per transaction routed into the existing apply-failure path (b293e223) instead of a third per-site guard; the staged JAR is now checked against its recorded SHA-256 before anything moves (cf10b491).
  • Codex round 5 (15cef5de): a malformed record's unchecked JsonParseException escaped the install path — fixed in 5d019894 (one place: readRecord; both deferred-removal paths skip an unreadable record with a warning).
  • Codex round 6 (4108101b): a download declaring a version other than the catalogue's latest was accepted — fixed in ff837fe1.
  • Codex round 7 (ff837fe1): an incomplete record was reported as "already staged" — fixed in be31008e (one shared validation).
  • Codex round 8 (be31008e): P1 — the shared-JAR check exempted instances with the same identify-string, which is the real shared-archive case (identify-string comes from the archive's plugin.yml); a missed condition of the round-1 fix, fixed in c708e793 (only the selected instance is exempt), with a sweep of every identify-string/name/version comparison listed in the commit.
  • Codex round 9 (c708e793): an uninstall during a staging download did not cancel it — fixed in 2ed6dd78.
  • Codex round 10 (2ed6dd78): one P2 (cancellation matches the operator's argument rather than the uninstall's resolved identity, missed when an alias is used and no JAR path comes back) — predates the review rounds; fixed under plan 17-44 in 3a833e93 (see the table above), with the unlistable-folder variant tested.
  • Codex round 12 (02e3a546, after follow-up 19): one P2 in staging code predating the follow-up — two loaded modules in different JARs with one identify-string made /upm update select the first; fixed in ebc13975 (refused before download, reply names every such module), thread replied.
  • Codex round 13 (ebc13975): one P2 in record-write code predating the follow-up — a SecurityException from the record's Files calls escaped callers that catch only IOException; fixed by class in ab93f344 (record writes, both public record operations, both phases of staging, deleteTree), thread replied.
  • Codex round 14 (ab93f344): one P2 in the start-up restore path (byte-identical to 28fe0208): a FAILED record left with the new JAR in the modules folder and the old kept aside was restored only from the kept JAR, putting both on the class path. Fixed by defect class in fda50c27: the undo failApply runs is one helper, undoApply, now also used by later starts and by /upm update's discard (which had the same defect); every reachable record state × file placement is tested against the recovery invariant (table: fix(upm): commit a module update only after the next start shows it loaded (#505, #513, #518, #517) #561 (comment); 2 rows violated it before the fix); thread replied and resolved.
  • Codex round 15 (88745228): no finding. Codacy then reported 6 new issues (path-construction markers, helper-assertion suppressions), fixed in cb05fd3b; the first request on cb05fd3b returned a Codex error and no review.
  • Codex round 16 (cb05fd3b): two P2s in code predating the follow-up, outside the recovery state machine — /upm update snapshotted the loaded modules before staging's lock, so an uninstall in between was missed; the uninstall's cancellation matched JAR names from outside the modules folder. Both fixed in f80dd639 (test 29864b5e); threads replied and resolved.
  • Codex round 17 (f80dd639): one P2 in the follow-up-19 copy check — a copy failing SecurityPolicy.isValidModuleJar is never on the module class path, so it cannot load first; fixed in 88472f91 (test 7793c770), thread replied and resolved. The follow-up's review budget is spent; 88472f91 itself has not been reviewed by Codex.
  • Codex round 18 (88472f91): one P2, pre-existing — a removal record with a null entry threw out of install; fixed by class in 00d80764 (install and uninstall validate removal records as start-up does), resolved.
  • Codex round 19 (00d80764): two P2s in commit and rollback (a file at the new JAR's name that is no longer the staged JAR). Handled by one rule for the category in d76b3d79 (test fd64f6ec): the foreign-file guard and NEEDS_OPERATOR; foreign-file table (38 of 76 new rows RED before, all green after): fix(upm): commit a module update only after the next start shows it loaded (#505, #513, #518, #517) #561 (comment); threads replied and resolved. The GitHub review loop ends here; gate 2 continues under the local-review rule (see Threat model).
  • Local review, run 1 (ca5f6407, codex review --base alpha, gpt-6.1-sol, effort high): no finding. A first attempt on the same head produced no review (Codex's sandbox did not start, nothing was read) and is not counted. No fix commit, so no confirmation run is due (maintainer's rule: at most two local runs, the second after the single fix commit).
  • Gate 2 closed at aa8ca8b6 under the local-review rule: run 1 found nothing; aa8ca8b6 is a test-only Codacy fix; CI green. Out-of-model items: Module-file transactions: out-of-threat-model review findings (PR #561) #565.

Compatibility

COMPATIBILITY.md states that /upm update now takes effect at the next start and is committed only after the module is observed loaded, where the records live, and that a plugins/ folder on another file system is reported rather than updated. PluginInstallUtils.updatePlugin(String) keeps its signature; its true now means "staged". PluginInstallUtils.stageUpdate(String) and ModuleFileTransactions are @ApiStatus.Internal. 17-44 adds: updatePlugin(String) returns false when another copy of the module would load first (follow-up 19), file-name discovery order, the duplicate-copy warning, main:-based uninstall identity, and the identity-based cancellation (which PluginInstallUtils.uninstallPlugin(String) now performs too; the command reads the versions through uninstallPluginReporting(String, List), @ApiStatus.Internal). ModuleJarIndex and PluginManager#getModuleJarIndex() are @ApiStatus.Internal.

Consumer impact

Measured with git grep -i on the fifteen module origin/master trees and UltiTools-External-Example: PluginInstallUtils, ModuleFileTransactions and upm-transactions have 0 hits in Java sources in every tree (control: UltiToolsPlugin hits in every tree, 2 to 137). /upm appears in those repositories only in inventory documents, changelogs and javadoc describing uninstall's unload order, which this pull request does not change.

Load order (17-44): discovery is now file-name order. It decides which of several JARs carrying the same class supplies it, which copy of a duplicated module is read first, which module a plugin.yml name: shared by two modules resolves to, and the order of the opt-in legacy load. Modules without a dependency between them already loaded in alphabetical order of their class names and still do. An install that relied on one copy winning by listing order may see the other one win after upgrading — once, and then the same one on every start and file system.

Documentation

Companion pull request, into docs alpha: UltiKits/UltiTools-Dev-Doc#106 — module-versioning.md (English and Chinese): the real trigger of hasNewerVersionLoaded (UltiTools-Dev-Doc#96, closed by hand after it merges), two copies of one module (file-name order, the start-up warning, /upm uninstall deleting every copy), and updating/uninstalling on a server (/upm update staged and committed only after the next start observes the module loaded, rollback and failure lines; uninstall cancelling staged updates and deferring deletion).

🤖 Generated with Claude Code

wisdommen and others added 23 commits September 30, 2026 13:52
…on observation

- ModuleUpdateTransactionTest: staging leaves the modules folder unchanged and writes one
  PENDING record; the next start applies before loading and commits only after the module
  is observed at the new version, loaded from the new JAR
- ModuleUpdateStartupOrderTest: the start-up class path is computed after the apply
- ModuleUpdateFixtures: module JARs built with JarOutputStream, loaded-module stand-ins

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…served

/upm update no longer writes into the live modules folder or deletes the old JAR
(whose ignored delete() result was the reported defect). It downloads the new
JAR into upm-transactions/, a sibling of the modules folder, and writes a
PENDING record atomically. The reply says the update takes effect at the next
start and is kept only if the new version actually loads.

At the next start, before the module class loader is built, the old JAR is
moved aside and the new one put in place (APPLIED). After the modules load,
the update is committed only when the module is loaded from the new JAR at the
new version. No pre-restart prediction of loadability is made anywhere.

Record file names are confined to their folder and canonicalised; the working
folder is derived from a hash of the record's type and key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ding rule, replies

- ModuleUpdateRecoveryTest: a module absent after the start is rolled back with one line
  naming both versions; a rollback blocked by a held-open JAR finishes at the next start;
  an old JAR that cannot be moved aside (injected, and a read-only folder on POSIX) or a
  new JAR that cannot be moved in changes nothing and is reported SEVERE and kept FAILED;
  the next /upm update reports it; a file at the new name is never replaced; the five
  crash windows are reproduced by crashing the real code at named points; records naming
  ../ or a link leaving the folder are refused
- ModuleUpdateStagingTest: staging failures leave nothing behind; a second update while
  one is staged changes nothing; an uninstall cancels a staged update
- ModuleUpdateCommandTest: replies say staged and next start, never updated; update-all
  reports per module; a refused uninstall cancels nothing
- ModuleFileCatalogueTest: every line is in en.json and zh.json with the same arguments
- ModuleUpdateStartupOrderTest: a module load that throws counts as not loaded

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… call

Mockito reports UnfinishedStubbing when a mock is created inside another
stubbing call; the command tests now create the result first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r every crash window

- After the modules load, an update not observed loaded from the new JAR at the new
  version is rolled back: the new JAR (recognised by the SHA-256 recorded at staging,
  never by name) is removed and the old JAR restored, with one line naming both
  versions. A rollback that cannot remove the new JAR now is recorded ROLLING_BACK and
  finished at the next start, before modules load.
- A move that fails at apply leaves the modules folder as it was (the old JAR is moved
  back if it had been moved), is logged SEVERE with the file and the error, and is kept
  FAILED; the next /upm update of the module reports it before staging again. A file
  already at the new JAR's name is never replaced.
- An APPLIED record found at a start means the previous start ended before deciding;
  nothing was observed, so it is rolled back before loading. COMMITTING and ROLLING_BACK
  records are finished. Stale temporary records and staging-only orphan folders are
  cleaned; a folder holding a kept old JAR without a record is reported, never deleted.
- One staged update per module: a second /upm update names the staged version and
  changes nothing. /upm update all stages each module independently and reports each.
- An uninstall that goes ahead cancels the module's waiting update; a refused one
  cancels nothing.
- FEATURES.md / UAT-CHECKLIST.md: update and update-all rows rewritten; new rows for the
  already-staged refusal, the start-up apply, a failed apply, an unconfirmed apply, the
  commit and the rollback; the install-version row no longer describes the old update.
- COMPATIBILITY.md: /upm update takes effect at the next start and is committed only
  after the module is observed loaded; measured consumers: none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e next start

- ModuleRemovalDeferredTest: with a modules folder that is not writable, the uninstall
  raises RemovalDeferredException naming the JAR and why, and writes a REMOVE record;
  the next start deletes it before loading and says so; a deletion that still fails is
  reported SEVERE and kept; a JAR replaced since the uninstall is never deleted
- ModuleRemovalDeferredCommandTest: the reply says the JAR is held open or not writable
  and is deleted at the next start before modules load, never "delete it by hand"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he next start

On Windows the shared module class loader keeps every module JAR open while the
server runs, so /upm uninstall of a loaded module could never delete its JAR and told
the operator to delete it by hand, which the same lock prevents.

- The uninstall records the JARs it could not delete (name, size, SHA-256) as a
  REMOVE record beside the modules folder and raises RemovalDeferredException, a
  FileSystemException that still names every file (getFile plus one suppressed per
  further file). Only a record that cannot be written leaves the plain failure.
- The next start deletes each recorded JAR before the module class loader is built,
  only if it is still the recorded file; one that still cannot be deleted is reported
  SEVERE and kept; one replaced since is left alone with a WARNING.
- The reply says the JAR is held open or not writable and is deleted at the next start
  before modules load.
- FEATURES.md / UAT-CHECKLIST.md: uninstall row amended; new deferred-uninstall and
  start-up deletion rows. COMPATIBILITY.md entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… caller

ModulesFolderSingleSourceTest fails on each second computation of "data folder plus
plugins" under src/main/java (eight today, including PluginManager#init reading
user.dir), and when the class loader, the start-up scan, install, update, uninstall or
the transaction folder does not call ModuleFileTransactions#modulesFolder. A control
proves the pattern finds each spelling.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Premise corrected by measurement (origin/alpha dfe71e0): the module class loader's
URLs already came from the data folder (UltiTools#getModuleUrls), as did install,
update and uninstall; only PluginManager#init's scan read user.dir. The class loader
decides which classes can load at all and the data folder follows Bukkit's plugin
directory, so the data folder is the authority.

- ModuleFileTransactions#modulesFolder(dataFolder) is the one computation;
  PluginManager#init, UltiTools#getModuleUrls and #initPluginModules, install,
  install-version, update staging, uninstall, the uninstall replies and the
  transaction folder all call it. user.dir stays where it means the server root.
- COMPATIBILITY.md entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…versions

With the transactions folder not writable, the commit cleanup still deleted the kept
old JAR although COMMITTING was never recorded; the next start, reading APPLIED, would
then remove the new JAR as unconfirmed and leave no version at all.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n disk

If COMMITTING cannot be written, the commit stops before any cleanup; the record still
says APPLIED, so the next start restores the old version as unconfirmed instead of
finding neither version.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… it again

An uninstall naming the module by a declared name different from its runtime name
does not match the staged record, and the start moved the new JAR in anyway.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…older

At apply, an old JAR that is neither in the modules folder nor kept aside means the
module was removed after staging (an uninstall by any name, or by hand); the update is
abandoned with one WARNING line instead of installing the module again. The uninstall
still cancels by runtime name; this closes the other names without a second matcher.
FEATURES.md apply row amended.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed module back

Gate-1 review findings (both reviewers): cancellation matched only the runtime name;
an update ran even when its old JAR was recorded for deletion and that deletion
failed again; recorded deletions ran before updates only by file-name sorting; an
install of the same bytes was deleted by an older recorded deletion; the recorded
size was never checked.

- ModuleUpdateUninstallTest: cancel through the removed JAR's name; an update whose
  old JAR is pending deletion is abandoned; an install cancels the recorded deletion
  of its file name; a copy-back with another timestamp is kept; removals run first
- command tests: cancellation receives the removed or deferred JAR names; the deferred
  reply says a start that still cannot delete reports it in its log

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d module back

Gate-1 review, swept as one class (every point where an uninstall and a staged update
of the same module meet):
- Recorded deletions run before updates by rule, in their own pass, not by how record
  names sort; an update whose old JAR is gone or still pending deletion is abandoned.
- The uninstall cancels an update staged under the name given, or one that would
  replace a JAR it removed or recorded for removal.
- A recorded deletion applies only while the file has the recorded size, modification
  time and SHA-256, and an install through /upm of that file name forgets it.
- The deferred-deletion reply no longer promises the next start succeeds: it says a
  start that still cannot delete the JAR reports that in its log.
- FEATURES.md / UAT-CHECKLIST.md rows amended.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… leftover kept JAR

Gate-1 review: the static lock was held for the whole download, so an uninstall on the
main thread (cancel, record a deferred deletion) waited for it; and a working folder
left with a kept old JAR but no record was reused, the kept JAR taken as this
transaction's backup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…wnload

- stageUpdate takes the lock for the record checks, the leftover check and the module
  lookup, marks the module as downloading, and releases it; the catalogue lookups and
  the download run outside it; the record write takes it again. An uninstall on the
  main thread (cancel, record a deferred deletion) no longer waits for a download.
- A second /upm update of a module being downloaded is refused as busy.
- A working folder holding a kept old JAR without a record is never adopted as this
  transaction's backup: staging is refused and names the folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ds; no kept JAR, no deletion

Gate-1 review: confinement canonicalised the folder too, so a derived folder that is a
link passed; names were not required to be JARs; a record missing a field crashed the
observation; and a rollback deleted the file whose hash matched even with no kept old
JAR to put back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…; never roll back without a kept JAR

Gate-1 review:
- The transaction's own working folder and its backup/staged folders must not be
  symbolic links; confinement canonicalised the folder as well, so a linked folder
  passed. Such a record is refused.
- Every name a record carries must be a JAR name, so a record can never move a file
  of another kind into the modules folder under a .jar name.
- A record missing a field its type needs is refused when read, and one transaction's
  unexpected failure during the observation is reported instead of ending the start
  (and so never hides a load failure).
- A rollback removes the new JAR only while the kept old JAR is there to put back;
  without it the new JAR stays and a WARNING names the missing file. A rollback an
  earlier start already completed is cleaned up without a second line.
- The class javadoc no longer claims the modules folder never changes while the
  server runs: a rollback right after loading does change it.
- FEATURES.md apply and observe rows amended.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng normaliser

Gate-1 review (info): ModuleFileTransactions re-implemented streaming SHA-256, string
SHA-256 and hex encoding (ResourceHashSidecar has them) and copied
PluginInstallUtils#normalizeIdentifyString line for line; two normalisers drifting
would stop a staged update from ever matching its loaded module. No behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Gate-1 review: the unconfirmed-apply row asked for a kill -9 inside a window that is
usually under a second; it now uses a fixture whose registerSelf() waits 60 seconds.
The rows named an internal fixture plan; they now say how to build each fixture. The
staged file name is the lower-cased identify-string, which the rows now say.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…/ on another file system fails truthfully

- The records folder is <server root>/.ultikits/upm-transactions, beside the credential store and
  outside plugins/, resolved from the data folder's grandparent (a relative data folder resolves
  the same server root).
- A modules folder on another file system: the atomic move is refused, nothing in the modules
  folder changes, the record is FAILED, one SEVERE line names both folders with its own key, and no
  copy is made; the next /upm update reports it.
- Every transaction test now uses a server layout (<root>/plugins/UltiTools), so no test writes
  records outside its own temporary folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lugins/ on another file system truthfully

Maintainer decision (2026-09-30, follow-up 18; closes gate-1 finding A-01): the pending-update and
pending-removal records move out of plugins/UltiTools/ -- which the panel's file interface can write
when its file writing is enabled -- to <server root>/.ultikits/upm-transactions, beside the
credential store. The panel file interface is unchanged.

- ModuleFileTransactions#transactionsFolder resolves the server root the way CredentialStore does:
  the data folder's grandparent, from the absolute data folder, never user.dir.
- The start-up swap stays an atomic rename. When plugins/ is on another file system the rename is
  refused (AtomicMoveNotSupportedException, no longer wrapped): nothing in the modules folder
  changes, the record is FAILED, and one SEVERE line with its own catalogue key names both folders.
  No copy fallback: a copy is not atomic and the crash windows rely on renames.
- FEATURES/UAT rows and COMPATIBILITY name .ultikits/upm-transactions/; a new UAT row exercises the
  cross-file-system failure with a tmpfs-backed modules folder.
- The unrecordable-decision test reads the file store of the server root (the records folder does
  not exist before the first record).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-30T15:28:02.585275Z 00d8076 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@codacy-production

codacy-production Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 486 complexity

Metric Results
Complexity 486

View in Codacy

🟢 Coverage 83.95% diff coverage · +0.37% coverage variation

Metric Results
Coverage variation ✅ +0.37% coverage variation (-1.00%)
Diff coverage ✅ 83.95% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (dfe71e0) 15352 12560 81.81%
Head commit (2ed6dd7) 16144 (+792) 13267 (+707) 82.18% (+0.37%)

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 (#561) 835 701 83.95%

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a2386abe6

ℹ️ 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".

Comment thread src/main/java/com/ultikits/ultitools/utils/ModuleFileTransactions.java Outdated
Comment thread src/main/java/com/ultikits/ultitools/utils/ModuleFileTransactions.java Outdated
wisdommen and others added 3 commits September 30, 2026 15:47
…iour

- NPath: stageUpdate (2880) splits into reserve() and locate() behind a
  small Reservation value; download (324) moves the fetch and the record
  write into fetch() and writeStaged(); applyUpdate (216) moves the
  missing-staged-JAR and abandon branches into their own methods;
  applyRemoval (336) moves the "still the recorded file" test into
  isStillRecorded(). Same checks, same order, same replies.
- Path construction: the four sites Codacy names are the helpers every
  record path goes through (a hashed id, a constant, or a name confined()
  has refused unless it is a plain JAR name directly in its folder);
  suppressed with the rationale ahead of the marker. Test sites likewise
  (a constant language, a listing of the test's own folder).
- Tests: owner-only POSIX permissions for the read-only folders; fields
  before the nested crash class; UltiTools imports UltiToolsPlugin and
  Supplier instead of qualifying them.
- The staging "nothing changed" snapshot now covers the server root, so it
  still includes the records folder after the move under .ultikits.

Local PMD (NPathComplexity, FieldDeclarationsShouldBeAtStartOfClass,
UnnecessaryFullyQualifiedName) and Opengrep (path traversal, file
permission rules) report nothing in this pull request's changed code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d JAR replaced after staging

Codex review on #561, round 1:
- P1: a JAR two loaded modules came from is refused at staging, before
  any download, naming the other module; nothing changes.
- P2: an old JAR replaced under the same name after staging (an install
  of the current version, a hand-made hotfix) is neither moved aside nor
  deleted by the commit: the update is abandoned with one WARNING and the
  replacement stays. The same bytes put back still count as the old JAR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…staging

The P2 fix records the old JAR's SHA-256 at staging; when it cannot be
read, staging refuses (named reason) rather than writing a record that
could not verify the file at the next start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…und 14)

ultitools.boot.module-update-apply: the new JAR is returned by its recorded
hash before the kept old JAR goes back, and an undo that cannot finish is
finished in the same order by the next start or the next /upm update.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@wisdommen

Copy link
Copy Markdown
Member Author

Codex round 14 — the recovery invariant, swept by defect class (17-44 follow-up 19)

Invariant (class javadoc of ModuleUpdateRecoveryInvariantTest): after any start, for every transaction record, the modules folder
holds exactly one JAR for that module (the old or the new, never both, never neither unless the record is a removal), and a JAR not in the
modules folder is either staged or in the kept-old location, identified by its recorded hash — and only while its record exists.

Moves and writes that can be interrupted. Apply: old → kept (backup/), then staged → modules, then APPLIED written. Observation:
COMMITTING or ROLLING_BACK written, then commit (delete work folder, then record) or rollback (delete the new JAR from the modules
folder by hash, move kept → modules, delete work folder, then record). Failed apply (failApply): new → staged by hash, then kept →
modules, then FAILED written. Any move or delete may fail, having happened or not; any record write may fail (the start goes on with the
state on disk unchanged); the start may end at any point. Each row is run by the test with the old JAR sharing the new JAR's file name and
not, and with the new version loading and not, on a start whose file operations succeed.

Placements: old in Modules / Kept aside / Gone; new Staged / in Modules / Gone.

Reachable combinations

State Old New How a crash or failure leaves it What the next start does Before the fix After
PENDING M S staged; crash before any move applies; observation commits or rolls back holds holds
PENDING K S crash after the old JAR moved skips the done move, moves the new JAR in, APPLIED, observes holds holds
PENDING K M crash after the new JAR moved, or APPLIED not written new JAR recognised by hash in the modules folder: APPLIED, observes holds holds
PENDING M G after-load rollback whose record writes failed, ended after the old JAR went back (or the staged JAR removed by hand) staged JAR missing → failApply: nothing to return, old in place, FAILED holds holds
PENDING K G the same rollback, ended before the old JAR went back failApply: old JAR back, FAILED holds holds
PENDING G S an uninstall took the old JAR before the apply abandoned: record and staged JAR deleted, module stays removed (the removal exception) holds holds
APPLIED K M swapped, start ended before deciding unconfirmed: ROLLING_BACK, new JAR removed by hash, old back holds holds
APPLIED K G rollback whose ROLLING_BACK write failed, ended after removing the new JAR rollback: old back, record deleted holds holds
APPLIED M G the same, ended after the old JAR went back (record deletion failed) rollback: nothing kept, new JAR absent → record deleted holds holds
COMMITTING K M decided, start ended before cleanup commit: work folder and record deleted holds holds
COMMITTING G M cleanup ended after deleting the kept JAR commit finishes holds holds
ROLLING_BACK K M decided, start ended, or removing the new JAR failed rollback holds holds
ROLLING_BACK K G new JAR removed; moving the old back failed or the start ended rollback: old back holds holds
ROLLING_BACK M G old back; deleting the record failed or the start ended rollback: record deleted holds holds
FAILED M S apply failed and was fully undone nothing left to undo holds holds
FAILED K S undo: moving the old JAR back failed undo: old back holds holds
FAILED K M undo: returning the new JAR failed, so the old was not moved back (round 14; also a move that threw after moving) before: only the old JAR moved back. After: undoApply — new JAR returned by hash, then the old back violated: both JARs in the modules folder; same name: the new JAR stays, the old never comes back, RESTORE_FAILED every start holds
FAILED M G staged JAR missing, old JAR back nothing left to undo holds holds
FAILED K G staged JAR missing, moving the old back failed undo: old back holds holds

/upm update discarding a FAILED record (the other place a failed apply is undone)

Old New Before the fix After
M S / G holds holds
K S / G holds (old back) holds
K M violated: the old JAR moved back beside the new one, then the record and work folder deleted — both JARs, no record left; same name: refused as occupied, stuck holds (undoApply)

Violating rows before the fix: 2 (FAILED / kept / modules at a start, and at /upm update's discard), each in both file-name
variants: 3 of 86 test cases failed the one-JAR check, 6 of 86 with the assertion that a FAILED record ends with the old version in place.
Fixed by one helper, undoApply (the undo failApply already ran: returnStagedJar by hash, then restoreBackup), now used by
failApply, restoreAfterFailure and discardFailed. No second recovery path.

Combinations no crash or failure can leave (why)

State Placements Why unreachable If made by hand
PENDING M/M the new JAR moves in only after the old JAR moved aside —
PENDING G/M, G/G the new JAR moves in only after the old is kept; the staged JAR is removed only by hand G/G: the operator removed the module and the staged JAR; FAILED with no JAR (the removal exception)
APPLIED any with new staged; M/M; G/any APPLIED is written after both moves; after it only a rollback moves files, removing the new JAR first; nothing deletes the kept JAR before a commit —
COMMITTING M/any; K/S, K/G, G/S, G/G after the decision the old JAR never returns and the new JAR never leaves the modules folder K/G, G/G: the committed new JAR deleted by hand; the commit deletes the kept JAR and the module is gone
ROLLING_BACK M/M; any with new staged; G/any a rollback removes the new JAR before the old returns, never stages it, never deletes the kept JAR —
FAILED M/M; G/any the undo returns the new JAR before the old; an apply never deletes the old JAR M/M: the operator placed a copy of the new version in the modules folder (a duplicate copy, see B-I-03)
(no record) a work folder left the record is written last at staging and deleted last everywhere else an orphan work folder is deleted at start, or reported if it holds a kept JAR

中文摘要:不变量——任何一次启动之后,对每条事务记录,模块目录中该模块恰好有一个 JAR(旧或新,不会两个都在,除移除外也不会一个都没有);不在模块目录中的 JAR 只在暂存位置或旧版本保留位置,按记录的哈希识别。修复前违反的行:2 行(FAILED / 旧版本保留 / 新版本在模块目录,分别在启动时和 /upm update 丢弃记录时),由 undoApply 一处修复(fda50c27)。

@wisdommen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 88745228f8

ℹ️ 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".

…-assertion suppressions

Codacy reported 6 new issues on 8874522; every site of both rules in the two
files is handled in one pass (Codacy names one instance per rule per file):

- Opengrep SpotbugsPathTraversalAbsolute: returnStagedJar's staged path (the
  same confined() construction backupOf already marks) and the test's paths
  built from its own staging's record; a nosemgrep marker directly above each,
  with the reason first.
- PMD JUnitTestsShouldIncludeAssert on the two parameterised tests, whose
  assertions live in Scenario#assertInvariant / #assertOldVersionInPlace.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@wisdommen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Unknown error
ℹ️ 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".

@wisdommen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb05fd3b85

ℹ️ 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".

Comment thread src/main/java/com/ultikits/ultitools/utils/PluginInstallUtils.java Outdated
Comment thread src/main/java/com/ultikits/ultitools/utils/PluginInstallUtils.java
wisdommen and others added 2 commits October 1, 2026 00:39
… names in cancellation (Codex round 16)

Codex round 16 on cb05fd3, two P2s in code predating this follow-up, outside
the recovery state machine:

- /upm update snapshotted the loaded modules before staging took its lock; an
  uninstall completing in that gap found no download marker and no record, and
  the stale snapshot staged the removed module's update. The test lets an
  uninstall's cancellation arrive while staging reads the loaded modules: the
  download must end cancelled and no record be written.
- The uninstall's cancellation matched code-source JAR names outside the
  modules folder; a module loaded from an external JAR cancelled the update of
  an unrelated module JAR with the same file name.

RED: compilation error (stageUpdate has no loaded-modules supplier form); the
second case is shown behaviourally after the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ules-folder JAR names only (Codex round 16)

Codex round 16 on cb05fd3, two P2s in code predating this follow-up and
outside the recovery state machine, fixed as ordinary defects:

1. PluginInstallUtils.stageUpdate snapshotted the plugin list before
   ModuleFileTransactions took its lock. An uninstall completing in between
   (unload, then its cancellation under the lock) found neither a download
   marker nor a record; the stale snapshot then staged the removed module's
   update, and where the uninstall could not delete or record the old JAR the
   next start applied it. stageUpdate now has a form taking a supplier, called
   once under the lock; the command passes one reading the live list. The
   uninstall removes the module from the list before its cancellation takes the
   lock, so either staging sees the module gone (refused as not loaded) or the
   download is marked running before the cancellation looks (cancelled). The
   List forms delegate to it. Consumers: /upm update and /upm update all, both
   through PluginInstallUtils.stageUpdate.

2. The uninstall's resolved identity carried the file names of every target
   code-source JAR, including ones outside the modules folder, and a
   cancellation matches an update's old JAR by file name. Only names of JARs
   directly in the modules folder are kept; an update's old JAR is always
   there. Other JAR-name sources were already confined to the modules folder
   (the loader's index, the entries the uninstall identified).

GREEN: UltiTools-Reborn-505-codex-r16-GREEN.log (329 related tests, 0
failures). Behavioural RED with the list read before the lock and the external
names kept: 2 of 2 fail (-codex-r16-RED-behavioural.log).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@wisdommen

Copy link
Copy Markdown
Member Author

@codex review

@codacy-production

codacy-production Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 811 complexity

Metric Results
Complexity 811

View in Codacy

🟢 Coverage 86.61% diff coverage · +0.61% coverage variation

Metric Results
Coverage variation ✅ +0.61% coverage variation (-1.00%)
Diff coverage ✅ 86.61% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (dfe71e0) 15352 12560 81.81%
Head commit (aa8ca8b) 16412 (+1060) 13527 (+967) 82.42% (+0.61%)

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 (#561) 1120 970 86.61%

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f80dd63924

ℹ️ 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".

Comment thread src/main/java/com/ultikits/ultitools/utils/ModuleFileTransactions.java Outdated
wisdommen and others added 2 commits October 1, 2026 00:52
…p the update (Codex round 17)

Codex round 17 on f80dd63 (P2, in the B-I-03 copy check this follow-up
added): the start-up builds the module class path only from JARs passing
SecurityPolicy.isValidModuleJar (UltiTools#collectModuleJarUrls) and scans only
those (PluginManager#loadPluginMainClass), so a copy failing it -- here one over
the entry limit, readable and declaring the module's main class, sorting first
-- can never load first; refusing the update because of it is a false refusal.

RED: 1 of 18 fails (UltiTools-Reborn-505-codex-r17-RED.log).

Also Codacy on f80dd63: the round-16 test's fully qualified catchThrowable is
the static import the file already has.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ut it on the class path (Codex round 17)

Codex round 17 on f80dd63, P2, in the B-I-03 copy check: a readable JAR
declaring the module's main class and sorting first, but failing
SecurityPolicy.isValidModuleJar (over the size or entry limit), refused the
update, although the start-up never puts such a JAR on the module class path
(UltiTools#collectModuleJarUrls) nor scans it (PluginManager#loadPluginMainClass),
so it cannot load first. The check now applies the same validation, in a helper
(loadsAhead) so copiesLoadingFirst's complexity does not grow. FEATURES
ultitools.upm.update says so.

This corrects the reasoning recorded while B-I-03 was designed, that every
.jar file in the folder is on the class path; the class path is built from the
validated JARs only.

GREEN: UltiTools-Reborn-505-codex-r17-GREEN.log (330 related tests, 0
failures); RED before the fix: 1 of 18 (-codex-r17-RED.log).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@wisdommen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 88472f91ac

ℹ️ 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".

Comment thread src/main/java/com/ultikits/ultitools/utils/ModuleFileTransactions.java Outdated
wisdommen and others added 2 commits October 1, 2026 01:11
…nd replaced by uninstall (Codex round 18)

Codex round 18 on 88472f9 (P2, pre-existing #518 code): a removal record
that parses but whose removals list holds a null entry passes readRecord, and
forgetDeferredRemoval dereferenced each entry, so /upm install failed after its
JAR was written and later records were not cleared. Swept over every consumer
of removal entries outside start-up (which already refuses such a record
through problemWith): recordDeferredRemoval merges an existing record's entries
and dereferences them the same way.

RED: both fail with a NullPointerException
(UltiTools-Reborn-505-codex-r18-RED.log).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…g its entries (Codex round 18)

Codex round 18 on 88472f9, P2, in pre-existing #518 code: a removal record
that parses but fails start-up's validation -- a null entry, an entry without
a name or hash, a wrong file name -- was used directly by the two operations
that read removal records while the server runs, and a null entry threw a
NullPointerException out of them.

Both now apply problemWith, the validation start-up already applies, in one
helper each (so the callers' complexity does not grow):

- forgetDeferredRemoval (install): removalRecordIn skips a record that cannot
  be read or carried out with a warning naming it; the others are still
  cleared.
- recordDeferredRemoval (uninstall): mergeableRemovals merges nothing from an
  earlier record of the module that cannot be read or carried out; the new
  record replaces it, with a warning naming it.

Start-up (applyBeforeLoad) already refused such a record; there is no other
consumer of removal entries.

GREEN: UltiTools-Reborn-505-codex-r18-GREEN.log (332 related tests, 0
failures); RED before the fix: 2 of 2 NullPointerException
(-codex-r18-RED.log).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@wisdommen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 00d80764f9

ℹ️ 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".

wisdommen and others added 4 commits October 1, 2026 01:36
… the foreign-file dimension (Codex round 19)

Codex round 19 on 00d8076: two P2s in the commit and rollback of the update
recovery, both a file at the new JAR's name that is no longer the staged JAR.
Coordinator's decision: close the whole category with one rule rather than
case by case -- the transaction only ever moves, replaces or deletes a file
whose SHA-256 matches the record -- and add the table dimension.

The invariant javadoc states the rule. A new parameterised family puts a
foreign JAR at each of the four locations a record uses (the old JAR's name,
the kept-old location, the staged location, the new JAR's name) for each of
the 19 reachable rows, new version loading or not (152 cases), and asserts
that the start never moves or deletes the foreign file, never deletes the last
identified copy of the module, never moves an identified JAR into the modules
folder beside it, and leaves nothing a later start still acts on. The 86
existing cases are unchanged.

RED: 75 of 152 cases, 38 of the 76 new (row, location) rows, including both
round-19 cases (COMMITTING and ROLLING_BACK, kept / modules, foreign file at
the new JAR's name); 0 of the 86 existing cases.
Log: UltiTools-Reborn-505-codex-r19-foreign-RED.log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ecord; otherwise NEEDS_OPERATOR (Codex round 19)

Codex round 19 on 00d8076, two P2s in commit and rollback: a file at the new
JAR's name that is no longer the staged JAR was committed to (the kept old JAR
deleted) or had the old JAR restored beside it. Coordinator's decision: close
the category -- another actor changing files while a transaction is pending --
with one rule instead of case by case, on the maintainer's precedent for
ambiguous identity (write nothing, warn).

The rule: the transaction only ever moves, replaces or deletes a file whose
SHA-256 matches the record (the staged new JAR or the kept old JAR), and only
into a place that is free. One guard, requireRecorded, runs before every
destructive step with the files that step touches, source and target:

- the apply's swap (old name, kept-old location, staged JAR, new name);
- undoing a failed apply, shared by failApply, later starts and /upm update's
  discard (undoApply);
- the commit (kept old JAR, staged location, and the new JAR it commits to,
  which must still be the staged one);
- the rollback (new name, kept old JAR, old name, staged location);
- the uninstall's cancellation of a staged update (kept old, staged).

When a file is not the recorded one, nothing is done to any file: the record
is kept in a new state, NEEDS_OPERATOR, which later starts do not act on (they
repeat the SEVERE line); one SEVERE line (new en/zh key NEEDS_OPERATOR) names
each unexpected file with its expected and actual hash and says to resolve it
by checking the files and deleting the record and its folder; /upm update of
the module refuses with REASON_NEEDS_OPERATOR, and /upm uninstall refuses
before unloading anything (ModuleFileTransactions#heldForOperator).

Behaviour changes, tests updated: a different file under the old JAR's name,
a changed staged JAR, or a file already at the new JAR's name used to abandon
the update or fail the apply; each now holds the record for the operator. The
UPDATE_ABANDONED_REPLACED key and its branch are gone (the guard replaces
them); an old JAR that is gone (an uninstall) still abandons the update, as no
file is foreign. A changed staged JAR after a crash that left the old JAR kept
now leaves it kept (named in the SEVERE line) instead of moving it back: the
rule does nothing to any file.

Tests: the 86 existing invariant cases are unchanged and green; the 152
foreign-file cases (38 of 76 rows RED before) are green and assert the SEVERE
line when held; /upm update and /upm uninstall refusal tests added. Guard off:
the same 75 of 238 fail (-codex-r19-foreign-RED-behavioural.log). Related
suites: 486 tests, 0 failures (-codex-r19-foreign-GREEN.log).

Also Codacy on 00d8076: the two fully qualified names in
ModuleUpdateUninstallTest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… NEEDS_OPERATOR (Codex round 19)

ultitools.boot.module-update-apply: the transaction only moves, replaces or
deletes a file whose SHA-256 matches the record; otherwise nothing is done,
the record is kept as NEEDS_OPERATOR with one SEVERE line naming each file and
how to resolve it (check the files, delete the record and its folder), later
starts repeat the line only, and /upm update and /upm uninstall refuse. The
two sentences it replaces (a replaced old JAR abandons the update; a changed
staged JAR fails the apply) described the previous behaviour.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ultitools.boot.module-update-apply.neg-needs-operator: a copy of the old JAR
under the new JAR's name before the first start after staging; the start
moves nothing and logs one SEVERE line naming the file with its expected and
found hash; the record is kept as NEEDS_OPERATOR; /upm update and /upm
uninstall of the module refuse; the next start repeats the line only. Every
quoted literal matches the en.json value and the refusal texts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@wisdommen

Copy link
Copy Markdown
Member Author

Codex round 19 — the foreign-file dimension of the recovery table (17-44 follow-up 19)

Rule (coordinator decision; class javadoc of ModuleUpdateRecoveryInvariantTest, FEATURES ultitools.boot.module-update-apply,
COMPATIBILITY, docs PR #106): the transaction only ever moves, replaces or deletes a file whose SHA-256 matches the record (the staged
new JAR or the kept old JAR), and only into a free place. One guard, requireRecorded, runs before every destructive step with the files
that step touches, source and target: the apply's swap, the undo of a failed apply (failApply, later starts, /upm update's discard),
the commit (the new JAR it commits to must still be the staged one), the rollback, and the uninstall's cancellation. On a mismatch nothing
is done to any file; the record is kept as NEEDS_OPERATOR (later starts repeat the SEVERE line only; /upm update and /upm uninstall
refuse); one SEVERE line names each unexpected file with its expected and found hash and says to check the files and delete the record and
its folder. Implemented in d76b3d79 (test fd64f6ec).

Dimension. A foreign JAR (another hash) at each of the four locations a record uses — Old name, Kept-old location, Staged
location, New name — for each of the 19 reachable rows above, new version loading or not (152 cases). Asserted: the foreign file is
never moved or deleted; the last identified copy of the module is never deleted; no identified JAR is moved into the modules folder beside
the foreign file; a later start does not act on what is left; a record held for the operator is reported SEVERE.

RED before the guard: 38 of the 76 (row, location) rows (75 of 152 cases); 0 of the 86 existing cases. All 76 rows green after it;
guard switched off, the same 75 cases fail.

State Old New Foreign at O at K at S at N
PENDING M S RED → held held held held
PENDING K S RED → held RED → held held RED → held
PENDING K M completed/held RED → held RED → held RED → held
PENDING M G held held held held
PENDING K G held RED → held held RED → held
PENDING G S RED → held RED → held held RED → held
APPLIED K M held RED → held RED → held RED → held
APPLIED K G held RED → held RED → held RED → held
APPLIED M G held held RED → held held
COMMITTING K M completed RED → held RED → held RED → held (round 19)
COMMITTING G M completed RED → held RED → held held
ROLLING_BACK K M held RED → held RED → held RED → held (round 19)
ROLLING_BACK K G held RED → held RED → held RED → held
ROLLING_BACK M G held held RED → held held
FAILED M S held held held held
FAILED K S held RED → held held RED → held
FAILED K M held RED → held RED → held RED → held
FAILED M G held held held held
FAILED K G held RED → held held RED → held

Measured per case after the guard (both "new loads" variants; one cell differs between them). "held" = the start left every file as it was and kept the record as NEEDS_OPERATOR; "completed" = the transaction finished without touching the foreign file (a commit never touches the old name); "RED" = before the guard the start moved or deleted the foreign file, deleted the last identified copy, or moved an identified JAR in beside it. Of the 76 rows: 73 held, 2 completed, and 1 (PENDING / K / M, foreign at the old name) completed when the new version loads and held when it does not (the rollback touches the old name).

Threat model (maintainer decision, 2026-10-01). Another actor changing files mid-transaction is outside PR #561's threat model; the
guard stays because it is done and tested, and further findings in this area go to the PR's follow-up issue.

中文摘要:交易只移动、替换或删除 SHA-256 与记录一致的文件;每个破坏性步骤前由同一个守卫 requireRecorded 检查所涉及的文件,不一致时不动任何文件,记录置为 NEEDS_OPERATOR 并输出一条 SEVERE 日志。新增的 76 行(每个可达状态 × 四个位置上的外来文件)中 38 行在修复前为 RED,修复后全部通过。

@wisdommen

Copy link
Copy Markdown
Member Author

Local Codex review — run 1 (maintainer's local-review rule, 2026-10-01)

  • Command: codex review --base alpha -c model='"gpt-6.1-sol"' -c model_reasoning_effort='"high"', from a worktree at the pushed head
  • Model / effort: gpt-6.1-sol / high; route confirmed in opencodex usage.jsonl: provider openai, routeKind: native, 21 requests
  • Head: ca5f6407 (base alpha at dfe71e01)
  • Duration: 6 min 17 s (15:59:45Z – 16:06:02Z)
  • Result: no finding ("No defect introduced by this change that can be confirmed and needs fixing"); the reviewer also ran the 370 module-update, recovery and uninstall tests, all passing
  • Dispositions: none needed. A first attempt on the same head produced no review — Codex's sandbox did not start and nothing was read — and is not counted as a run.
  • Threat model and follow-up: see the PR body; out-of-model items are tracked in Module-file transactions: out-of-threat-model review findings (PR #561) #565. With no fix commit, no confirmation run is due.

本地 Codex 复审第 1 次(ca5f6407,gpt-6.1-sol,high):无发现。

…) in the round-19 staging test

Two calls in ModuleUpdateStagingTest qualified a fixture method the file
already imports statically (UnnecessaryFullyQualifiedName). Every test file
this branch touched was swept for the same pattern; these were the only two.
No behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant