Skip to content

fix(uninstall): edit the Windows user PATH via the registry and broadcast best-effort - #1403

Merged
backnotprop merged 2 commits into
mainfrom
fix/windows-uninstall-path-broadcast
Aug 27, 2026
Merged

fix(uninstall): edit the Windows user PATH via the registry and broadcast best-effort#1403
backnotprop merged 2 commits into
mainfrom
fix/windows-uninstall-path-broadcast

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

The failure

The "Smoke-test uninstall lifecycle" step of the smoke-binaries job failed three times in a row on one Windows run with Could not remove ...\AppData\Local\plannotator from the Windows user PATH. while passing on other runs. That run's cleanup also killed several orphaned msedge processes.

The mechanism

WINDOWS_PATH_SCRIPT removed the entry with [Environment]::SetEnvironmentVariable('Path', $n, 'User'). That .NET call writes HKCU\Environment and then broadcasts WM_SETTINGCHANGE to every top-level window with SendMessageTimeout, synchronously, one window at a time, without SMTO_ABORTIFHUNG. On a runner with hung GUI processes the broadcast can outlast the uninstaller's 15 s command timeout in defaultRunCommand, which kills PowerShell and returns exit 124. The uninstaller then correctly refused to proceed, even though the registry edit had already completed. A real user with one hung window can hit the same stall.

The fix

Both PowerShell one-liners (WINDOWS_PATH_SCRIPT and WINDOWS_PATH_RESTORE_SCRIPT in packages/server/uninstall.ts) now edit the registry directly and broadcast separately:

  • Read the user Path from HKCU\Environment with DoNotExpandEnvironmentNames so %VARS% are never expanded, and capture its value kind (REG_EXPAND_SZ on most systems).
  • Remove exactly the matching entry with the same normalization as before (trim, trailing backslash, case-insensitive), write back through Microsoft.Win32.Registry with the same kind, keep the exit-3 "not present or unchanged" contract, and echo the original value as JSON on stdout for rollback.
  • Then broadcast WM_SETTINGCHANGE best-effort: a P/Invoke SendMessageTimeout with SMTO_ABORTIFHUNG and a 1000 ms per-window timeout, inside try/catch, followed by an explicit exit 0, so nothing about the broadcast can reach the exit code.
  • The restore script writes with the kind the value currently has (the kind the removal preserved), falling back to REG_EXPAND_SZ if the value is gone, and broadcasts the same way.
  • The scripts stay single-quoted; the DllImport attribute's double quotes are assembled from [char]34 at runtime because the script travels as one -Command argv element.

Shared broadcast statements live in WINDOWS_PATH_BROADCAST_STATEMENTS.

What stays fail-closed

  • A genuine registry failure (non-zero, non-3 exit) still pushes an error and preserves the running CLI. The message now distinguishes command timed out from exit N, aligned with the host-command wording used elsewhere in the file.
  • A timeout with nothing on stdout is still an error: the edit is unproven, so the CLI is kept.
  • Because the script writes the rollback echo only after a successful SetValue, a timeout that arrives after a parseable echo is now treated as a completed edit, with a warning that notifying open windows timed out. The rollback value is available in that case, so the self-delete and restore paths behave exactly as after a clean exit 0.
  • A failed restore is still an error and still names the full CLI path.

Two-runtime note

Uninstall is Bun-only (the compiled binary). apps/pi-extension has no mirror of this code: a grep for SetEnvironmentVariable and uninstall finds only a README mention. Nothing in CLAUDE.md or the marketing docs describes the Windows uninstall PATH handling, so no docs changed.

Testing

  • bun run typecheck: clean.
  • bun test packages/server/uninstall.test.ts: 41 pass, 0 fail. New tests cover the exit-3 unchanged path, a timeout before the echo (error, CLI preserved), a timeout after the echo (removed with a warning), the no-double-quote and no-SetEnvironmentVariable invariants with echo-before-broadcast ordering, and a real PowerShell parse of both scripts via Parser.ParseInput (parse only, no execution, no registry access). The parse test runs on the existing uninstall-windows CI job and skips when no PowerShell is found; locally it was run against a portable pwsh 7.4.6 through PLANNOTATOR_TEST_POWERSHELL.
  • bun test packages/server: 758 pass, 1 skip, 7 fail. The 7 are the known sem sidecar sandbox failures (review-workspace semantic diff API and workspace integration, agent-terminal-bridge node sidecar spawn) and are unrelated to this change.
  • The registry itself was not touched anywhere; the real Windows write path is exercised by the existing release smoke, which is the only place it should be.

AI-assisted (Claude) under maintainer direction.

…cast best-effort

[Environment]::SetEnvironmentVariable('Path', ..., 'User') writes HKCU\Environment
and then broadcasts WM_SETTINGCHANGE synchronously to every top-level window. On a
machine with a hung GUI process that broadcast can outlast the uninstaller's 15 s
command timeout, so PowerShell was killed (exit 124) and the uninstaller refused
to remove the CLI even though the PATH edit had completed.

The remove and restore scripts now read the user Path unexpanded from the
registry, preserve its value kind, write it back through Microsoft.Win32.Registry,
and only then broadcast WM_SETTINGCHANGE via SendMessageTimeout with
SMTO_ABORTIFHUNG and a 1 s per-window timeout inside try/catch, exiting 0
explicitly so the broadcast can never affect the exit code. The exit-3
not-present contract and the JSON rollback echo are unchanged.

A genuine registry failure still pushes an error and preserves the CLI; a
timeout is now reported as such. Because the rollback echo is written only after
a successful write, a timeout that arrives after the echo is treated as a
completed edit with a warning instead of a failure.
… process ends

The remove script prints the rollback JSON, and the restore script now prints a
PLANNOTATOR_PATH_RESTORED sentinel, only after the registry write. A native
fault inside Add-Type or SendMessageTimeout cannot be caught by try/catch and
ends the process with an NTSTATUS exit code, so both callers now treat a
parseable echo as proof of the completed edit whether the process timed out or
exited non-zero for another reason, and warn about the broadcast instead of
misreporting a finished edit as a failure (which would also lose the rollback
value). A non-zero exit with no echo still fails closed.
@backnotprop
backnotprop merged commit 469046f into main Aug 27, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant