A build box here started failing last Tuesday, right after a routine winget upgrade. The Java toolchain that had compiled cleanly the night before now spat out “error while loading shared libraries” before it even reached the build file. winget list showed the JDK entry still present. So did where java. The install looked fine on paper. It was not.
If you keep developer tooling on Windows and let winget handle upgrades, you have probably met a version of this. The interesting part is not the breakage itself. It is the exit code you get afterward, which almost never describes what actually went wrong.
What winget is actually doing to an SDK
winget is a package manager built on top of the Windows package manifest system. It does not install a JDK the way the vendor installer does. It reads a manifest, fetches the vendor’s installer, runs it, and records a fingerprint so future upgrades know what it is looking at.
That fingerprint is the weak point. When a new version of an SDK comes out, the manifest points at a new installer URL and a new hash. If the vendor changed how the installer lays down files, silent switches, or uninstaller metadata, winget’s upgrade path has no way to know. It runs the new installer over the old one and trusts the result.
Two things go wrong when that trust is misplaced:
- The old version does not get removed. The upgrade installs alongside it, or partially overwrites it. The registry now has two entries or none. The vendor uninstaller no longer matches what is on disk.
- PATH, JAVA_HOME, or symlinks get rewritten. A silent installer may reset environment entries it does not own, or write a new JDK path that other tools still reference by the old one.
The exit code you see from winget upgrade reflects whichever of these the installer itself reported, not the machine state after. A successful install of a new JDK on top of a half-removed old one returns zero. An uninstaller that fails to find its files returns the vendor’s own code, which winget passes through with no interpretation.
Reading the exit code you actually get
We collected a few codes across the last month of broken upgrades, and they fall into predictable groups.
0 with a broken toolchain
The installer succeeded; the environment did not. This is the most common case. No amount of reinstalling fixes it because winget has nothing left to do. Check JAVA_HOME against the on-disk JDK path, check that where java resolves to the new bin, and inspect the vendor uninstall registry entries for a stale version that the new install did not replace.
3010 or 1641
These are Windows Installer codes for “install succeeded, reboot required” and “install succeeded, reboot initiated”. winget treats them as success at exit and moves on. The classic symptom is a next-session build that cannot find a DLL the installer deferred registering until reboot. Note them and reboot before assuming the toolchain is fine.
1603
This is the catch-all for failed MSI installation. It means the installer gave up, but it does not say why. The real reason lives in the MSI log, not in winget’s output. Re-run the specific install with verbose logging enabled to get an actual diagnosis. Nine times out of ten on SDK upgrades it is a lock on a file that a running IDE or language server still holds.
1638
“Another version of this product is already installed.” This one tells you the upgrade tried to install fresh without removing the existing entry. The fix is to uninstall the previous JDK cleanly through whichever uninstaller the vendor shipped, verify the registry entry is gone, and then re-run the winget install rather than the upgrade.
1605
“This action is only valid for products that are currently installed.” The opposite problem. winget tried to remove the old version, but the uninstaller metadata was missing or mismatched with the on-disk files. This often points to a previous upgrade that left the machine in a split state: files present, uninstaller stub gone. Cleaning up means finding every directory the old JDK touched and removing the vendor registry keys by hand before the next install.
5
Access denied. Usually the installer needs elevation and winget was launched without it, or a policy is blocking the install location. Rare on SDKs but worth ruling out before chasing anything else.
Why this keeps happening with SDKs specifically
Big JDK distributions ship differently. Some use a native installer with an embedded MSI. Some ship a zip that must be unpacked and have environment variables set by a script. Some rely on a launcher that adds itself to PATH and manages versions internally. winget manifests vary just as much depending on how the vendor submitted theirs. An upgrade that works for one source can leave a different source in a broken state because the manifest never accounted for coexistence.
You will not find this in the manifest description. You find it after the fact, when gradle --version reports the old JDK while java -version reports the new one, and the compile fails because the two disagree on which version owns the classpath.
What we do before the next upgrade
The cheap routine that catches most of these:
- Run
winget listand save it to a file. Note the exact package id and version for every SDK you depend on. - Before upgrading, confirm the current version’s install directory and its entry in the vendor’s uninstall registry. If the uninstaller stub is missing, the upgrade will fail and the exit code will lie about it.
- Close anything holding a file in the SDK. IDEs, language servers, and background build daemons lock DLLs.
- After upgrade, re-check
winget list. If the version number did not change, the upgrade did not touch what winget thought it touched. - Reboot after any code from the Windows Installer family. A deferred file operation on an SDK install will not surface until the next session.
The routine is not glamorous. It is faster than the alternative, which is tracing a ClassNotFoundException that turned out to be a second copy of the JDK sitting under a directory nobody remembered installing.
One more thing worth saying. winget is convenient and we use it on every machine. It is not a settlement layer that guarantees the vendor installer and the OS agree about where the SDK lives. Treat it as a fetcher that hands off to the vendor’s binary, and read the exit code as the installer’s own report, not as a diagnosis of what ended up on your disk. When the two disagree, believe the disk.
