Skip to content

Commit 469046f

Browse files
authored
fix(uninstall): edit the Windows user PATH through the registry with a best-effort change broadcast (#1403)
The Windows uninstaller removed its PATH entry through .NET's SetEnvironmentVariable, whose synchronous settings-change broadcast can stall behind a hung window past the 15 second command timeout and make the uninstaller refuse to proceed (seen three times on one CI runner). The edit now goes through the registry directly (reading unexpanded, preserving the value kind), echoes the original value as proof of the write, and broadcasts the change best-effort with an abort-if-hung timeout that never affects the exit code; the restore path gets the same treatment with a sentinel. A completed write is trusted regardless of how PowerShell ended, while an unproven write still fails closed and preserves the CLI. Tests include a real PowerShell parse check of both scripts. AI-assisted (Claude) under maintainer direction.
1 parent 58358cc commit 469046f

2 files changed

Lines changed: 418 additions & 20 deletions

File tree

packages/server/uninstall.test.ts

Lines changed: 312 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
formatPurgeWarning,
2222
runPlannotatorUninstall,
2323
type UninstallEnvironment,
24+
WINDOWS_PATH_RESTORE_SCRIPT,
25+
WINDOWS_PATH_SCRIPT,
2426
WINDOWS_SELF_DELETE_SCRIPT,
2527
} from "./uninstall";
2628

@@ -78,6 +80,95 @@ describe("Windows self-delete worker", () => {
7880
});
7981
});
8082

83+
describe("Windows PATH scripts", () => {
84+
const scripts = {
85+
remove: WINDOWS_PATH_SCRIPT,
86+
restore: WINDOWS_PATH_RESTORE_SCRIPT,
87+
} as const;
88+
89+
test("edit the registry directly and never call the blocking .NET setter", () => {
90+
// SetEnvironmentVariable('Path', ..., 'User') broadcasts WM_SETTINGCHANGE
91+
// synchronously to every window and can stall past the 15 s command
92+
// timeout on a machine with a hung GUI process (the CI smoke flake).
93+
for (const script of Object.values(scripts)) {
94+
expect(script).not.toContain("SetEnvironmentVariable");
95+
expect(script).toContain("Microsoft.Win32.Registry");
96+
// The broadcast that replaces it must be bounded (SMTO_ABORTIFHUNG) and
97+
// must not be able to reach the exit code.
98+
expect(script).toContain("SendMessageTimeout");
99+
expect(script).toMatch(/'Environment',0x2,\d+,\[ref\]\$r\)\}catch\{\}; exit 0$/);
100+
}
101+
// The value is read unexpanded and written back with its own kind so
102+
// %VARS% in unrelated entries survive.
103+
expect(scripts.remove).toContain("DoNotExpandEnvironmentNames");
104+
expect(scripts.remove).toContain("$k.SetValue('Path',$n,$kind)");
105+
// The completed-write echo must come after the write and before the
106+
// broadcast in BOTH scripts: it is what lets the caller trust a write
107+
// whose process was killed or faulted while broadcasting.
108+
const echoes = {
109+
remove: "Write-Output (ConvertTo-Json",
110+
restore: "Write-Output 'PLANNOTATOR_PATH_RESTORED'",
111+
} as const;
112+
for (const [name, script] of Object.entries(scripts) as Array<
113+
[keyof typeof scripts, string]
114+
>) {
115+
const writeIndex = script.indexOf("$k.SetValue(");
116+
const echoIndex = script.indexOf(echoes[name]);
117+
const broadcastIndex = script.indexOf("SendMessageTimeout(");
118+
expect(`${name}: ${writeIndex}`).not.toBe(`${name}: -1`);
119+
expect(echoIndex).toBeGreaterThan(writeIndex);
120+
expect(broadcastIndex).toBeGreaterThan(echoIndex);
121+
}
122+
});
123+
124+
test("stay single-quoted so they survive -Command argv quoting", () => {
125+
// Both scripts travel as one argv element to powershell.exe; a literal
126+
// double quote would be re-escaped by the spawn layer and break parsing.
127+
for (const script of Object.values(scripts)) {
128+
expect(script).not.toContain('"');
129+
}
130+
});
131+
132+
const powershell =
133+
Bun.which("pwsh") ||
134+
Bun.which("pwsh.exe") ||
135+
Bun.which("powershell.exe") ||
136+
process.env.PLANNOTATOR_TEST_POWERSHELL ||
137+
null;
138+
139+
test.skipIf(!powershell)(
140+
"parse cleanly in a real PowerShell (no registry access)",
141+
async () => {
142+
// Parser.ParseInput only parses; nothing is executed, so this touches
143+
// neither the registry nor the environment. Runs on Windows CI and on
144+
// any dev box with pwsh.
145+
for (const [name, script] of Object.entries(scripts)) {
146+
const proc = Bun.spawn(
147+
[
148+
powershell!,
149+
"-NoProfile",
150+
"-NonInteractive",
151+
"-Command",
152+
"$errors=$null; [void][System.Management.Automation.Language.Parser]::ParseInput($env:PLANNOTATOR_TEST_SCRIPT,[ref]$null,[ref]$errors); if($errors.Count -gt 0){$errors | ForEach-Object { Write-Output $_.Message }; exit 1}; exit 0",
153+
],
154+
{
155+
stdin: "ignore",
156+
stdout: "pipe",
157+
stderr: "pipe",
158+
env: { ...process.env, PLANNOTATOR_TEST_SCRIPT: script },
159+
},
160+
);
161+
const [stdout, exitCode] = await Promise.all([
162+
new Response(proc.stdout).text(),
163+
proc.exited,
164+
]);
165+
expect(`${name}: ${stdout.trim()}`).toBe(`${name}: `);
166+
expect(exitCode).toBe(0);
167+
}
168+
},
169+
);
170+
});
171+
81172
function createFixture(
82173
overrides: Partial<UninstallEnvironment> = {},
83174
): Fixture {
@@ -1541,12 +1632,231 @@ describe("host and platform integrations", () => {
15411632

15421633
expect(result.ok).toBe(false);
15431634
expect(result.errors).toContain(
1544-
`Could not remove ${dirname(currentExe)} from the Windows user PATH.`,
1635+
`Could not remove ${dirname(currentExe)} from the Windows user PATH (exit 1).`,
1636+
);
1637+
expect(existsSync(currentExe)).toBe(true);
1638+
expect(fixture.scheduledDeletes).toEqual([]);
1639+
});
1640+
1641+
test("proceeds without a PATH error when the entry is not present (exit 3)", async () => {
1642+
const fixture = createFixture();
1643+
const localAppData = join(fixture.homeDir, "AppData", "Local");
1644+
const currentExe = join(localAppData, "plannotator", "plannotator.exe");
1645+
writeText(currentExe);
1646+
1647+
const result = await runPlannotatorUninstall(
1648+
{ purge: false, dryRun: false },
1649+
{
1650+
...fixture.environment,
1651+
platform: "win32",
1652+
execPath: currentExe,
1653+
env: { LOCALAPPDATA: localAppData },
1654+
which: () => "C:\\Windows\\powershell.exe",
1655+
runCommand: async () => ({ exitCode: 3, timedOut: false }),
1656+
},
1657+
);
1658+
1659+
expect(result.ok).toBe(true);
1660+
expect(result.errors).toEqual([]);
1661+
expect(result.removed).not.toContain(
1662+
`Windows user PATH entry ${dirname(currentExe)}`,
1663+
);
1664+
expect(fixture.scheduledDeletes).toEqual([
1665+
{ target: currentExe, parent: dirname(currentExe) },
1666+
]);
1667+
});
1668+
1669+
test("reports a timed-out PATH edit as a timeout and keeps the CLI", async () => {
1670+
const fixture = createFixture();
1671+
const localAppData = join(fixture.homeDir, "AppData", "Local");
1672+
const currentExe = join(localAppData, "plannotator", "plannotator.exe");
1673+
writeText(currentExe);
1674+
1675+
const result = await runPlannotatorUninstall(
1676+
{ purge: false, dryRun: false },
1677+
{
1678+
...fixture.environment,
1679+
platform: "win32",
1680+
execPath: currentExe,
1681+
env: { LOCALAPPDATA: localAppData },
1682+
which: () => "C:\\Windows\\powershell.exe",
1683+
// Killed before the script echoed anything: the edit is unproven.
1684+
runCommand: async () => ({ exitCode: 124, timedOut: true, stdout: "" }),
1685+
},
1686+
);
1687+
1688+
expect(result.ok).toBe(false);
1689+
expect(result.errors).toContain(
1690+
`Could not remove ${dirname(currentExe)} from the Windows user PATH (command timed out).`,
15451691
);
15461692
expect(existsSync(currentExe)).toBe(true);
15471693
expect(fixture.scheduledDeletes).toEqual([]);
15481694
});
15491695

1696+
test("treats a timeout after the rollback echo as a completed PATH edit", async () => {
1697+
const fixture = createFixture();
1698+
const localAppData = join(fixture.homeDir, "AppData", "Local");
1699+
const currentExe = join(localAppData, "plannotator", "plannotator.exe");
1700+
writeText(currentExe);
1701+
const originalPath = `C:\\Before;${dirname(currentExe)};C:\\After;;`;
1702+
1703+
const result = await runPlannotatorUninstall(
1704+
{ purge: false, dryRun: false },
1705+
{
1706+
...fixture.environment,
1707+
platform: "win32",
1708+
execPath: currentExe,
1709+
env: { LOCALAPPDATA: localAppData },
1710+
which: () => "C:\\Windows\\powershell.exe",
1711+
// The script echoes the original PATH only after the registry write,
1712+
// so an echo followed by a kill means only the broadcast stalled.
1713+
runCommand: async () => ({
1714+
exitCode: 124,
1715+
timedOut: true,
1716+
stdout: `${JSON.stringify(originalPath)}\n`,
1717+
}),
1718+
},
1719+
);
1720+
1721+
const pathLabel = `Windows user PATH entry ${dirname(currentExe)}`;
1722+
expect(result.ok).toBe(true);
1723+
expect(result.removed).toContain(pathLabel);
1724+
expect(result.warnings.some((w) => w.includes("timed out"))).toBe(true);
1725+
expect(fixture.scheduledDeletes).toEqual([
1726+
{ target: currentExe, parent: dirname(currentExe) },
1727+
]);
1728+
});
1729+
1730+
test("treats a non-zero exit after the rollback echo as a completed PATH edit", async () => {
1731+
const fixture = createFixture();
1732+
const localAppData = join(fixture.homeDir, "AppData", "Local");
1733+
const currentExe = join(localAppData, "plannotator", "plannotator.exe");
1734+
writeText(currentExe);
1735+
const originalPath = `C:\\Before;${dirname(currentExe)};C:\\After;;`;
1736+
1737+
const result = await runPlannotatorUninstall(
1738+
{ purge: false, dryRun: false },
1739+
{
1740+
...fixture.environment,
1741+
platform: "win32",
1742+
execPath: currentExe,
1743+
env: { LOCALAPPDATA: localAppData },
1744+
which: () => "C:\\Windows\\powershell.exe",
1745+
// A native fault inside Add-Type / SendMessageTimeout is not
1746+
// catchable and ends the process with an NTSTATUS code; the echo
1747+
// already on stdout still proves the write completed.
1748+
runCommand: async () => ({
1749+
exitCode: -1073741819,
1750+
timedOut: false,
1751+
stdout: `${JSON.stringify(originalPath)}\n`,
1752+
}),
1753+
},
1754+
);
1755+
1756+
const pathLabel = `Windows user PATH entry ${dirname(currentExe)}`;
1757+
expect(result.ok).toBe(true);
1758+
expect(result.errors).toEqual([]);
1759+
expect(result.removed).toContain(pathLabel);
1760+
expect(result.warnings.some((w) => w.includes("exit -1073741819"))).toBe(true);
1761+
expect(fixture.scheduledDeletes).toEqual([
1762+
{ target: currentExe, parent: dirname(currentExe) },
1763+
]);
1764+
});
1765+
1766+
test("treats a restore that printed its sentinel as completed however the process ended", async () => {
1767+
for (const ending of [
1768+
{ exitCode: 124, timedOut: true, needle: "timed out" },
1769+
{ exitCode: -1073741819, timedOut: false, needle: "exit -1073741819" },
1770+
]) {
1771+
const fixture = createFixture();
1772+
const localAppData = join(fixture.homeDir, "AppData", "Local");
1773+
const currentExe = join(localAppData, "plannotator", "plannotator.exe");
1774+
writeText(currentExe);
1775+
let commandCount = 0;
1776+
1777+
const result = await runPlannotatorUninstall(
1778+
{ purge: false, dryRun: false },
1779+
{
1780+
...fixture.environment,
1781+
platform: "win32",
1782+
execPath: currentExe,
1783+
env: { LOCALAPPDATA: localAppData },
1784+
which: () => "C:\\Windows\\powershell.exe",
1785+
runCommand: async () => {
1786+
commandCount += 1;
1787+
if (commandCount === 1) {
1788+
return {
1789+
exitCode: 0,
1790+
timedOut: false,
1791+
stdout: JSON.stringify(
1792+
`C:\\Before;${dirname(currentExe)};C:\\After;;`,
1793+
),
1794+
};
1795+
}
1796+
return {
1797+
exitCode: ending.exitCode,
1798+
timedOut: ending.timedOut,
1799+
stdout: "PLANNOTATOR_PATH_RESTORED\r\n",
1800+
};
1801+
},
1802+
scheduleWindowsSelfDelete: async () => false,
1803+
},
1804+
);
1805+
1806+
const pathLabel = `Windows user PATH entry ${dirname(currentExe)}`;
1807+
expect(result.ok).toBe(false);
1808+
expect(existsSync(currentExe)).toBe(true);
1809+
expect(result.errors.some((e) => e.includes("Could not restore"))).toBe(false);
1810+
expect(result.removed).not.toContain(pathLabel);
1811+
expect(result.preserved).toContain(`${pathLabel} (restored for retry)`);
1812+
expect(
1813+
result.warnings.some(
1814+
(w) => w.startsWith("Restored ") && w.includes(ending.needle),
1815+
),
1816+
).toBe(true);
1817+
}
1818+
});
1819+
1820+
test("reports a restore that never printed its sentinel as failed", async () => {
1821+
const fixture = createFixture();
1822+
const localAppData = join(fixture.homeDir, "AppData", "Local");
1823+
const currentExe = join(localAppData, "plannotator", "plannotator.exe");
1824+
writeText(currentExe);
1825+
let commandCount = 0;
1826+
1827+
const result = await runPlannotatorUninstall(
1828+
{ purge: false, dryRun: false },
1829+
{
1830+
...fixture.environment,
1831+
platform: "win32",
1832+
execPath: currentExe,
1833+
env: { LOCALAPPDATA: localAppData },
1834+
which: () => "C:\\Windows\\powershell.exe",
1835+
runCommand: async () => {
1836+
commandCount += 1;
1837+
if (commandCount === 1) {
1838+
return {
1839+
exitCode: 0,
1840+
timedOut: false,
1841+
stdout: JSON.stringify(
1842+
`C:\\Before;${dirname(currentExe)};C:\\After;;`,
1843+
),
1844+
};
1845+
}
1846+
return { exitCode: 124, timedOut: true, stdout: "" };
1847+
},
1848+
scheduleWindowsSelfDelete: async () => false,
1849+
},
1850+
);
1851+
1852+
const pathLabel = `Windows user PATH entry ${dirname(currentExe)}`;
1853+
expect(result.ok).toBe(false);
1854+
expect(result.removed).toContain(pathLabel);
1855+
expect(result.errors).toContain(
1856+
`Could not restore ${dirname(currentExe)} to the Windows user PATH after self-delete scheduling failed (command timed out).`,
1857+
);
1858+
});
1859+
15501860
test("restores Windows PATH when scheduling self-delete fails", async () => {
15511861
const fixture = createFixture();
15521862
const localAppData = join(fixture.homeDir, "AppData", "Local");
@@ -1615,7 +1925,7 @@ describe("host and platform integrations", () => {
16151925
expect(existsSync(currentExe)).toBe(true);
16161926
expect(result.removed).toContain(pathLabel);
16171927
expect(result.errors).toContain(
1618-
`Could not restore ${dirname(currentExe)} to the Windows user PATH after self-delete scheduling failed.`,
1928+
`Could not restore ${dirname(currentExe)} to the Windows user PATH after self-delete scheduling failed (exit 1).`,
16191929
);
16201930
expect(result.warnings).toContain(
16211931
`The Plannotator CLI remains at ${currentExe}, but its Windows PATH entry could not be restored. Run that full path to retry, then restore PATH manually if needed.`,

0 commit comments

Comments
 (0)