Skip to content

Modernize the Windows MSI: WiX v5, x64, upgrade from 5.1.x - #701

Open
vharseko wants to merge 65 commits into
OpenIdentityPlatform:masterfrom
vharseko:features/windows-distribution-new
Open

Modernize the Windows MSI: WiX v5, x64, upgrade from 5.1.x#701
vharseko wants to merge 65 commits into
OpenIdentityPlatform:masterfrom
vharseko:features/windows-distribution-new

Conversation

@vharseko

@vharseko vharseko commented Jul 4, 2026

Copy link
Copy Markdown
Member

Modernizes the Windows MSI end to end. Builds on #664 (branched from it) and includes the Windows script quoting fixes from #671 (they merge cleanly once #671 lands).

Toolchain: WiX 3.11 + wine → WiX v5, Windows-native

  • WiX 3.11.1 (2017, EOL) → WiX v5.0.2 (.NET wix tool): heat/candle/light + wine + winetricks + dotnet40 collapse into a single wix build -arch x64. WiX v5 is the newest version without the Open Source Maintenance Fee (v7 refuses to run without accepting the OSMF EULA).
  • The MSI is built on Windows only. Verified empirically (wix 5.0.2/6.0.1/7.0.0 on macOS): non-Windows builds fail on WIX0389 for any Directory/@Name and, with no names at all, on the missing msi.dll — the MSI database is written by the Windows Installer library, so no cross-platform build exists. wine is removed from build.yml, deploy.yml and release.yml.
  • package.wxs rewritten to the v4+ schema: single <Package>, MediaTemplate CompressionLevel="high", InstallerVersion=500, <Files> harvest from Ant-staged payloads, empty instance directories shipped via CreateFolder components (the harvest ships files only).
  • The opendj-msi modules stay in every reactor, and the distribution-windows-msi profile that requires Windows plus an installed wix tool sits inside opendj-msi-standard, around its <build>: inactive, the module is a pom that builds nothing, so a plain mvn install on a contributor's Windows machine no longer fails mid-reactor with "Cannot run program wix". Gating the module list instead - as an earlier revision of this PR did - would have frozen both MSI poms at their parent version forever, because maven-release-plugin only rewrites ${reactorProjects} and release.yml prepares the release on ubuntu-latest with no -P; Maven does not fail on that skew, it resolves the parent from the repository, so the module would have kept building as the old version - unpacking the previous line's published snapshot instead of the server zip built beside it. excludeArtifacts on the root central-publishing-maven-plugin keeps the modules out of Maven Central, which reactor membership would otherwise put them back into — maven.deploy.skip cannot do it, see the Release/deploy section below.

Installer behavior

  • x64: installs into C:\Program Files\OpenDJ (was x86 / Program Files (x86)).
  • No Windows service is registered. The package lays the files down and nothing more, exactly as the WiX3-era one did: registering the server as a service stays an explicit windows-service.bat --enableService (or setup) step, and the service that results belongs to the administrator — the package never creates, removes or reconfigures one. An earlier revision of this PR registered an OpenDJ service from the installer; it was reverted in the sixth review round because it silently switched every MSI install into service mode, so start-ds.bat dispatched to the SCM and start-ds.bat -N failed outright even for a server that was never meant to run as a service.
  • A running service is stopped before the files are replaced, or the upgrade is refused. RemoveExistingProducts empties the old tree during the sequence pass, and the JVM opens lib\*.jar without FILE_SHARE_DELETE (JDK-8224794, Won't Fix), so a running instance turns the nested uninstall into delete-on-reboot entries. An immediate best-effort net stop runs first, then an immediate check that lets the upgrade continue only when the service is gone or Stopped and otherwise refuses it (error 1722 naming CheckServiceStopped). The check polls rather than sampling the SCM once: net.exe returns the instant a control cannot be accepted, which is the case in every pending state, so a single "is it Running" test passed exactly the situations the stop had not handled — an administrator's own net stop still in flight, or a service still starting, which the wrapper gives 300 s while its JVM holds lib\*.jar. A stop already under way is waited out for 90 s; every other state refuses immediately. Both actions are gated on the service's own ImagePath pointing into the directory being installed to, so an unrelated instance's service is never touched. The stop succeeds from an elevated console, SCCM, SYSTEM or CI and cannot under the filtered token of a UAC double-click — which is exactly why the check exists rather than a silent half-upgrade.
  • A server running without a service is refused too. The package registers none, so start-ds.bat is the default way an MSI-installed server runs, and that JVM holds lib\*.jar exactly as a service-hosted one does. A third check, CheckServerNotRunning, probes the exclusive byte-range lock the server holds on locks\server.lock — the same evidence wait-server-stopped.ps1 uses in CI, because a zero exit code from stop-ds is not proof the JVM let go (Flaky Test on Windows: winlauncher.exe stop can return success without stopping the server #768) — and refuses the upgrade while it is held, allowing 60 s for a stop already under way. It is gated on nothing but WIX_UPGRADE_DETECTED, so it covers both modes. Until the tenth review round it could not refuse at all: the inline catch compared $_.Exception.GetType().FullName with System.IO.IOException, and PowerShell wraps anything thrown out of a .NET method or constructor in a MethodInvocationException, so every exception — the held lock included — took the fail-open branch and the exit 1 was unreachable. It is a typed catch [System.IO.IOException] now, with the [\[]/[\]] escapes a Formatted field needs so the type literal is not substituted away, and CI runs the command directly against a held lock instead of inferring it from an install. The probe opens the lock file for read, not read-write: LockFile needs only GENERIC_READ, while a read-write open of a file under Program Files is denied to a standard user outright — and an immediate custom action impersonates the invoking user, so a read-write probe took its fail-open branch on every UAC double-click upgrade of a default-directory install, running server or not, and the refusal only ever happened for the elevated console / SCCM / SYSTEM / CI modes. What still fails open is a tree whose ACL denies reading the lock at all: UnauthorizedAccessException is a sibling of IOException, not a subclass, so it reaches the bare catch — the guard refuses on a lock it can read and held, and never claims more than that. Restart Manager is set to Disable: RM must not stop the service — it would restart the upgraded server before upgrade.bat has migrated the instance data, and would take a StartPending service away from the check above — and DisableShutdown is not a middle ground, which is measured rather than reasoned. It keeps RM's detection, which is exactly why it was tried, but the restart is not part of what it disables: the headline upgrade scenario came out of that run with the service StartPending, because the installer restarts whatever the RM session listed whether RM stopped it or the guards did. Turning RM off takes its detection with it, leaving the legacy in-use scan, which finds windowed applications and therefore never a headless JVM — so the lock check is what covers both modes instead, including /quiet, where a FilesInUse dialog could not be shown anyway.
  • Upgrade over an existing install: the installer detects the location recorded in the registry by this package, or the legacy x86 default directory (two separate searches, registry wins), and installs there — instance data (config, db, logs) is preserved; an explicit OPENDJ=... always wins. During an upgrade the legacy directory has to prove it holds a server (a setup.bat in it) before it is adopted: Installed is unset throughout a major upgrade, so a bare existence test would have routed the new tree into a leftover empty directory while RemoveExistingProducts emptied the real installation somewhere else. A registered service survives untouched: it names the wrapper inside the installation directory, which the upgrade replaces in place, so the registration keeps working against the refreshed server. Verified in CI by upgrading from the released 5.1.2 MSI at its real x86 default with no OPENDJ given, with the service left running. AllowSameVersionUpgrades keeps a same-version hotfix re-release from producing two ARP entries.
  • Two upgrades are refused rather than performed, both from the execute sequence after CostFinalize — the one point where OPENDJ is final whether the installer was started silently or with full UI:
    • The location cannot be determined. 5.1.x wrote no registry value, so an upgrade from a custom directory resolves no search and falls back to the default; installing there while RemoveExistingProducts empties the old tree would strand config/db/logs. The refusal covers the registry-less case as a whole — nothing recorded a location and no server sits in the legacy default — and lets an upgrade through only on directory evidence: the target holds a setup.bat and either it is not the fallback default or it was named. So msiexec /i ... /quiet naming nothing still refuses; so does a directory that was named but holds no server, which is what a typo looks like (OPENDJ=C:\opendj-custmo over a server in C:\opendj-custom used to pass both guards and strand the data); while a GUI administrator browses to the existing directory in InstallDirDlg and proceeds on the evidence that directory carries. (An earlier revision aborted in the UI sequence before WelcomeDlg, leaving a GUI-only administrator with nothing but a command line to retype.) The one case the wizard cannot resolve is an old server in the default directory itself: the "was it named" flag is read before AppSearch and InstallDirDlg never sets it, so browsing to the default leaves the guard with the values a "Next, Next, Install" session produces. The refusal message and the install guide say so and point at the command line, rather than promising a dialog that cannot help. That flag is OPENDJ_GIVEN, public and secure and holding the named path rather than a boolean: it was a private property until the tenth review round, and a private property set in the UI sequence never reaches the execute sequence — which is processed in the installer service, not in the client — so every full-UI session read it as empty at the guards, including one started with OPENDJ= on the command line, and the wizard therefore refused the very workaround the message prescribes. OPENDJ can now be passed with or without /quiet. The guards require the resolved directory to start with the value, which keeps the property from being a switch: OPENDJ_GIVEN=1 disarms nothing, and a value that does pass has spelled out the directory the install resolved to. Evidence alone is deliberately not enough in the default directory: any unrelated OpenDJ tree — a zip install, a copy — satisfies a setup.bat search without being the product being upgraded, and keying the guard on that alone let such a decoy disarm it while RemoveExistingProducts gutted the real installation elsewhere, which is what the "or it was named" half is for. Naming the directory is the way through, including for an old server that really does live in the default one, which a registry-less 5.1.x is indistinguishable from the decoy without being told. The same decoy in the legacy default directory remains undetectable when nothing is named — nothing a registry-less 5.1.x left behind separates the two, and refusing every registry-less legacy-default upgrade would refuse the documented main upgrade path with it — so the install guide carries the workaround instead: name the real directory, which the relocation guard below now lets through.
    • The upgrade would relocate. Passing OPENDJ=<somewhere else> over a detected installation is not a move — the old tree is emptied while the new one is installed elsewhere. Refused whenever the existing location is known authoritatively (the recorded registry value, or a legacy default directory that really holds a server) and the requested directory is not the same one. The comparison is an equality test, not a substring one: "contains" would have admitted C:\opendj\v2 over a recorded C:\opendj, which is the relocation being guarded against. The legacy branch makes one exception, without which the two guards contradicted each other: a directory that was named on the command line and holds a server is taken as the installation being upgraded, not as a relocation target. That is exactly the workaround the decoy residual above prescribes, and refusing it left such a host with no upgrade path at all. Naming an empty or new directory is still a relocation and still refused, and the registry branch keeps refusing unconditionally — a location this package recorded is authoritative. The residual: a named directory holding some other OpenDJ tree is now accepted, which directory evidence cannot distinguish from the real one; between refusing the documented workaround and trusting an explicit instruction, the instruction wins.
  • The service wrapper stops reporting failures as success. opendj_service.exe answered --disableService with "already disabled" whenever it could not enumerate the SCM, and with "marked for deletion" when createServiceBinPath() failed on a path over MAX_PATH or a command line over COMMAND_SIZE — both of which InstallerHelper.disableWindowsService() lets through, so an uninstall carried on over a service it had not removed. Both are errors now (exit 3, SERVICE_DISABLE_ERROR, which the uninstaller turns into a stop). A third case joins them: getServiceList() returns success while leaving the command line NULL for any service whose configuration it could not read, and the search matches on nothing else, so a service that exists but is unreadable landed on "does not exist". Those entries are counted now, and a sweep that matched nothing while skipping some returns SERVICE_LIST_PARTIAL — an error on the removal path only; --serviceState keeps answering "disabled", since its three java callers only ever test for SERVICE_STATE_ENABLED and it would otherwise print an error on every box where an unrelated service denies QueryServiceConfig.
  • Java: the installer does not check for or install a JRE (a JAVA_HOME/PATH launch condition was tried and dropped — it blocked upgrades of working servers and never consulted OPENDJ_JAVA_HOME, see the review discussion and Add CI install-test for the Windows MSI + document MSI install/upgrade/uninstall #664). setup and the server require Java 11+; the install guide points at https://adoptium.net. The MSI is not code-signed (no certificate) — SmartScreen note added to the docs.

Release / deploy

  • deploy.yml reuses the MSI already built by the triggering Build run (no rebuild).
  • release.yml: new release-msi job (windows, continue-on-error) installs the released server zip into the local repository and packages only :opendj-msi-standard, then attaches the MSI to the GitHub release.
  • opendj-msi-standard is intentionally no longer published to Maven Central (excludeArtifacts on central-publishing-maven-plugin in the root pom — both MSI poms stay in every reactor so the release plugin keeps versioning them, and maven.deploy.skip cannot do this job: the publishing extension unbinds maven-deploy-plugin and publishes through its own goal, which that property does not gate): it was previously wine-built on Linux and deployed from the ubuntu deploy jobs; it is now built natively on Windows and distributed through GitHub Releases and the Package/Deploy OpenDJ MSI Package artifact. The Maven coordinate stays at 5.1.2 as its last published version. This also applies to sustaining/4.10.x: Package/Deploy always runs master's workflow, which no longer installs wine, while the 4.10.x pom still builds its MSI through it — accepted; the 4.10.x MSI remains available up to its last published release.
  • The committed Windows launchers are refreshed by CI, not by hand. opendj-server-legacy/lib/*.exe is what every Linux-built server zip ships — and with it the tagged releases and the Maven Central artifacts — while only a Windows job can rebuild them, so a native source change that was never re-committed shipped the old wrapper while CI stayed green (master carried exactly that gap from April to July). Package/Deploy now downloads windows-exe-11 from the triggering Build run and commits the binaries back when their bytes differ. It runs only after a green push build on a release branch and already holds contents: write for the wiki push, so build-maven stays read-only; /Brepro in the Makefile keeps the output a function of the sources, without which it would commit on every run; and pushes made with GITHUB_TOKEN start no workflow run, so it cannot loop. The step sits ahead of the Maven deploy so the published snapshot carries the fresh launchers, which also means it must never be what costs that deploy: it retries the push three times over a rebase and then warns and lets the job continue. The rebase moves the worktree onto the branch tip, which can carry commits the triggering Build never saw, so an EXIT trap puts it back on the refreshed tree however the step ends — a failed fetch, a rebase conflict, three lost races or the push landing — because git rebase --abort only returns to the state the aborted rebase itself started from, which on the second attempt is the first attempt's result. The warning-only comparison step and the standalone Native launchers workflow are removed.
  • Unrelated to the MSI, but carried by this branch: exec-maven-plugin is now managed centrally at 3.6.3, and the mib-generation execution in opendj-server-legacy drops its pinned 1.3.2 to follow it. That execution lives in the snmp profile, which auto-activates whenever ${opendmk.lib.dir}/jdmkrt.jar exists, so the bump reaches ordinary builds on every platform, not only the MSI leg.

CI

  • test-msi: installs into the spaced x64 default directory, asserts the package registered no service and that lib\opendj_service.exe was laid down, runs setup --doNotStart, then starts and stops the server with start-ds.bat/stop-ds.bat — proving an MSI install that never asked for service mode behaves like a zip one. Only then does it enable the service with windows-service.bat, start it, ldapsearch, stop and disable it; uninstall.bat --cli must remove the instance and disable a service it finds; msiexec /x must leave nothing behind. The wait-for-the-lock helper the zip test uses after stop-ds (a zero exit code is not evidence the JVM let go — Flaky Test on Windows: winlauncher.exe stop can return success without stopping the server #768) moved to .github/scripts/wait-server-stopped.ps1 so both jobs share one copy.
  • test-msi also checks the guards without installing anything, before the install steps: it reads the MSI authoring — wix copies sequence conditions and ExeCommand into the tables verbatim, and reading the built package's tables through the WindowsInstaller.Installer COM API returned no rows at all under pwsh 7 — and asserts that the two refusal conditions name only public properties (a lower-case letter in a condition is a private property, which reads as empty in the installer service and silently inverts the guard), that OPENDJ and OPENDJ_GIVEN are declared Secure, that OPENDJ_GIVEN is still captured with Sequence="first" (without it the property is re-set after CostFinalize in a full-UI install and the guards' prefix test compares a value with itself), and then runs CheckServerNotRunning's command exactly as msiexec runs it — Formatted field resolved, handed to cmd.exe, so Windows PowerShell 5.1 executes it — against a real held byte-range lock: exit 0 with no lock file, 0 with an unlocked one, 1 while a second process holds it, and not before the 60 s grace is up. The scenario suite could see neither failure on its own: it reaches the inline PowerShell only through a ten-minute install and can then judge it by msiexec's exit code alone, and all 42 of its msiexec calls are /qn, which runs no UI sequence at all.
  • test-msi-upgrade: released 5.1.2 x86 MSI at its native Program Files (x86)\OpenDJ default → configure → service registered the pre-MSI way and left running → upgrade with the new x64 MSI with no OPENDJ given: the installer stops the running service (the stop+check pair, elevated here), keeps the same directory, leaves the data intact and the registration in place, then upgrade.bat, start through that same service, search, stop. A repair (REINSTALL=ALL) must not disturb the registration; disabling the service before uninstalling must leave no orphan. Plus fresh-install and refusal scenarios: the legacy default directory is auto-detected, the registry-recorded location wins over it, a silent upgrade from an undetectable custom directory refuses with guidance and the same upgrade succeeds once the directory is named, a leftover empty legacy directory is not adopted as the target of an upgrade, an upgrade that would relocate to another directory is refused on both branches of the guard, an install into a third directory leaves a zip instance's OpenDJ Server untouched despite a leftover legacy directory, an unrelated OpenDJ tree in the x64 default directory is not adopted as the target of an upgrade whose real installation is elsewhere (refused, then completed once the directory is named), a 5.1.x installed in that default directory refuses the silent upgrade and accepts the named one, an upgrade started while the service is StartPending is refused by CheckServiceStopped with the instance intact — that state produced deterministically by holding bat\start-ds.bat open, which keeps the wrapper reporting SERVICE_START_PENDING — a decoy in the legacy default directory does not block the documented workaround (naming an empty directory is still refused as a relocation, naming the real installation upgrades it), and an upgrade over a server running without a service is refused by CheckServerNotRunning and then succeeds once stop-ds.bat has run. The relocation scenarios install the released 5.1.2 package first: reinstalling this MSI over itself is maintenance mode, where FindRelatedProducts does not run, so WIX_UPGRADE_DETECTED would never be set and the guard could not fire. The two check-based refusals assert the exact 1603 and the failing action's Return value 3 in the log, and the running-server one also fails on any new PendingFileRenameOperations entry: -ne 0 accepts 3010, which is precisely what a guard that fails open produces, and the action's name appears in the log whether it refused or waved the upgrade through — which is how a completely inert CheckServerNotRunning stayed green for two rounds. An upgrade passing OPENDJ_GIVEN=1 must still be refused, as must one naming a directory that holds no server - the mistyped-target case, asserted on the topology the undetectable-directory scenario already builds.
  • Neither MSI job depends on build-maven any more. The matrix's ubuntu legs run for about two hours, so every push inside that window cancelled the run before these two-minute jobs started — which is how several rounds of guard work reached review with no completed run behind them. Both now depend on a small wait-msi-artifact job that polls for the windows-latest-11 artifact and gives up early if the Windows leg finishes without publishing it. That job runs on ubuntu: waiting on windows-latest held a Windows runner from t=0 and competed for the capacity the leg being waited for needs (in run 31578978374 the windows/11 leg queued for 38 minutes while windows/26 started within one), and its fixed 45-minute budget covered the leg's runtime but not its queue time — in that same run the artifact appeared at +52 minutes, so both jobs would have failed a healthy build. One gate job also means one copy of the wait instead of two.
  • The windows/11 job validates the built package with wix msi validate -sice ICE61 (ICE): wix build itself runs no ICE validation, so a green build alone would not catch sequencing mistakes like ICE63. ICE61 is suppressed because AllowSameVersionUpgrades authors it by design.

Windows script fixes surfaced by these tests (in #671)

Install paths with spaces/parentheses (C:\Program Files (x86)\OpenDJ — the old default!) broke the batch scripts in four places: unquoted java.io.tmpdir, setcp.bat argument parsing, "%VAR%" == "" checks with the now-quoted tmpdir, and the start-ds.bat tmp-cleanup block. All fixed and exercised by the MSI tests in this PR.

Deferred

  • Authenticode signing — no certificate.
  • A Burn bundle auto-installing the JRE — dropped; the installer neither bundles nor checks for a JRE (see above), the docs carry the download link.
  • A release-time check that the committed launchers are not older than their sources: written and dropped once Package/Deploy started committing them automatically. It would cover the two residuals — a release cut in the window between a merge and the deploy run, and a master whose Build is red — and can be added on request.

The launcher binaries under opendj-server-legacy/lib were last refreshed from the windows-exe-11 artifact in 252d5ba, and service.c has changed in eight commits since — the committed opendj_service.exe still carries the isMsiManagedService ownership check this branch later reverted, and lacks the SCM-failure reporting serviceState()/removeService*() gained — so it matches no reviewed state of the sources. Only that one file is out of date: the windows-exe-11 artifact of the Build run on this head has launcher_administrator.exe and winlauncher.exe sha256-identical to the committed ones, which is /Brepro doing what it is there for, and its opendj_service.exe is the current sources. The Windows build leg compiles the launchers itself before packaging, so the MSI and the Windows-built zip carry the current code; the Linux-built zip, deb, rpm and Docker image do take the committed bytes, which is the residual — a Release dispatched before the first post-merge Package/Deploy run would ship them. Package/Deploy re-commits the artifact over lib/*.exe on the first push after merge, which is where the refresh is left deliberately rather than hand-committing an eighth time. The GUI wizard path (license-dialog skip, InstallDirDlg pre-population, the two upgrade refusals) is not covered by CI — one manual run before merge is planned; the property-visibility half of it, a guard reading something the installer service never receives, is now asserted statically against the built MSI.

The guards have now executed in finished runs, and one of those runs is what exposed the fail-open described above. Run 31619923691 (12 Aug, head e6f0a44) finished with test-msi, test-msi-upgrade and wix msi validate -sice ICE61 green — the only red job was build-docker-alpine, which has nothing to do with the MSI — and its log shows the running-server scenario printing exit 3010 where every other refusal prints 1603: the upgrade went through and deferred the locked jars to a reboot, while a -ne 0 assertion accepted it.

Run 31703288399 (13 Aug, head 6bcada5) is green end to end - test-msi, test-msi-upgrade, docker and the ICE validation - with the guard probe reporting CheckServerNotRunning: 0 with no lock file, 0 unlocked, 1 while held (after 61s). The run before it, 31689733634 (head 59b7570), is the one that answered the fail-open: test-msi-upgrade green with Upgrade refused while a non-service server was running (exit 1603), the failing action's Return value 3 in the log and no delete-on-reboot entry left behind. test-msi failed in that run on the new guard step's MSI-table read rather than on anything it was testing; that read is replaced in 6bcada5, and the run on this head is what confirms it. The gate job described under CI is what made finished runs reachable in ~20 minutes instead of two hours.

vharseko added 22 commits June 26, 2026 20:37
The opendj-msi package was built and uploaded but never installed or
exercised in CI. Add a test-msi job (needs: build-maven) that, on a
windows-latest runner, installs the built .msi silently (msiexec /i),
runs setup, registers and starts/stops the OpenDJ Windows service with
an ldapsearch liveness check, then uninstalls (msiexec /x).
…E 25)

The MSI ships no JRE, so add a WiX launch condition that fails the install
early with a clear message when Java is not detected (it does not install
Java). JAVA_HOME is captured from the environment before LaunchConditions;
`Installed` keeps uninstall/repair working regardless of Java.

Fix the test-msi CI job (it failed with `net start` exit 2 "service already
started"):
- setup.bat is now invoked with --doNotStart, so the server is started only by
  `net start "OpenDJ Server"` (setup.bat no longer starts a standalone instance
  first).
- Bump actions/setup-java from 21 to 25 (latest LTS; smoke-tests the MSI under a
  fresh JRE). The runner has JAVA_HOME from setup-java, so the new launch
  condition is satisfied and the install proceeds.
The launch condition (Installed OR JAVA_HOME_ENV) false-blocked valid installs: a JRE does not always set JAVA_HOME (it may be only on PATH). Drop it - the MSI again only copies files and Java availability stays the admin's responsibility. The test-msi fix (--doNotStart, JRE 25) is unaffected.
The install guide covered only the .zip and native .deb/.rpm. Add Windows MSI sections to chap-install/chap-upgrade/chap-uninstall: GUI and silent msiexec install, Java as a runtime prerequisite the installer does not enforce, configure via setup.bat, optional Windows service registration via windows-service.bat, MSI upgrade (disable service, back up, install newer .msi, upgrade.bat, re-enable), and uninstall via Apps & features / msiexec /x.
Migrate the Windows MSI off the EOL WiX 3.11.1 (heat/candle/light run under
wine + winetricks dotnet40) to the cross-platform WiX v5 .NET tool, and
address the MSI validation findings:

- package.wxs rewritten to the WiX v4+ schema: single <Package>, x64
  (ProgramFiles64Folder), MediaTemplate CompressionLevel="high",
  InstallerVersion=500, <Files> directory harvest (replaces heat), and a
  <ServiceInstall>/<ServiceControl> on opendj_service.exe so the MSI itself
  registers the "OpenDJ Server" Windows service (Start=auto, not started during
  install - setup must configure the instance first). UpgradeCode preserved.
- opendj-msi-standard/pom.xml builds the MSI with `wix build -arch x64`; removed
  the external-dependency-plugin (wix3111 + winetricks), heat/candle/light and
  the wine/winetricks antrun steps.
- opendj-msi/pom.xml: wine-path profiles replaced by an unconditional module so
  the MSI builds natively on every OS where opendj-msi is included.
- build.yml: drop wine (keep rpm); add a "Setup WiX (.NET tool)" step; test-msi
  no longer calls windows-service.bat --enableService (the MSI registers it).
- install guide: x64 install path; the MSI registers the service.

Signing (OpenIdentityPlatform#4) deferred (no certificate). The Burn bundle that auto-installs a JRE
is a follow-up, to be added after this MSI build is validated in CI.
WiX 5 ships as a net6.0 .NET tool, but the runners only have a newer .NET runtime (no .NET 6), so 'wix' failed to launch (exit 131, missing_runtime) on arm64 macOS. Set DOTNET_ROLL_FORWARD=Major in the Setup WiX step (exported and written to GITHUB_ENV) so the tool rolls forward to the installed runtime, both for that step and the Maven build that invokes 'wix build'.
wix build failed with WIX0004 ('Files element contains an unexpected attribute Exclude'): in WiX 5.0.2 exclusion is a child <Exclude Path=.../> element (with <Include Path=.../>), not an inline attribute. Convert both <Files> harvests to the child-element form.

macOS (arm64) runners have no .NET runtime for the net6.0 wix apphost (DOTNET_ROOT unset, exit 131); add actions/setup-dotnet (8.0.x) on macOS so the tool finds a runtime and rolls forward.
…only Include)

WiX 5.0.2 requires Include as an attribute on <Files> and rejects an Exclude attribute as well as <Include>/<Exclude Path> child elements (WIX0004/0005/0010). Move all exclusions to the Ant staging step: stagingRoot = payload minus lib (and macOS/Unix bits), stagingLib = lib minus opendj_service.exe (registered via an explicit component sourced from the package dir). Each <Files> now uses only the Include attribute, and there is no duplicate lib directory.
On Linux/macOS the wix tool treats backslash as a literal, not a path separator, which may cascade into the WIX0389 'not a relative path' errors. Use '/**' (accepted on Windows too) to test whether the cross-platform wix build then succeeds.
The WiX Toolset can create MSI databases on Windows only: it warns "only
supports Windows" and, on Linux/macOS, fails with WIX0389 on every Directory
name and (with no name) on a missing msi.dll (the Windows Installer library).
Verified locally on macOS with WiX 5.0.2, 6.0.1 and 7.0.0 - none can build.

- opendj-packages/pom.xml: build opendj-msi only in the distribution-windows
  profile (removed from distribution-unix and distribution-mac).
- build.yml: run the WiX setup step only on Windows; drop macOS setup-dotnet.
- package.wxs: keep the Windows-proven backslash glob in <Files Include>.
- .gitattributes: force LF for *.wxs/*.wxi.
The MSI can only be built on Windows. Instead of rebuilding opendj-server-legacy
on a Windows runner:

- deploy.yml: reuse the MSI already built by the triggering Build run (download
  the windows-latest-11 artifact, re-upload as "OpenDJ MSI Package"); both steps
  continue-on-error; drop wine (rpm only).
- release.yml: release-maven uploads the released server zip as an artifact;
  new release-msi job (windows, continue-on-error so an MSI failure does not
  break the release) installs the zip into the local Maven repository and only
  packages :opendj-msi-standard (no -am), then attaches the MSI to the GitHub
  release; drop the .msi from the ubuntu release file list; drop wine.
…e legacy service

- package.wxs: refuse to install when no Java is detectable (JAVA_HOME\bin\java.exe
  or java-looking PATH entries; MSI cannot scan PATH, so substring heuristic) with
  a message pointing at https://adoptium.net; detect an existing install (InstallDir
  registry value written by this package, else the legacy x86 default directory)
  and apply it only when OPENDJ is not set explicitly; drop the legacy
  windows-service.bat "OpenDJ Server" service before InstallServices (its display
  name collides with the MSI ServiceInstall).
- build.yml: test-msi asserts the installer fails without a JRE before one is set
  up; new test-win-upgrade installs the released 5.1.1 x86 MSI, configures an
  instance with the legacy service, upgrades with the newly built x64 MSI without
  OPENDJ and asserts same directory, intact data, MSI-managed service, then runs
  upgrade.bat, starts the service and searches the pre-upgrade data.
- docs: Java is required by the installer (adoptium.net link); MSI upgrade
  autodetects the directory and replaces the service; uninstall removes the
  service automatically.
- _script-util.bat: quote -Djava.io.tmpdir="%OPENDJ_TMP_DIR%" - any install
  directory containing spaces (including the x64 default C:\Program Files\OpenDJ
  and the legacy Program Files (x86)) broke the CheckJVMVersion probe and every
  script with "The detected Java version could not be used".
- build.yml test-msi: the no-JRE negative test now hides Java at MACHINE scope
  (the Windows Installer service evaluates launch conditions with the machine
  environment) and restarts msiserver, restoring everything afterwards; the
  positive install now goes to the spaced x64 default directory end-to-end.
- build.yml test-msi-upgrade (renamed from test-win-upgrade): the released 5.1.1
  scripts cannot run from a spaced directory, so the old install lives in
  C:\opendj and the upgrade passes OPENDJ explicitly; added a fresh-install
  scenario asserting the legacy default directory is auto-detected when OPENDJ
  is not given, plus uninstall/service cleanup checks.
- ADNotificationRequestControl: escape && in the javadoc code sample
  (javadoc "invalid input: '&'" warnings).
…aces)

_script-util.bat appends -Djava.io.tmpdir=%OPENDJ_TMP_DIR% to OPENDJ_JAVA_ARGS
without quotes, so in any install directory containing spaces (for example
C:\Program Files (x86)\OpenDJ) the CheckJVMVersion probe - and with it setup
and every other command-line tool - fails with "The detected Java version
could not be used with the set of Java arguments".

Quote the value: -Djava.io.tmpdir="%OPENDJ_TMP_DIR%".
…cp.bat)

_script-util.bat passed unquoted paths to setcp.bat, and setcp.bat compared
arguments with if ""%1""=="""". The argument-joining hack survived spaces, but
a parenthesis in the path (C:\Program Files (x86)\OpenDJ - the default MSI
directory) breaks the cmd parser with "... was unexpected at this time", so
setup.bat and every tool exit with 255.

Quote the setcp.bat arguments at the three call sites and switch setcp.bat to
%~1 with quoted comparisons.
…e "%VAR%" == "" comparisons

After -Djava.io.tmpdir="%OPENDJ_TMP_DIR%" is appended, OPENDJ_JAVA_ARGS
contains embedded quotes, and the subsequent if "%OPENDJ_JAVA_ARGS%" == ""
checks blow up the cmd parser ('...\tmp"" was unexpected at this time', every
tool exits 255) regardless of whether the install path has spaces. Compare
with "if defined", which does not expand the value.
…h quoting

- The <Files> harvest ships files only (heat had -ke), so the empty instance
  directories (bak, changelogDb, classes, db, import-tmp, ldif, locks, logs,
  tmp and the template ones) were missing from the installed tree and the
  server could not create its lock/pid files - setup aborted with "error
  stopping server". Create them with explicit CreateFolder components.
- [OPENDJ] ends with a backslash which escaped the closing quote in the
  service ImagePath ('start "C:\opendj\"' -> broken argv), so the MSI-managed
  service failed to start (NET HELPMSG 2186). Append an extra backslash so
  \\" parses as backslash + closing quote.
Components whose KeyPath is a directory cannot use auto-generated GUIDs, so the empty-instance-directory components broke wix build with WIX0230. Assign fixed GUIDs.
The tmp-cleanup block expands %OPENDJ_TMP_DIR% unquoted inside a ( ) compound statement, so a parenthesis in the install path - C:\Program Files (x86)\OpenDJ - terminates the block at parse time and start-ds fails; the Windows service then dies with a service-specific error -1 while setup (which never runs this block) succeeds. Quote the three path expansions.
opendj_service.exe located its own service entry by comparing the raw
SCM ImagePath with the exact string it builds itself, so a service
registered by the MSI (exe unquoted, instance dir with a trailing
backslash) was never found and "net start" failed with error 2186.
getServiceName now compares the executable path, the subcommand and the
normalized instance dir token by token instead. The MSI ServiceInstall
argument becomes 'start "[OPENDJ]."' and the sc-config workaround is
not needed.
@vharseko vharseko added the packaging deb/rpm/MSI, distribution layout, config.ldif label Jul 6, 2026
…ening it

WIX0012: a property AppSearch fills cannot be private, so OpendjGivenInstall
does not compile - which was the whole of the previous commit's answer to
"a public property can be handed in on the command line and switch the guard
off". OPENDJ_GIVEN_INSTALL is public, and the guard stops testing it for
truth: the search sets it to the full path of the setup.bat it found, so
requiring that path to contain the resolved OPENDJ is both the "a server is
there" test and the reason OPENDJ_GIVEN_INSTALL=1 says nothing. A value
spelled out as a path inside the named directory still passes - deliberate
construction rather than a slip, and the comment says so.
@vharseko

Copy link
Copy Markdown
Member Author

Correction to the first heading above: the property is public, not private, and the paragraph arguing that privacy is what closes the command-line bypass was wrong twice over — WIX0012 says a property AppSearch fills cannot be private, so it did not even compile. The Build on that head failed on exactly that line, with nothing else wrong in the leg.

Fixed in 777b773 by making the test value-based instead of truth-based. The search stores the full path of the setup.bat it finds, so the guard now requires that path to sit inside the directory that was named — OPENDJ_GIVEN_INSTALL ~>< OPENDJ — rather than requiring the property to be merely set. OPENDJ_GIVEN_INSTALL=1 on the command line therefore says nothing. A value spelled out as a path inside the named directory would still pass; that is deliberate construction rather than a slip, and the comment says so. Everything else in the reply stands, and no other behaviour changed.

One thing that run did establish, on the third heading: the gate job caught the Windows leg finishing without the artifact at +8 minutes and skipped both MSI jobs, instead of a 45-minute timer expiring on two held Windows runners. The rest of that heading — and all of the first, second and fourth — is still waiting on the run now in flight.

Measured, not reasoned. DisableShutdown was chosen to keep RM's detection -
"The installer still uses the Restart Manager to detect files in use by
applications" - on the reading that only the shutting down and the
restarting were disabled. Run 31615584455 says otherwise: the headline
upgrade scenario finished with the service StartPending after an otherwise
successful install, so the installer restarts what the RM session listed at
InstallValidate whether RM stopped it or CheckServiceStopped did. That is
the failure the guards exist to prevent - the upgraded server coming up
before upgrade.bat has migrated the instance data.

So Disable, and the detection goes with it. CheckServerNotRunning is what
replaces it and why the trade is affordable: the byte-range lock on
locks\server.lock answers for both the service and the start-ds.bat modes,
and answers in /quiet, where a FilesInUse dialog could not be shown. What
stays uncovered is some other process holding payload files - the WiX3-era
behaviour, now stated as the residual it is.
@vharseko

Copy link
Copy Markdown
Member Author

Second correction, to the "Non-service servers lose all in-use detection" heading: DisableShutdown is not the middle ground I claimed, and CI said so before you had to.

The reading was that it keeps RM's detection — "The installer still uses the Restart Manager to detect files in use by applications" — while disabling both the shutting down and the restarting. Only the first half is true. In run 31615584455 the headline upgrade scenario finished with the service StartPending after an otherwise successful install: the guards stopped it, and the installer restarted it at the end anyway, because what the RM session listed at InstallValidate gets restarted whether RM stopped it or CheckServiceStopped did. That is precisely the failure the round-eight commit exists to prevent — the upgraded server coming up before upgrade.bat has migrated the instance data — so the setting is back to Disable in e6f0a44, with the observation recorded in the comment as a measurement rather than an argument.

Which means your finding is closed by CheckServerNotRunning alone, not by restoring detection: the byte-range lock on locks\server.lock answers for the start-ds.bat mode and the service mode alike, and answers under /quiet, where a FilesInUse dialog could not be shown even if RM had found the JVM. What stays uncovered with RM off is some other process holding payload files — the WiX3-era behaviour, now stated as the residual it is rather than implied to be something better.

Where the evidence stands after two runs on this branch. Green: the MSI builds, wix msi validate -sice ICE61 passes on the current authoring including the new search and the new action, the gate job releases both MSI jobs about 17 minutes into the run, test-msi passes end to end, and the gate's early exit was exercised for real when the Windows leg failed. Still unproven: every test-msi-upgrade scenario, which was skipped after the first one failed on the restart above — including the two new ones for the relocation exception and the running non-service server. The run on e6f0a44 is the one that reaches them.

@vharseko
vharseko requested a review from maximthomas August 12, 2026 18:18
@maximthomas

maximthomas commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review: requesting changes — one blocker, and the new CI scenarios can't catch it

Reviewed at e6f0a44, continuing from the MSIRESTARTMANAGERCONTROL correction. I focused on
the paths added in the last two rounds that have never actually executed. Most of the round-nine
and round-ten work checks out — details at the bottom — but two of the new guards don't do what
they're written to do, and the tests added alongside them can't tell.


1. Blocker — CheckServerNotRunning can never refuse

opendj-packages/opendj-msi/opendj-msi-standard/resources/msi/package.wxs:362

catch { if ($_.Exception.GetType().FullName -ne 'System.IO.IOException') { exit 0 }; Start-Sleep -Seconds 2 }

When a .NET method or constructor throws inside PowerShell, an untyped catch doesn't unwrap
it — $_.Exception is System.Management.Automation.MethodInvocationException, and the real
exception is in .InnerException. Both throw sites here are invocations
(New-Object System.IO.FileStream(...) and $fs.Lock(0, 1)), so the comparison is always true,
the action always takes exit 0, and the exit 1 is unreachable.

The lock probe itself is right: LockFileManager.java:231,249 takes the lock with
RandomAccessFile(lockFile, "rw") + tryLock(0, Long.MAX_VALUE, false), a mandatory LockFileEx
range covering byte 0, and RandomAccessFile opens with FILE_SHARE_READ|FILE_SHARE_WRITE. So
the open succeeds by design and Lock(0, 1) is what raises IOException — exactly the
wrapping-sensitive call.

Empirical confirmation (PowerShell 7.5.0 and 6.2.4, identical output)
### VARIANT A: exact shipped code, untyped catch + FullName equality ###
  caught. FullName = System.Management.Automation.MethodInvocationException
  RESULT: guard tripped -> exit 0 (sleep branch NEVER reached)
### VARIANT B: typed catch [System.IO.IOException] ###
  RESULT: TYPED CATCH MATCHED -> Start-Sleep branch reached.

5.1 isn't runnable off-Windows, but the mechanism is in engine source 5.1 shares:
Compiler.cs::VisitTryStatement special-cases a lone catch-all and emits
$_ = new ErrorRecord(rte.ErrorRecord, rte) with no unwrapping, while typed catches go through
ExceptionHandlingOps.FindMatchingHandler, which tries InnerException first. Same code at
v6.0.0-alpha.9, the direct fork of the 5.1 codebase. The 5.1-moniker docs show the
MethodInvocationException message template for [System.IO.File]::ReadAllText.

This is a blocker rather than a nit because the comment at lines 322-328 is exactly right about
the exposure: with MSIRESTARTMANAGERCONTROL=Disable, no service key means
OPENDJ_SVC_IMAGEPATH is empty, ~>< is false, and StopServiceBeforeUpgrade /
CheckServiceStopped are skipped outright. CheckServerNotRunning is the only guard left for a
start-ds.bat server — and it always exits 0. The outcome is the one described at lines 266-269:
locked jars degrade to delete-on-reboot entries and /norestart returns 3010.

Fix.github/scripts/wait-server-stopped.ps1:39 already has it:

catch [System.IO.IOException] { Start-Sleep -Seconds 2 }
catch { exit 0 }

One trap worth noting: because the open uses New-Object, $_.Exception stays
MethodInvocationException even inside a matching typed catch, so a FullName comparison in
that block would still be wrong. (An exact-FullName test is fragile regardless —
FileNotFoundException is an IOException subclass.)


2. The new CI scenarios pass either way

.github/workflows/build.yml:1313-1314, and the same pattern at :1235

if ($p.ExitCode -eq 0) { ...; throw "the upgrade must refuse while a server is running out of the tree" }
if (-not (Select-String -Path upgrade-running.log -Pattern "CheckServerNotRunning" -Quiet)) { ... }
  • Line 1313 asserts only ExitCode -ne 0. A fail-open upgrade hits the locked jars and returns
    3010 under /norestart, which satisfies it.
  • Line 1314 greps for an action name. MSI writes Action start ...: CheckServerNotRunning.
    when it runs and Skipping action: CheckServerNotRunning (condition is false) when it doesn't.
    The string is there either way.

So on the current head this step should print
Upgrade refused while a non-service server was running (exit 3010) and go green while the
upgrade it was meant to block has just renamed a live server's jars.

I'd argue this matters more than the blocker itself: while it holds, a green run isn't evidence
that any of these guards work, and careful reading is the only thing standing in.

Fix — assert 1722, grep for Return value 3 on the action line, and assert
PendingFileRenameOperations is absent afterwards. Both :1313 and :1235.


3. The relocation exception is unreachable in a full-UI session

package.wxs:368 — the exception is NOT (OpendjDirGiven AND (OPENDJ_GIVEN_INSTALL ~>< OPENDJ)),
and OpendjDirGiven has one writer, package.wxs:153:

<SetProperty Id="OpendjDirGiven" Before="AppSearch" Sequence="first" Value="1" Condition="OPENDJ"/>

Sequence="first" compiles to msidbCustomActionTypeFirstSequence, and per Custom Action
Execution Scheduling Options
:

Always skips action in execute sequence if UI sequence has run. ... The action is not
required to be present or run in the UI sequence to be skipped in the execute sequence.

The skip keys on the UI sequence having run, not on the condition having been true. So in a
full-UI session OpendjDirGiven is unset when the guard evaluates — including when OPENDJ=
is passed on the command line
, which is what the refusal message and chap-upgrade.adoc:266
both point users to. /qn and /qb never process the UI sequence, which is why every CI
scenario passes. (This half deserves one empirical check: a wizard run with /l*v, grepping
for OpendjDirGiven.)

The wizard also ends up inverted in the decoy topology — 5.1.x at D:\opendj, empty registry,
stray tree in C:\Program Files (x86)\OpenDJ:

wizard action result
browse to the correct D:\opendj OpendjDirGiven unset → branch (b) TRUE → refused
accept the pre-populated default OPENDJ_LEGACY_DEFAULT ~<> OPENDJ false → no guard fires, installs into the decoy while RemoveExistingProducts empties D:\opendj

Fix — publish the "directory was named" signal so it survives into the execute sequence
(public Secure="yes", or re-derive it there from OPENDJ vs OPENDJ_LEGACY/OPENDJ_DEFAULT
plus OPENDJ_GIVEN_INSTALL ~>< OPENDJ). Worth noting all 75 msiexec calls in build.yml use
/qn, so no wizard behaviour is regression-tested at all.


Checked and clean

  • service.cSERVICE_LIST_UNAVAILABLE appended last, no renumbering, handled at all six
    getServiceName call sites. ControlPanelInfo.java:420, Uninstaller.java:1352 and
    DirectoryServer.java:5284 test only == SERVICE_STATE_ENABLED, so SERVICE_STATE_ERROR
    reads as "not enabled" just as SERVICE_STATE_DISABLED did. Only nit: the getServiceName doc
    comment at service.c:977-978 still says "SERVICE_RETURN_ERROR otherwise".
  • openScmif (*scm == NULL) is a real fix; the old test compared the address of the
    caller's variable, which is never NULL.
  • Guard balance and precedence, ~= / ~<> / ~>< semantics, trailing-backslash construction on
    both sides of every comparison, the Formatted-field brace rules (no {} group in the command
    holds a [property]), and FindRelatedProductsAppSearch ordering with the execute-sequence
    re-run — all correct.

Suggested order

  1. Fix the CI assertions first, before touching a guard, and re-run against this head — the
    suite should go red on №1. If it doesn't, the assertions still aren't real.
  2. Then the typed catch, and watch the now-honest suite go green.
  3. Consider moving the PowerShell out of the XML attribute so it can be unit-tested — a Binary
    table entry, or a .ps1 as source of truth inlined at build time. I reproduced №1 in a
    container in about five minutes; that loop belongs in CI. It's also the direct cause here —
    wait-server-stopped.ps1 has the typed catch, and the inline copy lost it on the way into the
    attribute.
  4. One full-UI scenario for №3.
  5. Possibly split: service.c and openScm are independent and verified clean, and could go in
    now while the MSI guard work continues separately.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

see the comment above #701 (comment)

CheckServerNotRunning could not refuse anything: PowerShell wraps an exception
thrown out of a .NET method or constructor in a MethodInvocationException, so
the catch-all's $_.Exception.GetType().FullName -ne 'System.IO.IOException'
test held for every exception, including the byte-range lock the action exists
to detect - the exit 1 was unreachable. Use the typed catch
wait-server-stopped.ps1 has always had, with the Formatted-field escapes
([\[] and [\]]) an ExeCommand needs so the type literal is not substituted away.

The scenario covering it passed regardless: it asserted a non-zero exit code,
which the fail-open outcome satisfies (3010, locked jars deferred to a reboot),
and grepped for an action name the log carries whether the action refused or
not. Assert the exact 1603, the action's "Return value 3", and that nothing was
scheduled for delete-on-reboot; likewise for the StartPending scenario.

OpendjDirGiven was private, and a private property set in the UI sequence never
reaches the execute sequence, which is processed in the installer service
rather than the client. Every full-UI session therefore read it as empty at the
guards - including one started with OPENDJ= on the command line, the single
route both the refusal message and the install guide prescribe - so the wizard
refused the documented workaround and the relocation guard's exception could
never apply. No scenario could see it: all 42 msiexec calls in build.yml are
/qn. Replace it with a public, secure OPENDJ_GIVEN holding the named path,
which the guards require the resolved OPENDJ to start with, so it is not a
switch.

Add a test-msi step that reads the built MSI: guard conditions may name only
public properties, OPENDJ and OPENDJ_GIVEN must be secure, and
CheckServerNotRunning's command, resolved the way msiexec resolves it, must
exit 0 with no lock file, 0 with an unlocked one, and 1 while another process
holds the lock.

getServiceName's doc comment now names SERVICE_LIST_UNAVAILABLE, and the
upgrade chapter notes that OPENDJ can be passed with or without /quiet.
@vharseko

Copy link
Copy Markdown
Member Author

All three confirmed, all three fixed in 59b7570. №1 and №2 turned out to be provable from the run that was already sitting on e6f0a44, which makes your point about the assertions sharper than you put it: the evidence was in the log the whole time.

№1 — the guard could not refuse. Confirmed twice

Reproduced the mechanism the way you describe it (containers, pwsh 7.4.6 and 6.2.4, identical): with a catch-all, $_.Exception is System.Management.Automation.MethodInvocationException and the real exception sits in .InnerException — for the New-Object constructor, for a static method, for ::new(), and for an instance method throwing a genuine IOException. The shipped comparison was therefore true in every case. A typed catch [System.IO.IOException] matches through the wrapper in all of them, including through the inner try/finally.

Then the same command end to end, extracted from the wxs and MSI-formatted, against a real cross-process byte-range lock:

command outcome under a held lock
shipped exit 0 after 3 s — fail-open
typed catch exit 1 after 62 s — refusal

And the proof it already happened in production CI — run 31619923691, job test-msi-upgrade, exactly e6f0a44:

Upgrade refused with guidance, original install untouched (exit 1603)
Empty legacy directory not adopted, upgrade refused          (exit 1603)
Upgrade refused while the service was StartPending           (exit 1603)
Upgrade refused while a non-service server was running       (exit 3010)   <-- fail-open

3010 is ERROR_SUCCESS_REBOOT_REQUIRED: the upgrade went through and left the jars it could not replace to a reboot, which is the outcome the comment above CheckServerNotRunning predicts. Every real refusal is 1603.

One trap in the fix as prescribed. catch [System.IO.IOException] cannot be inlined verbatim: ExeCommand is a Formatted field, so [System.IO.IOException] is a property reference and gets substituted away — leaving two catch-all blocks and a script that fails to parse, which would have refused every upgrade rather than none. It is authored as catch [\[]System.IO.IOException[\]], and the CI step below runs the resolved command, so a literal that got substituted away would show up as the very first probe failing rather than as a guard that quietly refuses everything.

Semantics are preserved, near enough to be worth stating: UnauthorizedAccessException is not an IOException, so a denied open still fails open; FileNotFoundException is one, so a file vanishing between the test and the open now costs a single 2 s sleep before Test-Path lets the next pass out at exit 0.

№2 — the assertions. Confirmed by the same log

The job was green while the guard did nothing, exactly as you say. Both scenarios now assert the exact 1603, grep CheckServerNotRunning. Return value 3 / CheckServiceStopped. Return value 3 rather than the bare action name, and the running-server one additionally snapshots PendingFileRenameOperations around the upgrade and fails on any new entry.

On your suggested order — assertions first, then the guard — the red run already exists in the form of that exit 3010 line, so I did both in one commit rather than pushing a knowingly-red head. What replaces the "watch it go red" step is the direct probe in №4 below: it fails on the old command in about 60 s without installing anything.

№3 — confirmed, and the mechanism is worse than the scheduling bit

You are right about msidbCustomActionTypeFirstSequence, and WiX v5.0.2 does put the row in both tables (Compiler_Package.cs::ParseSetPropertyElement: case "first" only sets executionType, sequences stays at the {InstallUISequence, InstallExecuteSequence} default). But the flag is only half of it — the property would not have survived even if the action had run in the UI sequence:

Custom Action Execution Scheduling Options: "…Is a service registered by default when using Windows 2000 and, in this case, the execute sequence table is processed in the installer service."

Private Properties: "you cannot set a private property in the user interface phase of the installation and then pass the value to the execution phase."

So the comment I had written — "the guards are immediate actions running client-side, where the property is visible" — was wrong in its premise: immediate means impersonated, not client-side. Two independent mechanisms each empty the flag in a full-UI session, and /qn (all 42 Start-Process msiexec calls in build.yml) runs no UI sequence at all, so nothing could see it.

Fixed by replacing the private OpendjDirGiven flag with a public, secure OPENDJ_GIVEN holding the named path. To keep it from becoming a switch, the guards require the resolved OPENDJ to start with it (~<<, not ~=: the captured value is the raw command-line string, the resolved one is a directory property with a trailing backslash). OPENDJ_GIVEN=1 disarms nothing — no resolved path begins with 1 — and a value that does pass has spelled out the directory the install resolved to, which is what naming it does. Starts-with rather than contains for that same reason: 1 occurs inside plenty of real paths and at the start of none. A silent scenario asserts the =1 case is still refused.

Your wizard table, row by row, on this head: row 2 is unchanged — a decoy in the legacy default with nothing named is the residual documented at package.wxs:239-247, and it behaves identically silently; nothing in this round makes it better or worse. Row 1 is unchanged too, deliberately: browsing to the real directory still refuses, because browsing sets no property the guard can read, and I would rather leave the exception needing OPENDJ_GIVEN than let a hand-passed OPENDJ_GIVEN_INSTALL stand alone. It is the safe half — nothing installed, nothing removed — and the message names the command line, which now works in a wizard session. Both are written down at package.wxs:285-292. Say the word if you would rather widen the exception to "the target holds a server, however it was chosen"; it is a two-token change.

№4 — the loop you asked for, in CI

New test-msi step, before anything is installed, reading the built MSI:

  • the two guard conditions may name only public properties (a lower-case letter in a condition = a private property = empty in the service — this exact class of bug, caught statically);
  • OPENDJ and OPENDJ_GIVEN must be in SecureCustomProperties;
  • CheckServerNotRunning's command, resolved the way msiexec resolves it ([\[]/[\]] unescaped, [property] substituted) and handed to cmd.exe so it is Windows PowerShell 5.1 that runs it, must exit 0 with no lock file, 0 with an unlocked one and 1 while a second process holds a LockFileEx range over byte 0 — and not before the 60 s grace is up.

It costs ~70 s and would have failed on the previous head. Moving the script out of the XML attribute entirely (your Binary-table / build-time-inlined .ps1 suggestion) is the better shape and I have not done it — this gets the loop into CI without another round of authoring changes.

Nits

getServiceName's doc comment now names SERVICE_LIST_UNAVAILABLE alongside the other two returns.

On splitting service.c and openScm out: happy to do that if you prefer to land them now — they are independent of everything above.

Verified locally before pushing: WiX 5.0.2 compiles the authoring (on Linux it gets as far as the Windows-only WIX0389 on Directory/@Name, with nothing reported against these changes), the workflow YAML parses, all 22 pwsh steps pass Parser::ParseFile, and the probe behaves as tabulated above. The rest is for the run on this head.

@vharseko
vharseko requested a review from maximthomas August 13, 2026 10:11
The step added with the guard fixes could not read the built MSI: the
WindowsInstaller.Installer COM view returned no rows in pwsh 7 without
raising anything, so the first assertion failed as "RequireDirOnCustomUpgrade
is not scheduled in InstallExecuteSequence" while test-msi-upgrade proved on
the same head that it is scheduled and fires. Take the two guard conditions,
the Secure declarations and CheckServerNotRunning's ExeCommand from
package.wxs instead - wix copies all three into the tables verbatim, and it is
test-msi-upgrade that judges the artifact, now on the refusal's own 1603 and
"Return value 3". The lock probe itself is unchanged.
@vharseko

Copy link
Copy Markdown
Member Author

Run 31689733634 on 59b7570 — the artifact-level answer to №1 and №2, and one correction to what I said about the new step.

The guard refuses now

test-msi-upgrade green end to end, with the line that was 3010 on the previous head:

Upgrade refused with guidance, original install untouched (exit 1603)
Empty legacy directory not adopted, upgrade refused       (exit 1603)
Relocating upgrade refused on both branches, original install untouched
Stray default-directory tree ignored: refused, then upgraded where told
Default-directory upgrade refused silently, accepted when named
Upgrade refused while the service was StartPending        (exit 1603)
Legacy-default decoy: empty target refused, named installation upgraded
Upgrade refused while a non-service server was running    (exit 1603)
The same upgrade proceeded once the server was stopped

So on the same scenario that printed exit 3010 before: 1603, with CheckServerNotRunning. Return value 3 in the log and no new PendingFileRenameOperations entry. The OPENDJ_GIVEN rework left every other scenario alone, including the new one asserting that OPENDJ_GIVEN=1 still gets refused.

Correction: the new step reads the authoring, not the MSI tables

I said it was "extracted from the built MSI". It was, and it did not work: the WindowsInstaller.Installer COM view returned zero rows in pwsh 7 without raising anything, so the step failed on its first assertion — RequireDirOnCustomUpgrade is not scheduled in InstallExecuteSequence — while test-msi-upgrade was proving on that same head that it is scheduled and does fire. Most likely if (-not $record) around View.Fetch: Record's default member is the parameterized StringData, and pwsh 7's COM adapter does not convert that to $true the way 5.1 does. I could not verify that without a Windows box and did not want to spend a second red run guessing, so 6bcada5 drops the COM read: the two guard conditions, the Secure declarations and CheckServerNotRunning's ExeCommand now come from package.wxs, which wix copies into the tables verbatim.

What that costs is a wix-level transformation between authoring and package that nobody has ever seen happen for these three fields; what still judges the artifact is the scenario suite above, and it now judges it on the refusal's own 1603 and Return value 3 rather than on any non-zero exit code. The probe itself is unchanged — the command is resolved exactly as msiexec resolves it ([\[]/[\]] unescaped, [property] substituted), written to a .cmd and run through cmd.exe, so Windows PowerShell 5.1 executes it against a real held byte-range lock: 0 / 0 / 1, and not before the 60 s grace is up. Dry-run of the whole step on the Linux stand-in (only cmd.exe and the path separators swapped):

RequireDirOnCustomUpgrade : WIX_UPGRADE_DETECTED AND (OPENDJ ~= OPENDJ_DEFAULT) AND NOT OPENDJ_REG AND NOT (OPENDJ_GIVEN AND (OPENDJ ~<< OPENDJ_GIVEN))
RefuseRelocatingUpgrade : WIX_UPGRADE_DETECTED AND ((OPENDJ_REG AND (OPENDJ_REG ~<> OPENDJ)) OR (NOT OPENDJ_REG AND OPENDJ_LEGACY_INSTALL AND (OPENDJ_LEGACY_DEFAULT ~<> OPENDJ) AND NOT (OPENDJ_GIVEN AND (OPENDJ ~<< OPENDJ_GIVEN) AND (OPENDJ_GIVEN_INSTALL ~>< OPENDJ))))
CheckServerNotRunning: 0 with no lock file, 0 unlocked, 1 while held (after 61s)

If you would rather have the assertions read the package itself, that is a follow-up with a properly debugged reader — P/Invoke on msi.dll rather than IDispatch, most likely — and I would want a Windows shell to develop it against rather than the CI queue.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at 6bcada5. The three blockers from the last round are genuinely fixed, and I confirmed the typed catch, the ~<</~<>/~>< operand order, OPENDJ_GIVEN public+Secure, the service.c SCM fixes, and the release.yml/deploy.yml publish paths. Three things should be settled before merge — one of them outside the MSI entirely.

Release drops opendj-msi from the reactor, freezing the MSI POM versions (blocker)

opendj-packages/pom.xml removes <module>opendj-msi</module> from distribution-unix and distribution-mac, and the replacement profile activates only on Windows with WiX present:

<activation>
    <os><family>windows</family></os>
    <file><exists>${env.USERPROFILE}/.dotnet/tools/wix.exe</exists></file>
</activation>

.github/workflows/release.yml runs release:prepare release:perform on ubuntu-latest with no -P, and maven-release-plugin only rewrites ${reactorProjects}. So opendj-packages/opendj-msi/pom.xml and .../opendj-msi-standard/pom.xml — both hard-coding parent 5.2.0-SNAPSHOT — will never be version-bumped again. Every past release (5.1.0/5.1.1/5.1.2) did rewrite them.

Maven does not fail on parent version skew; it silently falls back to the repository copy with no warning, and opendj-packages:5.2.0-SNAPSHOT + opendj-server-legacy:5.2.0-SNAPSHOT are both currently published. After the next release the MSI module pins itself to 5.2.0-SNAPSHOT, unpacks the pre-release server zip, and emits opendj-5.2.0-SNAPSHOT.msi — which release.yml attaches to the 5.2.0 release via target/*.msi. continue-on-error: true hides it.

Suggested fix: keep opendj-msi in the Linux module list and move the WiX/OS activation down onto opendj-msi-standard, so the release plugin still sees both POMs.

A relocating upgrade is unguarded when nothing recorded the old location (major)

opendj-packages/opendj-msi/opendj-msi-standard/resources/msi/package.wxs: RequireDirOnCustomUpgrade triggers on OPENDJ ~= OPENDJ_DEFAULT, and RefuseRelocatingUpgrade's second branch needs OPENDJ_LEGACY_INSTALL. With 5.1.x in a custom directory, no registry entry and no legacy directory (the topology already in .github/workflows/build.yml), a mistyped target slips past both:

msiexec /i opendj.msi OPENDJ=C:\opendj-custmo /qn

Guard 1: resolved dir ≠ x64 default → silent. Guard 2: no OPENDJ_REG, no OPENDJ_LEGACY_INSTALL → silent. Exit 0, RemoveExistingProducts strips the program files from C:\opendj-custom, the new tree lands at the typo path, and config/db/logs are stranded with no server and no upgrade.bat. The same typo is refused when an unrelated tree happens to sit in the legacy default, so today the protection depends on an incidental leftover directory.

Related: OPENDJ_GIVEN is a prefix test, so OPENDJ_GIVEN=C disarms guard 1 outright — the comment's "a value that does pass has spelled out the directory the install resolved to" holds for 1 but not for C, C: or C:\. Low on its own (it reaches nothing the documented OPENDJ= route doesn't), but it falls out of the same fix.

Giving guard 1 the evidence test guard 2 already uses closes both, with guard 2 and the SetProperty untouched:

<Custom Action="RequireDirOnCustomUpgrade" After="CostFinalize"
        Condition="WIX_UPGRADE_DETECTED AND NOT OPENDJ_REG AND NOT OPENDJ_LEGACY_INSTALL AND NOT ((OPENDJ_GIVEN_INSTALL ~&gt;&lt; OPENDJ) AND ((OPENDJ ~&lt;&gt; OPENDJ_DEFAULT) OR (OPENDJ_GIVEN AND (OPENDJ ~&lt;&lt; OPENDJ_GIVEN))))"/>

Read as: during an upgrade where nothing recorded a location and no server sits in the legacy default, refuse unless the target holds a server and was actively chosen. NOT OPENDJ_LEGACY_INSTALL is load-bearing — without it the headline upgrade scenario and the two Return value 3 scenarios refuse. I walked this against all 43 msiexec calls in build.yml; none change outcome. It also closes the wizard form (browse to an empty directory over a registry-less custom install).

opendj_service.exe does not match service.c (major)

The committed binary was last refreshed in 252d5ba9b8; opendj-server-legacy/src/build-tools/windows/service.c has changed in six commits since. It still contains a function the PR itself reverted away:

$ git show HEAD:opendj-server-legacy/lib/opendj_service.exe | strings | grep isMsiManagedService
isMsiManagedService: no HKLM\SOFTWARE\OpenDJ key, treating '%s' as orphaned.
Refusing to remove the MSI-managed service '%s'.
$ git grep isMsiManagedService -- opendj-server-legacy/src
(no match)

and lacks the new string from serviceState() ("no service list"), while its sibling "Service '%s' is enabled." is present.

The functional delta is small — the openScm NULL check and the SERVICE_LIST_UNAVAILABLE → 2 mapping, both diagnostic-only; removeService() returning 3 already ships. And deploy.yml's new commit-back supersedes the bytes on the first post-merge push. But that step has never run, all its failure paths are continue-on-error, and a Release dispatch before the first Package/Deploy would ship the stale binary to Maven Central (it copies lib/*.exe verbatim into the zip, deb, rpm and Docker image). One more refresh commit, as the branch has already done seven times.

Worth noting the stale exe is an intermediate branch state carrying two behaviours this PR deliberately reverted, so it is not equivalent to any reviewed source.

Nits

  • build.yml OPENDJ_GIVEN=1 scenario asserts only a non-zero exit: it is the only one of eight refusal scenarios with no message assertion, and -eq 0 also accepts 3010 — the fail-open outcome the neighbouring scenarios use -ne 1603 to exclude. The guard it expects already has a grep three lines above: "location could not be determined".
  • chap-upgrade.adoc over-promises: "The installer further refuses to install into a directory other than the detected one" is false in the case above, and contradicts the NOTE ten lines below telling the reader to name a directory. Worth scoping once the guard is fixed.
  • Stale comments in package.wxs: "build.yml asserts that in the built MSI" and "extracted from the built MSI" — since 6bcada5 the step reads package.wxs itself, so nothing inspects the built package. Also "all 42 msiexec calls" — there are 43 (that count was off when written).
  • Sequence="first" is untested: all 43 msiexec calls are /qn, so reverting it to both is invisible to CI even though it is what keeps the OPENDJ_GIVEN prefix test from being a tautology in a UI install. One regex in the step that already reads the .wxs would pin it.
  • Maintenance-mode in-use detection (pre-existing, follow-up): CheckServerNotRunning is gated on WIX_UPGRADE_DETECTED, and FindRelatedProducts does not run in maintenance mode, so repair/reinstall/uninstall get no in-use check. Not a regression — master had no guards and RM could not shut down the detached JVM either — and MSIRESTARTMANAGERCONTROL=Disable fixes a reproduced failure, so it should stay. If widened later, note that a bare OR Installed would make a running server un-uninstallable; it needs to exclude REMOVE="ALL".

maven-release-plugin rewrites the versions of ${reactorProjects}, and
release.yml runs release:prepare on ubuntu-latest with no -P. Gating
opendj-msi on Windows plus an installed wix.exe therefore froze both MSI poms
at whatever parent version they happened to carry. Maven does not fail on that
skew - it resolves the parent from the repository instead - so the module would
have kept building as the previous version: unpacking that line's published
snapshot rather than the server zip built beside it, naming the package after
it, and handing parse-version the numbers ProductVersion is built from, which
is what FindRelatedProducts keys the upgrade on. The first release would have
attached opendj-<version>-SNAPSHOT.msi built from pre-release bits, and the
next development cycle would have pointed the MSI tests at a stale published
zip instead of at the branch under test.

What cannot run outside Windows is the wix invocation, not the module, so that
is what the profile gates now: opendj-msi is back in the unix, mac and windows
module lists, and distribution-windows-msi moved into opendj-msi-standard
around its <build>. Inactive, the module is a pom that builds nothing - the
same "no MSI artifact, no 'Cannot run program wix'" behaviour as before.
maven.deploy.skip keeps it out of Maven Central, which reactor membership
would otherwise put it back into.

Verified with release:prepare -DdryRun=true: both MSI poms are rewritten to
the release version, and the set of rewritten poms matches the active reactor.
RequireDirOnCustomUpgrade only asked whether the resolved directory was the x64
default, so with nothing in the registry and no server in the legacy default a
mistyped target - msiexec /i opendj.msi OPENDJ=C:\opendj-custmo over a server in
C:\opendj-custom - passed it, and RefuseRelocatingUpgrade has nothing to compare
against in that topology either. RemoveExistingProducts emptied one directory and
InstallFiles populated another one letter away; the only thing that had ever
refused it was an unrelated tree happening to sit in the legacy default. Key the
guard on the registry-less, legacy-less case as a whole and let it through on
directory evidence instead: the target holds a setup.bat AND either it is not the
fallback default or it was named. NOT OPENDJ_LEGACY_INSTALL keeps the headline
legacy-default upgrade out of it, which guard (2) watches for relocation.

That also settles OPENDJ_GIVEN: with the evidence a conjunct rather than an
alternative, a hand-passed prefix as short as "C" - which matches every resolved
path - disarms nothing, because the directory still has to hold a server. And it
covers the wizard for the first time: browsing to the real installation carries
its own evidence, browsing to an empty directory is refused like the typo.

CI gets the typo scenario on the topology the neighbouring one already sets up.
The OPENDJ_GIVEN=1 scenario now asserts 1603 and the guard's own message rather
than a non-zero exit code, and the step that reads the authoring pins
Sequence="first" - without it OPENDJ_GIVEN is re-set after CostFinalize in a
full-UI install and its prefix test compares a value with itself, which no /qn
scenario can see.

The refusal message and chap-upgrade.adoc now say what the guard actually does,
and three comments claiming build.yml reads the built MSI are corrected to the
authoring it has read since 6bcada5.
@vharseko

Copy link
Copy Markdown
Member Author

All three confirmed and two of them fixed — ad4ba90 (reactor) and c22a4da (guard 1 plus the nits). The exe is deliberately left to the deploy job; reasoning below.

1. Reactor — fixed, and your one-liner needed two additions

Confirmed exactly as described: master carried opendj-msi in distribution-unix:58, distribution-mac:72 and distribution-windows:82; this PR left it only in a profile that cannot activate on the runner release.yml:38 uses, and both MSI poms hard-code 5.2.0-SNAPSHOT.

Keeping opendj-msi in the Linux list is not sufficient on its own, though: opendj-msi-standard/pom.xml declares its own parent (opendj-msi:5.2.0-SNAPSHOT), so it has to be in the reactor too or it stays frozen while its parent moves. So the gate is on neither module list now — it moved down onto the plugins, as a distribution-windows-msi profile inside opendj-msi-standard wrapping its <build>. Same activation as before (Windows AND the wix.exe file test), same -P name for release.yml:228, and inactive it is a pom that builds nothing, which keeps the "no Cannot run program wix on a contributor's machine" property the profile existed for.

Second addition: reactor membership would have put both poms straight back into the Central deploy this PR removed them from, so maven.deploy.skip=true in opendj-msi/pom.xml, inherited by the standard module.

Verified with release:prepare -DdryRun=true:

opendj-packages/opendj-msi/pom.xml.tag                     → <version>9.9.9</version>
opendj-packages/opendj-msi/opendj-msi-standard/pom.xml.tag → <version>9.9.9</version>

The same run also demonstrates the mechanism you named: the poms that got a .tag are exactly the active reactor — on macOS opendj-docker, opendj-svr4 and both MSI modules, but not opendj-deb/opendj-rpm, which sit in distribution-unix and its <name>Linux</name>.

Two details worth adding to the record, because they make the blocker worse than "the MSI is misnamed":

  • Nothing would have failed. release.yml:219-221 installs the release zip as opendj-server-legacy:${releaseVersion}, but the module asks for ${project.version} (opendj-packages/pom.xml:121), which the frozen parent pins to the old snapshot — and the root pom declares central-portal-snapshots with snapshots enabled (pom.xml:129-137), so that zip resolves over the network. The carefully installed release zip is simply never requested; continue-on-error does not even come into it.
  • The daily build breaks next, not the release. After 5.2.0 the tree moves to 5.2.1-SNAPSHOT while the MSI module stays at 5.2.0-SNAPSHOT, so unpack-archive starts downloading the previous line's published zip instead of taking the one built beside it in the same reactor. test-msi and test-msi-upgrade would have gone on passing against bits that have nothing to do with the branch under test — including the freshly compiled opendj_service.exe. That is what tipped the decision against fixing this inside release.yml (a versions:set there would have covered the release path only).

2. Guard 1 — your condition, taken as written

Condition="WIX_UPGRADE_DETECTED AND NOT OPENDJ_REG AND NOT OPENDJ_LEGACY_INSTALL AND NOT ((OPENDJ_GIVEN_INSTALL ~&gt;&lt; OPENDJ) AND ((OPENDJ ~&lt;&gt; OPENDJ_DEFAULT) OR (OPENDJ_GIVEN AND (OPENDJ ~&lt;&lt; OPENDJ_GIVEN))))"

I walked it against every scenario in build.yml before taking it and agree with your reading: nothing changes outcome, NOT OPENDJ_LEGACY_INSTALL is what keeps the headline upgrade and both Return value 3 scenarios out of it, and the wizard form closes as a side effect — which also settles the "browse to the real directory is still refused" residual I documented last round, so that comment is gone rather than reworded.

You are right about OPENDJ_GIVEN=C too: a one-character prefix matches every resolved path, and my comment claiming a passing value "has spelled out the directory" was wrong for C, C: and C:\. With the evidence test a conjunct rather than an alternative it stops mattering — the directory still has to hold a server.

CI gets the typo case on the topology the neighbouring scenario already builds:

msiexec /i opendj.msi OPENDJ=C:\opendj-custmo   # over a server in C:\opendj-custom

asserted at exactly 1603, with the guard's message in the log, no C:\opendj-custmo created and C:\opendj-custom\setup.bat intact.

The refusal message now says the directory holds no OpenDJ server and suggests checking for a typo, and chap-upgrade.adoc no longer promises a refusal it did not make — both sentences you flagged are rescoped rather than deleted.

3. opendj_service.exe — deliberately left to Package/Deploy

Confirmed in the bytes: the committed binary carries isMsiManagedService: … and Refusing to remove the MSI-managed service '%s' — a function b2de060 reverted — and lacks no service list; service.c has moved in six commits since 252d5ba9b8. It is an intermediate branch state, as you say, not a state that was ever reviewed.

We are still going to let deploy.yml commit it back on the first push after merge rather than adding an eighth refresh commit, on the grounds that nothing CI judges uses the committed binary: build.yml:79-86 runs nmake all and xcopy /Y *.exe ..\..\..\lib\ before Maven on every Windows leg, so the zip and the MSI in windows-latest-11 — the artifact both MSI jobs download — are built from current sources. What is stale is the Linux-built zip and everything downstream of it, which is exactly what the commit-back exists to fix.

Your residual stands and we are taking it as a process constraint rather than arguing it away: that step has never executed and every failure path in it is a warning, so no Release gets dispatched until the refresh commit is visible on master.

Nits

  • OPENDJ_GIVEN=1 now asserts 1603 and greps location could not be determined, like its neighbours.
  • The three stale comments are corrected to say the authoring rather than the built MSI. The msiexec count is gone rather than updated — it was 43 when you counted, is 44 with the typo scenario, and was never going to survive another round.
  • Sequence="first" is pinned in the step that already reads the .wxs, with the reason in a comment: without it the property is re-set after CostFinalize in a full-UI install and the prefix test compares a value with itself.
  • Maintenance-mode in-use detection: left alone, and the REMOVE="ALL" trap noted for whoever widens it.

@vharseko
vharseko requested a review from maximthomas August 13, 2026 15:57

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Round 11 items 1 and 2 look right — ad4ba90 keeps both MSI poms versioned, and c22a4da closes the mistyped/relocating upgrade hole (I re-derived the guard state space from scratch; it holds, including the OPENDJ_GIVEN=1/=C bypasses, both decoy topologies, and the documented workaround).

One major in the new reactor fix: the mechanism that is supposed to keep the modules off Maven Central does nothing.

Maven Central guard is inert (major)

opendj-packages/opendj-msi/pom.xml:43 relies on maven.deploy.skip, but that property is dead in this build. pom.xml:336-345 declares central-publishing-maven-plugin:0.8.0 with <extensions>true</extensions>, whose lifecycle participant unconditionally wipes the deploy plugin's executions:

private void maybeSkipMavenDeployPlugin(Model, Plugin);
   7: ifnull        24          // only condition: plugin absent
  14: invokeinterface List.clear:()V
  21: invokespecial setUpCentralPublishingExecution   // publish @ deploy

maven.deploy.skip gated only maven-deploy-plugin:deploy, which no longer has a binding — the string does not occur anywhere in the plugin jar. Reproduced at head: mvn deploy -pl :opendj-msi-standard never runs deploy:deploy, and central-publishing:publish is still reached.

Effect on a 5.2.0 release — opendj-msi-standard is now an unconditional module and the wine install is gone, so Central gets:

opendj-msi/5.2.0/opendj-msi-5.2.0.pom
opendj-msi-standard/5.2.0/opendj-msi-standard-5.2.0.pom

and no .msi. So the PR's stated outcome — "the Maven coordinate stays at its last published version (5.1.2)" — is not what happens. opendj-msi-standard-5.1.2.msi resolves today; a consumer moving to 5.2.0:msi gets a resolvable pom and a hard Could not find artifact ...:msi, rather than the clean "version does not exist" the PR intends.

Fix: excludeArtifacts on the existing root plugin config. It is matched against the bare artifactId (excludeArtifacts.contains(artifact.getArtifactId())), so groupId:artifactId would silently never match:

<excludeArtifacts>
    <excludeArtifact>opendj-msi</excludeArtifact>
    <excludeArtifact>opendj-msi-standard</excludeArtifact>
</excludeArtifacts>

Verified: this yields [INFO] No files to stage! and no bundle, with the rest of the reactor still published. Bumping the plugin to >= 0.9.0 (where skipPublishing becomes per-module) also works. Please do not use skipPublishing at 0.8.0 — it is session-global there: in a non-last module it is a no-op, and in the last module it skips the entire release with BUILD SUCCESS.

Uninstall still reports "already disabled" when the SCM is unreadable (minor)

opendj-server-legacy/src/build-tools/windows/service.c:2553 now returns 3 when the service list cannot be read, which fixes the no-service path. The path where a service is found still fails open: removeServiceWithServiceName calls serviceNameInUse(), whose own failure branch (service.c:1990) returns SERVICE_RETURN_ERROR, and everything that is not SERVICE_IN_USE maps to exit 1 / "Service does not exist" — which ConfigureWindowsService.disableService reads as SERVICE_ALREADY_DISABLED.

If the SCM becomes unreadable between the getServiceName enumeration and the serviceNameInUse enumeration, --disableService claims the service was already disabled and uninstall proceeds, leaving a registered auto-start service pointing at a removed tree — the same lie service.c:2499 set out to remove.

Launcher refresh rebases onto unvalidated commits (minor)

.github/workflows/deploy.yml:140 runs git rebase FETCH_HEAD before the Maven publish at .github/workflows/deploy.yml:196 and before the artifact uploads. If the branch moved after checkout, the tree fast-forwards onto commits outside workflow_run.head_sha that the triggering Build never validated, and those bits are what get deployed. Two pushes landing close together are enough. Either move the refresh after the Maven steps, or reset the worktree back to HEAD_SHA for the build.

Nits

  • Stale package.wxs comment: opendj-packages/opendj-msi/opendj-msi-standard/resources/msi/package.wxs:125 still says additional instances fall back to WiX3-era delete-on-reboot. CheckServerNotRunning is not ImagePath-gated (package.wxs:455-459) and fires on WIX_UPGRADE_DETECTED alone, so a running OpenDJ Server-2 holds locks\server.lock and the upgrade is refused with 1603 instead. Same for a ko/zh_TW service registered as OpenDS. The residual is only that the stop is not attempted — a refusal, not damage.
  • Stale -P justification: .github/workflows/release.yml:225 says -P guards against "Could not find the selected project in the reactor". Since the profile moved into opendj-msi-standard's <build>, that failure can no longer occur. What -P now buys is forcing the plugins on when wix.exe is not under %USERPROFILE%\.dotnet\tools; without it the job builds a pom that produces nothing and fail_on_unmatched_files is the only symptom.
  • Unrelated plugin bump: dropping <version>1.3.2</version> from the mib-generation execution at opendj-server-legacy/pom.xml:1406 moves it onto the managed exec-maven-plugin 3.6.3. That execution lives in the snmp profile, which auto-activates whenever ${opendmk.lib.dir}/jdmkrt.jar exists — i.e. on nearly every build on every platform, not just the MSI leg. Worth its own commit or a line in the description.

maven.deploy.skip in opendj-msi gated maven-deploy-plugin:deploy, which this
build no longer runs at all: central-publishing-maven-plugin is declared with
extensions=true, and its lifecycle participant clears the deploy plugin's
executions unconditionally and binds its own publish goal in their place. The
property is not read anywhere in that plugin, so both MSI poms would have gone
to Central on the next release - and with the wine build gone they carry no
.msi on Linux, leaving a resolvable 5.2.0 pom whose msi artifact does not
exist. That is worse for a consumer than the "version was never published"
this PR intends.

excludeArtifacts on the plugin's root configuration is what this build has:
matched against the bare artifactId, so a groupId:artifactId entry would
silently never match. skipPublishing is not usable at 0.8.0 - it is
session-global there, a no-op in a non-last module and a skip of the entire
release in the last one.

help:effective-pom on both MSI modules confirms both halves: maven-deploy-plugin
is left with no executions, and the exclusions are inherited into the injected
central-publishing execution.
removeService() stopped claiming "the service does not exist" when its own
enumeration fails, but removeServiceWithServiceName() still did: everything
that is not SERVICE_IN_USE became exit code 1, and serviceNameInUse() returns
SERVICE_RETURN_ERROR when getServiceList() fails. ConfigureWindowsService reads
exit 1 as SERVICE_ALREADY_DISABLED, so an SCM that becomes unreadable between
the getServiceName enumeration and this one let --disableService report the
service as already gone and the uninstall proceed, leaving a registered
auto-start service pointing at a removed tree.

Only SERVICE_NOT_IN_USE is "does not exist" now; an undetermined answer is
exit 3 (SERVICE_DISABLE_ERROR), which the quicksetup uninstaller turns into a
failure instead of a silent success. The 'cleanup' subcommand shares the
function and is covered too. The MSI registers no service of its own and never
calls this binary, so an uninstall cannot be blocked through it.
The refresh rebases onto the current branch tip before the Maven deploy and the
artifact uploads, so a branch that moved after checkout would have its newer,
never-validated commits built and published by this job. Remember the refresh
commit, push from the rebased HEAD as before, then come back to HEAD_SHA plus
the launchers. Guarded like the rest of the step: a failed restore builds the
tip - what this job did before the refresh existed - rather than skipping the
deploy.
package.wxs: a running additional instance ("OpenDJ Server-2", or a ko/zh_TW
registration under "OpenDS") is not stopped by the ImagePath-gated pair, but it
is not waved through either - CheckServerNotRunning is not ImagePath-gated and
refuses on the held locks\server.lock with 1603. Nothing falls back to the
WiX3-era delete-on-reboot any more.

release.yml: with the profile inside opendj-msi-standard's <build> and the
module in every reactor, "Could not find the selected project in the reactor"
can no longer happen. What -P buys now is forcing the plugins on when wix.exe
is not where the file activation looks.
@vharseko

Copy link
Copy Markdown
Member Author

Thanks — all four points reproduced. Addressed in 6e220bd, bc66bbd, 5c7cfdd and c714f41 on top of c22a4da.

Maven Central guard (major)

Confirmed, and the mechanism reproduces on this repository rather than only in the plugin jar: mvn help:effective-pom for both MSI modules shows maven-deploy-plugin with no executions left at all, so maven.deploy.skip was gating a goal that has no binding here. Replaced with excludeArtifacts on the root central-publishing-maven-plugin configuration, with the two bare artifactIds as you noted; the same effective-pom run shows them inherited into the injected injected-central-publishing execution (phase=deploy, goal=publish), which is the config PublishMojo reads. skipPublishing left alone at 0.8.0 for the reason you gave.

I did not run mvn deploy locally to see No files to stage!: with real OSSRH credentials in settings.xml, an error in the change publishes for real. It will be visible in the first release run.

The PR description said maven.deploy.skip too — corrected there.

--disableService still fails open (minor)

Fixed. Only SERVICE_NOT_IN_USE is exit 1 now; any other non-SERVICE_IN_USE answer from serviceNameInUse() is exit 3, which ConfigureWindowsService maps to SERVICE_DISABLE_ERROR and InstallerHelper.disableWindowsService() turns into an ApplicationException — the uninstall stops instead of proceeding. The fix sits in removeServiceWithServiceName() rather than at the call site, so the cleanup subcommand behind --cleanupService is covered by the same change; it had the identical fail-open. Nothing can be made un-uninstallable through this: the MSI registers no service of its own and never invokes the binary.

Launcher refresh rebases onto unvalidated commits (minor)

Addressed, with one qualification: actions/checkout takes workflow_run.head_branch by name, on master as well as here, so this job has always built whatever the tip was at checkout time — the rebase widened the window rather than opening it. Moving the refresh after the Maven steps would cost the property that the published snapshot carries the fresh launchers, so instead the step records the refresh commit before the loop and returns to it after the push: the Maven steps build HEAD_SHA plus the launchers. The restore is warning-guarded like everything else in that block — a failed restore builds the tip, which is what the job did before the refresh existed, rather than skipping the deploy.

Nits

  • package.wxs — comment rewritten: an additional instance (OpenDJ Server-2, or a ko/zh_TW registration under OpenDS) is not stopped by the ImagePath-gated pair, but CheckServerNotRunning is not ImagePath-gated and refuses on the held locks\server.lock with 1603. Nothing falls back to WiX3-era delete-on-reboot any more; the residual is only that the stop is not attempted.
  • release.yml-P justification rewritten to what it now buys: forcing the plugins on when wix.exe is not under %USERPROFILE%\.dotnet\tools, with fail_on_unmatched_files as the only symptom otherwise.
  • exec-maven-plugin — now called out in the PR description, including that the snmp profile auto-activates on ${opendmk.lib.dir}/jdmkrt.jar and so the bump reaches ordinary builds on every platform. It cannot become its own commit at this point without rewriting the branch.

Round 11, item 3 — the stale opendj_service.exe

Deliberately not hand-refreshed, and the PR description now states the real situation instead of the outdated "one fix behind" note: the committed binary still carries the reverted isMsiManagedService check and lacks the SCM-failure reporting, so it matches no reviewed state of the sources. Package/Deploy commits the windows-exe-11 bytes back on the first push after merge, and the Windows leg compiles the launchers before packaging, so the MSI and the Windows-built zip are unaffected. The residual you named stands: a Release dispatched between the merge and that first Package/Deploy run would ship the stale binary in the Linux-built zip, deb, rpm and Docker image. Say the word if you would rather have an eighth refresh commit in the tree instead of relying on that ordering.

@vharseko
vharseko requested a review from maximthomas August 18, 2026 08:55

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed c22a4da..c714f41. The excludeArtifacts guard is correct — confirmed with a live mvn deploy -DskipPublishing=true on an exported tree (target/central-deferred empty as authored, both MSI poms staged with the exclusion removed), which also proves the pom's own point that skipPublishing reads ${skipPublishing}, never ${maven.deploy.skip}. The service.c exit-code split is correct in source, and the docs, message bundles and CI need no companion change. Four things below.

Committed opendj_service.exe is a different build from the source (major)

opendj-server-legacy/lib/opendj_service.exe at c714f41 is a build of opendj-server-legacy/src/build-tools/windows/service.c @ 0b19faf, not of the source in this PR. Pinned by extracting every string literal from both revisions and matching against the blob, in ASCII and UTF-16LE by raw byte count — not from the diff, and not an encoding artifact:

committed .exe service.c @ c714f41
Refusing to remove the MSI-managed service '%s'. present absent (isMsiManagedService reverted by b2de060)
Could not determine whether the service exists. absent present (:2510)
Could not determine the state of the service… absent present (:2464)

Five behavioural deltas ship as a result, in both directions:

shipped .exe source
serviceState, SCM unreadable 1 (disabled) 2 (:2464)
remove, SCM unreadable 1 (already disabled) 3 (:2510)
openScm null check if (scm == NULL) if (*scm == NULL) (:150)
command-line match tolerant serviceCmdsMatch exact _stricmp (:1014)
isMsiManagedService 9 occurrences 0

The first two are the fixes bc66bbd and 92b30bd exist to make — the source even labels one in-line as "the lie removeService() stopped telling when its own enumeration fails." That lie is still in the binary users run.

The last one is added by merging: master's exe has zero isMsiManagedService occurrences, this one has six. And it is reachable without any exotic command. getServiceName returns whatever key the SCM already holds, and released OpenDJ ≤ 3.0.0 registered its service under the key literally OpenDJopendj-server-legacy/pom.xml carried <name>OpenDJ</name> until e6c513b (2016-04-06), tag 3.0.0 still does, and PRODUCT_NAME = "${project.name}". The new MSI upgrades those installs in place at [ProgramFilesFolder]OpenDJ\ (OPENDJ_LEGACY_DEFAULT, package.wxs:211) and writes HKLM\SOFTWARE\OpenDJ\InstallDir there, so both halves of the ownership check match. The admin then runs uninstall.bat or windows-service.bat --disableService:

stale exe: isMsiManagedService("OpenDJ") -> TRUE -> exit 4
ConfigureWindowsService.java: no `case 4:` at c714f41 (reverted) -> default: -> SERVICE_DISABLE_ERROR
Uninstaller.java:1368-1379   default: -> throw ApplicationException(WINDOWS_SERVICE_ERROR)
=> uninstall aborts, on a bare "an error occurred" - the explanatory messages were reverted too

Distribution is wider than the refresh comments assume. opendj-server-legacy/src/main/assembly/opendj-archive-component.xml:122-130 copies lib/*.exe verbatim on every platform, and in .github/workflows/release.yml the release-msi job installs the zip built by the ubuntu release-maven job and only repackages — so a released MSI carries this binary too, not just the zip, deb, rpm and Docker image. .github/workflows/deploy.yml self-heals it on the first successful push-build after merge, but every failure path there only warns, and a release cut before that lands ships it.

There is also no pre-merge check any more: .github/workflows/check-native-launchers.yml was added by 0d88657 (round 3) and deleted by 24b1b7e, both inside this PR, and .github/workflows/build.yml says outright that nothing compares the built launchers with the committed ones. Please take the refresh commit rather than rely on the merge → Package/Deploy ordering.

Validated tree is restored only on the push-success path (minor)

.github/workflows/deploy.yml:165 — the restore sits inside the if git push branch, but three other exits are taken after a rebase has already moved the worktree:

  • :146git fetch fails on attempt 2 or 3 (attempt 1 rebased, its push lost the race) → warn, exit 0.
  • :151 — rebase conflicts on attempt 2 or 3 → git rebase --abort returns to the attempt-1 rebase result, not $REFRESHED. Reproduced in a scratch repo: orig-head is recorded per invocation, and the aborted tree still carried the foreign commit.
  • :172 — three push races → loop falls through, warn.

The Maven steps at :200/:206 and every upload then run on that tree. Minor rather than major because the degraded state is exactly what this job did before 5c7cfdd — the checkout at :57-61 takes the branch by name, so the tip was always what got built — and it needs a rare refresh commit plus a race. Still, the commit does not deliver its stated intent on three of its four exits.

One trap covers all of them (verified to fire on both exit 0 and fall-through under bash --noprofile --norc -e -o pipefail):

REFRESHED=$(git rev-parse HEAD)
# Back to the validated tree however this step ends: every exit below can be
# taken after a rebase has already moved the worktree onto the branch tip.
trap 'git checkout --quiet --force --detach "$REFRESHED" || echo "::warning title=Could not restore the validated tree::the build continues on the current $BRANCH tip."' EXIT
for attempt in 1 2 3; do

--force matters: if git rebase --abort itself fails at :149 (masked by || true), a plain git checkout --detach dies with you need to resolve your current index first. That limitation is in the current :165 too. The in-branch copy at :162-167 then goes away.

package.wxs comment claims a guarantee the guard gives conditionally (minor)

opendj-packages/opendj-msi/opendj-msi-standard/resources/msi/package.wxs:128-131 now says an upgrade whose service the ImagePath search missed is "not let through either … the upgrade is refused with 1603. The residual is that the stop is never attempted - a refusal, not damage."

The same file says the opposite 290 lines later, at :420-423 — "anything else means the question could not be asked rather than answered - a read-only handle or a denied open under Program Files - and lets the upgrade proceed." The probe at :447 is catch [System.IO.IOException] { retry } plus a bare catch { exit 0 }, and UnauthorizedAccessException is a sibling of IOException, not a subclass, so an ACL-denied open proceeds into RemoveExistingProducts with MSIRESTARTMANAGERCONTROL=Disable. The repo already documents this exact hazard — .github/scripts/wait-server-stopped.ps1:33-36 deliberately omits the blanket catch: "A blanket catch would also swallow UnauthorizedAccessException … and then report a lock that was never held."

Minor because these four commits change only the comment; the probe is byte-identical to c22a4da. For the elevated console / SCCM / SYSTEM / CI modes the comment is simply right. But it cannot hold together with :353-357 of the same file, which treats the UAC-filtered token as live and expected — and that is the mode where a LocalSystem-owned locks\server.lock denies the invoking user. Either qualify the sentence to match :420-423, or make the guard fail closed:

} catch [System.IO.IOException] { Start-Sleep -Seconds 2 }
  catch [System.UnauthorizedAccessException] { exit 1 }
  catch { exit 0 }

removeService() still reports one hard error as "marked for deletion" (minor)

opendj-server-legacy/src/build-tools/windows/service.c:2575-2577bc66bbd fixed the SERVICE_LIST_UNAVAILABLE arm but left its sibling:

  else
  {
    returnCode = 2;      // createServiceBinPath() failed
  }

The function's own contract three lines above the fix (:2540-2544) reads "Returns 2 if the service was marked for deletion but is still in use / Returns 3 if an error occurred". createServiceBinPath fails when GetModuleFileName truncates at MAX_PATH (:943-948) or when "<exe>" start "<instanceDir>" exceeds COMMAND_SIZE (:951-965) — hard errors, so both belong on 3. With an install deep enough that <root>\lib\opendj_service.exe reaches 260 characters, --disableService reports WARN_WINDOWS_SERVICE_MARKED_FOR_DELETION — "The server has been marked for deletion as a Windows Service", which is false — and InstallerHelper.disableWindowsService() only throws on SERVICE_DISABLE_ERROR, so it passes. The new comment at :2460-2462 scopes the residual to "an uninstall that hits an unreadable SCM"; this reaches the same end with a perfectly readable one.

Related, same theme: the two-way split the new comments assert ("could not be read at all" vs "read and held no match") is not exhaustive. getServiceList returns SERVICE_RETURN_OK while leaving cmdToRun = NULL for any service whose config could not be read, and getServiceName silently skips those entries (:1012), so a service that exists but whose config read is denied lands on "does not exist".

Nits

  • deploy.yml:131-136 contradicts itself: it says the restore returns to "HEAD_SHA plus the launchers", then the parenthetical concedes the checkout at :57-61 takes the branch by name — so $REFRESHED is tip-at-checkout plus the launchers, and is not the tree the triggering Build validated. Make the first sentence say "the tree as checked out, plus the launchers". 5c7cfdd's commit message repeats the phrase.
  • deploy.yml:142 undercounts: "all eight artifact uploads" — the file has ten actions/upload-artifact steps, nine excluding the MSI attachment it counts separately. From 90c4271, earlier in this PR, but it is the justification for the git fetch guard three lines below.
  • release.yml:225-230 inverts a containment: "the profile now lives in opendj-msi-standard's <build>" — the profile lives in <profiles> and contains the <build>. The substantive claim holds: -pl :opendj-msi-standard resolves without -P (verified on Linux, BUILD SUCCESS, ${parsedVersion.*} resolved without -am).
  • c714f41's commit message overstates: "Nothing falls back to the WiX3-era delete-on-reboot any more", while package.wxs:393-394 still says what remains uncovered is some other process holding payload files — the WiX3-era behaviour.

@vharseko

Copy link
Copy Markdown
Member Author

1. The stale opendj_service.exe — rebuilt and replaced after the merge, by deploy.yml

Your evidence reproduces exactly. git diff 252d5ba..c714f41 -- opendj-server-legacy/lib/ is empty and 252d5ba's parent is 0b19faf, so the committed binary is the round-5 build: isMsiManagedService ×6, and neither of the two new debug() strings. 24b1b7e, despite its subject line, committed no binary at all — it only moved the refresh from check-native-launchers.yml into Package/Deploy.

That is where it gets fixed, and it is deliberate rather than an omission:

  • This branch cannot produce the binary. deploy.yml:44 gates the job on event == 'push' && head_repository.full_name == github.repository — the trust boundary 10c99b9 states in that condition. A fork PR branch is outside it by design, and there is no Windows toolchain on my side: the binary exists only as a CI artifact.
  • The merge produces it. Build runs on master, Package/Deploy downloads windows-exe-11 from that very run (deploy.yml:83) and commits it (:91). That step sits before Build package local and Publish to the Maven Central Repository (:194, :201), and the refresh commit is made locally before the push loop — so the zip, deb, rpm and the Maven Central artifacts this job publishes carry the rebuilt launchers on every exit of that loop, not only the push-success one. The MSI is the exception: :240 downloads windows-build from the triggering run, so that one snapshot MSI still carries the committed binary and picks the refresh up on the next push.
  • The artifact for this head is already correct. Build run 32118317122 (c714f41, success): its opendj_service.exe carries both Could not determine whether the service exists and Could not determine the state of the service, and zero isMsiManagedService. launcher_administrator.exe and winlauncher.exe are sha256-identical to the committed ones, so the post-merge refresh is a one-file commit — /Brepro holding exactly as deploy.yml:77-80 claims.
  • Committing that artifact now would be stale again immediately. Your fourth point changes the same source (removeService()'s 23), so the binary has to be rebuilt after that fix in any case; the refresh has to be the last thing that happens, and the only place it can happen is after the merge.

Two residuals I accept rather than dispute:

  • master carries the round-5 binary between the merge commit and the refresh commit — one Build cycle.
  • A release cut inside that window would ship it, and so would the one snapshot MSI above. release.yml is workflow_dispatch, so that ordering is mine to hold: no release before the refresh commit is on master, and if the artifact download warns instead of landing, Package/Deploy gets re-run rather than released on top of.

…moval exit codes

deploy.yml: the tree the triggering Build validated was restored only on the
push-success path, while three other exits - a failed fetch, a rebase conflict on
attempt 2 or 3, and three lost push races - are taken after a rebase has already
moved the worktree ("git rebase --abort" returns to the state the current rebase
started from, which on attempt 2 is the previous attempt's result). One EXIT trap
covers all four, with --force so a failed abort cannot leave the Maven steps
mid-rebase. Verified in scratch repositories on each of the four exits. Two
comments go with it: $REFRESHED is the tree as checked out plus the launchers,
not HEAD_SHA plus the launchers, and the file has nine artifact uploads besides
the MSI attachment, not eight.

package.wxs: the ImagePath comment claimed an upgrade whose service the search
misses is refused anyway, a guarantee the probe 290 lines below only delivers
when it can open locks\server.lock - UnauthorizedAccessException is a sibling of
IOException, not a subclass, so a denied open reaches the bare catch and the
upgrade proceeds. The open now asks for FileAccess.Read rather than ReadWrite:
LockFile needs only GENERIC_READ, while a read-write open of a file under
Program Files is denied to a standard user outright, so the probe took the
fail-open path on every UAC double-click upgrade of a default-directory install,
running server or not. Read is what the default Program Files ACL grants Users,
so the lock is testable in that mode as well now, and both comments state what
is left: a tree whose ACL denies reading the lock at all.

service.c: removeService() reported a createServiceBinPath() failure as exit 2,
"marked for deletion but still in use", which InstallerHelper
.disableWindowsService() lets through as a warning - so an install path long
enough to truncate GetModuleFileName() at MAX_PATH, or a service command line
over COMMAND_SIZE, claimed a deletion that never happened. It is 3, "an error
occurred", which is what this function's contract three lines above already
assigns to it.

The two-way split the round-11 comments assert - "could not be read at all"
against "read and held no match" - was not exhaustive either: getServiceList()
returns OK while leaving cmdToRun NULL for an entry whose configuration it could
not read, and getServiceName() skips those, so a service that exists but is
unreadable landed on "does not exist". Those entries are counted now, and a
sweep that matched nothing while skipping some returns the new
SERVICE_LIST_PARTIAL. Only removeService() treats it as an error; serviceState()
keeps answering "disabled", because its three java callers only ever test for
SERVICE_STATE_ENABLED and --serviceState would otherwise print an error on every
box where an unrelated service denies QueryServiceConfig to the caller.

release.yml: the distribution-windows-msi profile contains opendj-msi-standard's
<build>; it does not live inside it.

Not corrected in place, being a pushed commit message: c714f41 says "Nothing
falls back to the WiX3-era delete-on-reboot any more", while package.wxs states
that what remains uncovered - some other process holding payload files - is that
same behaviour. The claim holds for a server of ours whose lock can be read; it
does not hold in general.

opendj-server-legacy/lib/opendj_service.exe stays as committed: only a Windows
job can rebuild it, and Package/Deploy commits the windows-exe-11 artifact of the
Build run that follows the merge.
@vharseko

Copy link
Copy Markdown
Member Author

Points 2, 3, 4 and the nits are in 290c91e. All four reproduced; one of them turned out to be wider than the report.

2. Validated tree restored only on the push-success path

Fixed as you wrote it — one trap ... EXIT with --force, and the in-branch copy at the old :162-167 removed. Reproduced and then verified in scratch repositories on each of the four exits, under bash -e -o pipefail: rebase conflict (with --abort landing on the previous attempt's result, exactly as you described), git fetch failing on attempt 2 after attempt 1 had already rebased, the fall-through after three lost races, and the push that lands. In all four the worktree comes back on $REFRESHED and the foreign commit's files are gone from it.

3. package.wxs comment against the guard

Qualified the comment, and then found the guard's own comment at :420-423 was understating it too, so this one goes further than a wording change.

UnauthorizedAccessException is not an exotic path here. The probe opened the lock with FileAccess.ReadWrite, and an immediate custom action impersonates the invoking user — who, under [ProgramFilesFolder], does not have write access to anything at all. So for the whole UAC double-click mode the open was denied by the default ACL, not by a hardened one, and CheckServerNotRunning took the bare catch on every such upgrade, running server or not: the guard the top-of-file comment leans on was inert in exactly the mode :353-357 calls expected.

Fail-closed would therefore have refused every double-click upgrade of a default-directory install, since locks\server.lock outlives the JVM that held it. The open asks for Read instead: LockFile needs only GENERIC_READ, and Read is what the default Program Files ACL grants Users, so the lock is now testable in that mode rather than unaskable. The fail-open branch stays for what it was meant to cover — an ACL that denies reading the lock at all — and both comments now say that instead of promising a refusal.

The probe test at build.yml:793-835 covers the change end to end (0 with no file, 0 unlocked, 1 while a second process holds it, not before the 60 s grace); wait-server-stopped.ps1 keeps its read-write open, noted inline, since it runs elevated over a workspace tree it owns.

4. removeService() reporting a hard error as "marked for deletion"

createServiceBinPath()'s failure is exit 3 now, the code the contract three lines up already assigns to it.

The "related, same theme" half is fixed rather than just documented. getServiceList() returns SERVICE_RETURN_OK while leaving cmdToRun = NULL for an entry it could not read, and the command line is the only thing getServiceName() matches on, so those entries are counted now and a sweep that matched nothing while skipping some returns a new SERVICE_LIST_PARTIAL — "read, no match, and the non-match is not proof".

Only removeService() treats it as an error. serviceState() deliberately keeps answering "disabled", and the asymmetry is written down where it is made: all three java callers of ConfigureWindowsService.serviceState() compare against SERVICE_STATE_ENABLED only, so nothing would change for them, while --serviceState would start printing an error on every box where an unrelated service denies QueryServiceConfig to the caller. The uninstaller asks serviceState() before disabling, so the new error reaches a hand-run --disableService/cleanup on an instance whose service really is absent; CI is unaffected either way, since every --disableService there follows an --enableService and so has a match. The MAX_SERVICE_NAME residual is named in the contract rather than folded in: Windows caps a service name at the same 256, so it is unreachable, not tolerated.

Nits

  • deploy.yml:131-136 — now "the tree as checked out, plus the launchers", with the parenthetical saying the checkout takes the branch by name rather than the triggering run's SHA.
  • deploy.yml:142 — nine.
  • release.yml:225-230 — the profile contains opendj-msi-standard's <build>.
  • c714f41's message: a pushed commit message, so the correction is recorded in 290c91e's instead — the claim holds for a server of ours whose lock can be read, not in general, and package.wxs:393-394 stays right about what is left uncovered.

The PR description is updated for all of this: the maven.deploy.skip line that survived in the reactor paragraph, the read-only probe, the wrapper's exit codes, the restore trap, and the one-file scope of the pending launcher refresh.

opendj-server-legacy/lib/opendj_service.exe is still as committed, for the reason in the previous comment — and now also because this commit touches service.c again, so any binary taken before it would have been stale on arrival.

@vharseko
vharseko requested a review from maximthomas August 18, 2026 12:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build CI docs enhancement packaging deb/rpm/MSI, distribution layout, config.ldif security Security fixes / CodeQL code-scanning alerts tests Test suites: fixing, enabling, un-disabling Windows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants