@bitkyc08/opencodex 2.53.0 → 2.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/gui/dist/assets/{index-BlO4Yl6q.js → index-CkvITofZ.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/devin.ts +39 -7
  5. package/src/cli/capabilities.ts +4 -2
  6. package/src/cli/catalog.ts +39 -10
  7. package/src/cli/dispatch.ts +19 -64
  8. package/src/cli/doctor.ts +1 -1
  9. package/src/cli/internal-command.ts +44 -0
  10. package/src/cli/registry.ts +9 -7
  11. package/src/cli/restart-scope.ts +184 -0
  12. package/src/codex/app-server-processes.ts +20 -5
  13. package/src/codex/app-server-restart-service.ts +29 -0
  14. package/src/codex/catalog/provider-fetch.ts +13 -5
  15. package/src/codex/catalog/sync.ts +12 -3
  16. package/src/codex/desktop-app/darwin.ts +268 -0
  17. package/src/codex/desktop-app/handoff.ts +303 -0
  18. package/src/codex/desktop-app/linux.ts +388 -0
  19. package/src/codex/desktop-app/lock.ts +226 -0
  20. package/src/codex/desktop-app/types.ts +141 -0
  21. package/src/codex/desktop-app/windows.ts +239 -0
  22. package/src/codex/desktop-app-restart.ts +264 -279
  23. package/src/codex/inject.ts +83 -21
  24. package/src/codex/sync.ts +16 -22
  25. package/src/generated/compatibility-version.json +58 -22
  26. package/src/lib/codex-restart-contract.ts +31 -0
  27. package/src/providers/registry.ts +13 -12
  28. package/src/server/responses/core.ts +62 -4
  29. package/src/server/responses/encrypted-payload.ts +161 -0
  30. package/src/server/responses.ts +1 -1
  31. package/src/types/provider.ts +6 -5
  32. package/src/web-search/index.ts +14 -67
  33. package/src/web-search/passthrough-bridge.ts +256 -32
  34. package/src/web-search/sidecar-providers.ts +76 -0
@@ -1,52 +1,94 @@
1
1
  /**
2
- * Full restart of the Codex desktop app (the Electron shell), Windows only.
2
+ * Full restart of the Codex desktop app (the Electron shell) on macOS, Linux and
3
+ * Windows.
3
4
  *
4
- * `--restart-codex` deliberately signals only `codex app-server` /
5
- * `codex-code-mode-host` processes: `isCodexAppServerCommandLine` requires a
6
- * `codex` executable token, so the shell that owns the model picker is never a
7
- * match. On macOS that is enough, because the respawned app-server re-emits
8
- * `codex-app-server-initialized` and the renderer drops its cached
9
- * `model/list`. On Windows MSIX it is not: externally terminating the child
10
- * does not reliably re-emit that event in the surviving shell, so the picker
11
- * keeps showing the old catalog until the app itself is restarted (#2292).
5
+ * WHY THIS EXISTS AT ALL. `--restart-codex` used to signal only `codex app-server` /
6
+ * `codex-code-mode-host` processes, and on every platform that turns out not to
7
+ * refresh the model picker. The app-server it signals is a CHILD of the desktop app
8
+ * (measured: pid 16733 under pid 15901 on macOS, 3285204 under 3284901 on Linux), so
9
+ * the app simply respawns it while the renderer keeps the roster it built at launch.
10
+ * The matcher was never the problem; the only thing that reliably refreshes the
11
+ * picker is restarting the shell that owns it.
12
12
  *
13
- * This is therefore a SEPARATE opt-in flag rather than a widening of
14
- * `--restart-codex`. Quitting the desktop app ends live conversations, which is
15
- * a different consent from restarting a background helper, and the CLI contract
16
- * for `--restart-codex` promises the narrow behavior.
13
+ * WHY THE CONSENT CHANGED. This capability was deliberately kept behind a separate
14
+ * Windows-only `--restart-desktop-app` flag, because quitting the app ends live
15
+ * conversations and that is a larger consent than restarting a background helper.
16
+ * That reasoning was sound and has been superseded by an explicit maintainer
17
+ * decision: `--restart-codex` now means the app is fully stopped and started again.
18
+ * The narrow behaviour did not disappear, it moved to `--restart-app-server-only`.
17
19
  *
18
- * Everything here fails CLOSED: if the package cannot be identified, if a
19
- * target is part of our own ancestry, or if any target survives termination,
20
- * nothing is relaunched and the caller is told to restart manually. A stale
21
- * picker is a much smaller problem than a wrongly killed process.
20
+ * WHAT IS SHARED AND WHAT IS NOT. The ladder below — discover, enumerate, find
21
+ * shells, check self-ancestry, graceful, wait, re-verify identity, force, wait,
22
+ * refuse-or-relaunch — is identical on all three platforms. Only identity,
23
+ * discovery, membership, the two stop primitives and relaunch differ, and those live
24
+ * behind DesktopAppAdapter. Re-deriving the PID-reuse and fail-closed reasoning once
25
+ * per operating system is how two of the three end up subtly wrong.
26
+ *
27
+ * EVERYTHING FAILS CLOSED. A failed discovery, a failed enumeration, an unreadable
28
+ * process identity or an unreadable ancestry chain never authorises a kill and is
29
+ * never reported as "nothing to do". A stale picker is a much smaller problem than a
30
+ * wrongly killed process.
31
+ *
32
+ * Design and audit history: devlog/_plan/260913_cross_platform_desktop_app_restart/.
22
33
  */
23
- import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsTaskkillExe } from "../lib/windows-elevation";
24
- import { execFileSync } from "node:child_process";
34
+ import {
35
+ acquireDesktopRestartLock,
36
+ releaseDesktopRestartLock,
37
+ type DesktopRestartLockIo,
38
+ } from "./desktop-app/lock";
39
+ import { rootShells, type DesktopAppAdapter, type DesktopExec, type DesktopProcess } from "./desktop-app/types";
40
+ import { darwinDesktopAppAdapter, darwinDefaultExec } from "./desktop-app/darwin";
41
+ import { linuxDesktopAppAdapter, linuxDefaultExec } from "./desktop-app/linux";
42
+ import { windowsDesktopAppAdapter, windowsDefaultExec } from "./desktop-app/windows";
43
+
44
+ export type { DesktopAppExecOptions } from "./desktop-app/types";
25
45
 
26
- /** Bounded subprocess options. A hung Appx/CIM probe must never wedge `ocx sync`. */
27
- export interface DesktopAppExecOptions {
28
- timeout?: number;
29
- windowsHide?: boolean;
46
+ /** How long a graceful close is given before the forced pass. */
47
+ const GRACEFUL_EXIT_TIMEOUT_MS = 15_000;
48
+ /** How long a forced kill is given before the target counts as surviving. */
49
+ const FORCED_EXIT_TIMEOUT_MS = 5_000;
50
+
51
+ export interface DesktopAppRestartHandoff {
52
+ helperPid: number;
53
+ logPath: string;
30
54
  }
31
55
 
32
56
  export interface DesktopAppRestartIo {
33
57
  platform?: NodeJS.Platform;
34
- /** Returns stdout. Options are part of the seam so the timeout is testable. */
35
- execFile?: (file: string, args: readonly string[], options?: DesktopAppExecOptions) => string;
58
+ /** Overrides the adapter chosen from `platform`. Tests drive every branch through this. */
59
+ adapter?: DesktopAppAdapter;
60
+ execFile?: DesktopExec;
36
61
  /** Process ancestry of the current process, innermost first. Used for the self-kill guard. */
37
62
  ancestryPids?: () => number[];
38
63
  isAlive?: (pid: number) => boolean;
39
64
  sleep?: (ms: number) => void;
40
65
  now?: () => number;
66
+ lock?: DesktopRestartLockIo;
67
+ /**
68
+ * Hand the restart to a detached helper when this process is inside the tree.
69
+ * Supplied by wp5; absent here means the ladder refuses instead, which is the
70
+ * behaviour that shipped before the handoff existed.
71
+ */
72
+ startHandoff?: () => DesktopAppRestartHandoff | null;
73
+ /**
74
+ * False forbids a handoff. The helper passes it so recursion is structurally
75
+ * impossible, and the management service passes it because it runs inside a proxy
76
+ * that never exits — a handoff waiting for the caller to exit would always time out
77
+ * after telling the operator it had been handed off.
78
+ */
79
+ allowHandoff?: boolean;
41
80
  }
42
81
 
43
82
  export type DesktopAppRestartReason =
44
- | "windows_only"
83
+ | "unsupported_platform"
45
84
  | "package_discovery_failed"
46
85
  | "process_probe_failed"
47
86
  | "no_targets"
48
87
  | "self_ancestry"
49
- | "targets_survived";
88
+ | "restart_in_flight"
89
+ | "handoff_started"
90
+ | "targets_survived"
91
+ | "relaunch_failed";
50
92
 
51
93
  export interface DesktopAppRestartResult {
52
94
  attempted: boolean;
@@ -54,302 +96,245 @@ export interface DesktopAppRestartResult {
54
96
  surviving: number[];
55
97
  relaunch: "started" | "skipped";
56
98
  reason?: DesktopAppRestartReason;
99
+ handoff?: DesktopAppRestartHandoff;
57
100
  }
58
101
 
59
- /** How long a graceful close is given before the forced pass. */
60
- const GRACEFUL_EXIT_TIMEOUT_MS = 15_000;
61
- /** How long a forced kill is given before the target counts as surviving. */
62
- const FORCED_EXIT_TIMEOUT_MS = 5_000;
63
- /** Every probe is bounded; PowerShell module loading is the slow part. */
64
- const PROBE_TIMEOUT_MS = 10_000;
65
-
66
- interface DesktopPackage {
67
- family: string;
68
- installLocation: string;
69
- aumid: string;
70
- }
102
+ const ADAPTERS: Partial<Record<NodeJS.Platform, { adapter: DesktopAppAdapter; exec: DesktopExec }>> = {
103
+ darwin: { adapter: darwinDesktopAppAdapter, exec: darwinDefaultExec },
104
+ linux: { adapter: linuxDesktopAppAdapter, exec: linuxDefaultExec },
105
+ win32: { adapter: windowsDesktopAppAdapter, exec: windowsDefaultExec },
106
+ };
71
107
 
72
- /**
73
- * Runtime discovery, never a hardcoded identifier. The beta MSIX package family
74
- * changes between builds, so a literal AUMID would silently stop matching and
75
- * then either do nothing or — worse — match a package we did not mean.
76
- */
77
- function discoverPackage(exec: NonNullable<DesktopAppRestartIo["execFile"]>): DesktopPackage | null {
78
- const script = [
79
- "$ErrorActionPreference='SilentlyContinue'",
80
- "Import-Module Appx -ErrorAction SilentlyContinue",
81
- "$p = Get-AppxPackage -Name OpenAI.Codex",
82
- "if (-not $p) { $p = Get-AppxPackage -Name OpenAI.CodexBeta }",
83
- "if (-not $p -or -not $p.InstallLocation) { 'MISS' } else {",
84
- " $p.PackageFamilyName; $p.InstallLocation; \"$($p.PackageFamilyName)!App\"",
85
- "}",
86
- ].join("; ");
87
- let stdout: string;
108
+ function defaultIsAlive(pid: number): boolean {
88
109
  try {
89
- stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], {
90
- timeout: PROBE_TIMEOUT_MS,
91
- windowsHide: true,
92
- });
110
+ process.kill(pid, 0);
111
+ return true;
93
112
  } catch {
94
- return null;
113
+ return false;
95
114
  }
96
- const lines = stdout.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0);
97
- if (lines.length < 3 || lines[0] === "MISS") return null;
98
- const [family, installLocation, aumid] = lines;
99
- if (!family || !installLocation || !aumid) return null;
100
- return { family, installLocation, aumid };
101
115
  }
102
116
 
103
- interface DesktopProcess {
104
- pid: number;
105
- parentPid: number;
106
- /** Win32_Process CreationDate. Guards against PID reuse across the wait window. */
107
- createdAt: string;
117
+ function defaultSleep(ms: number): void {
118
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
108
119
  }
109
120
 
110
121
  /**
111
- * Only `ChatGPT.exe` processes whose image lives under the discovered install
112
- * location AND owned by the current user. The install location alone is not
113
- * enough: an MSIX package under `WindowsApps` is shared, so on a multi-user
114
- * machine another account's Codex desktop matches the same path. The app-server
115
- * collector already pays for `GetOwner` for exactly this reason.
122
+ * True when the pid still names the process we verified.
116
123
  *
117
- * `CreationDate` is captured so a PID can be re-verified before it is signalled;
118
- * a graceful-close window is long enough for Windows to recycle a PID.
124
+ * Between listing and signalling there is a graceful-close window long enough for the
125
+ * OS to recycle a pid, and the next step is a hard kill. A pid alone is not an
126
+ * identity across that window; the start-time token is what distinguishes a process
127
+ * from its replacement.
119
128
  */
120
- function listPackageProcesses(
121
- exec: NonNullable<DesktopAppRestartIo["execFile"]>,
122
- installLocation: string,
123
- ): DesktopProcess[] | null {
124
- const literal = installLocation.replace(/'/g, "''");
125
- const script = [
126
- "$ErrorActionPreference='SilentlyContinue'",
127
- `$root = '${literal}'`,
128
- "$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name",
129
- "Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |",
130
- " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |",
131
- " ForEach-Object {",
132
- " $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner",
133
- " if ($o -and $o.ReturnValue -eq 0 -and $o.User) {",
134
- " $owner = if ($o.Domain) { \"$($o.Domain)\\$($o.User)\" } else { $o.User }",
135
- " if ($owner -ieq $me) {",
136
- " \"$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o'))\"",
137
- " }",
138
- " }",
139
- " }",
140
- // Statements must be newline-separated. Joining with a space concatenates
141
- // `$ErrorActionPreference='SilentlyContinue' $root = '...'` into one malformed statement,
142
- // which PowerShell rejects — so the probe threw and every caller read "not running" (#2557).
143
- ].join("\n");
144
- let stdout: string;
145
- try {
146
- stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], {
147
- timeout: PROBE_TIMEOUT_MS,
148
- windowsHide: true,
149
- });
150
- } catch {
151
- // A probe that could not run is NOT proof the app is absent. Returning [] here made a
152
- // failed enumeration indistinguishable from "no targets", so the CLI reported the app as
153
- // not running and skipped a restart the user had explicitly asked for.
154
- return null;
155
- }
156
- const processes: DesktopProcess[] = [];
157
- for (const line of stdout.split(/\r?\n/)) {
158
- const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
159
- if (!match) continue;
160
- const pid = Number(match[1]);
161
- const parentPid = Number(match[2]);
162
- const createdAt = match[3]!;
163
- if (Number.isSafeInteger(pid) && Number.isSafeInteger(parentPid)) {
164
- processes.push({ pid, parentPid, createdAt });
165
- }
166
- }
167
- return processes;
168
- }
129
+ type IdentityCheck = "same" | "gone" | "unknown";
169
130
 
170
- /**
171
- * True when the PID still names the same process we verified. Between listing
172
- * and signalling there is a graceful-close window, and a `taskkill /T /F` on a
173
- * recycled PID would tear down an unrelated process tree.
174
- */
175
- function stillSameProcess(
176
- exec: NonNullable<DesktopAppRestartIo["execFile"]>,
177
- installLocation: string,
131
+ function checkIdentity(
132
+ adapter: DesktopAppAdapter,
133
+ exec: DesktopExec,
134
+ install: Parameters<DesktopAppAdapter["listProcesses"]>[1],
178
135
  target: DesktopProcess,
179
- ): boolean {
180
- const processes = listPackageProcesses(exec, installLocation);
181
- // Fail CLOSED on a failed re-probe: this guards a kill, and "we could not look" must not be
182
- // read as "the pid was recycled and is now someone else's process".
183
- if (processes === null) return false;
184
- const current = processes.find(p => p.pid === target.pid);
185
- return current !== undefined && current.createdAt === target.createdAt;
186
- }
187
-
188
- /** Roots are the package processes whose parent is not itself in the package tree. */
189
- function rootProcesses(processes: readonly DesktopProcess[]): DesktopProcess[] {
190
- const inTree = new Set(processes.map(p => p.pid));
191
- return processes.filter(p => !inTree.has(p.parentPid));
136
+ ): IdentityCheck {
137
+ const processes = adapter.listProcesses(exec, install);
138
+ // THREE outcomes, not two. Collapsing them into a boolean is what made this ladder
139
+ // claim a restart it never performed: a re-probe that could not RUN looked identical
140
+ // to a process that had exited, and the caller recorded the pid as stopped, skipped
141
+ // the forced pass, and relaunched into an app that was still running - reporting
142
+ // success the whole way. Measured on a real Windows host, where the app kept its
143
+ // original pid and start time through a restart that said it had stopped it.
144
+ if (processes === null) return "unknown";
145
+ const current = processes.find(entry => entry.pid === target.pid);
146
+ if (current === undefined) return "gone";
147
+ // Same pid, different start time: the pid was recycled and now belongs to somebody
148
+ // else. Treated as gone, because the process we meant to stop no longer exists and
149
+ // signalling this pid would hit an unrelated process.
150
+ return current.createdAt === target.createdAt ? "same" : "gone";
192
151
  }
193
152
 
194
153
  /**
195
- * Full Windows parent chain for this process, innermost first.
154
+ * Pids of the running desktop-app tree, or null when discovery or the probe failed.
196
155
  *
197
- * `process.ppid` is one level, which is not enough: a terminal hosted inside the
198
- * desktop app sits several hops below `ChatGPT.exe`, so a one-level check would
199
- * miss the exact case the guard exists for and we would terminate our own host.
200
- * The chain therefore comes from CIM, with a bound so a corrupted parent cycle
201
- * cannot spin.
156
+ * Read-only. Used by the CLI to exclude app-servers the desktop restart is about to
157
+ * take anyway, so an operator\u2019s in-flight turn is not interrupted twice in one command.
202
158
  */
203
- function windowsAncestryPids(exec: NonNullable<DesktopAppRestartIo["execFile"]>): number[] {
204
- const chain: number[] = [process.pid];
205
- let current = process.pid;
206
- for (let hop = 0; hop < 16; hop++) {
207
- let stdout: string;
208
- try {
209
- stdout = exec(resolveTrustedWindowsPowerShellExe(), [
210
- "-NoProfile", "-NonInteractive", "-Command",
211
- `$ErrorActionPreference='SilentlyContinue'; (Get-CimInstance Win32_Process -Filter "ProcessId=${current}").ParentProcessId`,
212
- ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
213
- } catch {
214
- // An unreadable chain must not be read as "not our ancestor".
215
- return [];
216
- }
217
- const parent = Number(stdout.trim());
218
- if (!Number.isSafeInteger(parent) || parent <= 0 || chain.includes(parent)) break;
219
- chain.push(parent);
220
- current = parent;
221
- }
222
- return chain;
223
- }
224
-
225
- function defaultExecFile(file: string, args: readonly string[], options?: DesktopAppExecOptions): string {
226
- return execFileSync(file, [...args], {
227
- encoding: "utf-8",
228
- timeout: options?.timeout ?? PROBE_TIMEOUT_MS,
229
- windowsHide: options?.windowsHide ?? true,
230
- });
231
- }
232
-
233
- function defaultIsAlive(pid: number): boolean {
234
- try {
235
- process.kill(pid, 0);
236
- return true;
237
- } catch {
238
- return false;
239
- }
240
- }
241
-
242
- function defaultSleep(ms: number): void {
243
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
244
- }
245
-
246
- function waitForExit(
247
- pid: number,
159
+ /**
160
+ * Poll the platform's own process list until it stops listing this process.
161
+ *
162
+ * A single post-kill enumeration is not enough. Measured on Windows: `taskkill /T /F`
163
+ * succeeds, the process is genuinely dead a moment later, and yet the very next
164
+ * `Win32_Process` query still lists it. Checking once turned that lag into a reported
165
+ * survivor, which blocked the relaunch and left the machine with no app at all - the
166
+ * failure mode is the mirror of claiming a stop that never happened, and just as bad.
167
+ *
168
+ * Liveness is polled first because it is cheap; the enumeration is what decides. A probe
169
+ * that cannot run keeps the loop going rather than deciding either way, and if the
170
+ * deadline passes without a clean "gone" the caller treats it as a survivor.
171
+ */
172
+ function waitUntilGone(
173
+ adapter: DesktopAppAdapter,
174
+ exec: DesktopExec,
175
+ install: Parameters<DesktopAppAdapter["listProcesses"]>[1],
176
+ target: DesktopProcess,
248
177
  timeoutMs: number,
249
178
  isAlive: (pid: number) => boolean,
250
179
  sleep: (ms: number) => void,
251
180
  now: () => number,
252
181
  ): boolean {
253
182
  const deadline = now() + timeoutMs;
254
- while (now() < deadline) {
255
- if (!isAlive(pid)) return true;
183
+ for (;;) {
184
+ if (!isAlive(target.pid) && checkIdentity(adapter, exec, install, target) === "gone") return true;
185
+ if (now() >= deadline) break;
256
186
  sleep(250);
257
187
  }
258
- return !isAlive(pid);
188
+ // One last look after the deadline, so a process that exited during the final sleep is
189
+ // not reported as surviving purely because of poll timing.
190
+ return checkIdentity(adapter, exec, install, target) === "gone";
259
191
  }
260
192
 
261
- /**
262
- * Stop every package-tree root gracefully, force the stragglers, then relaunch
263
- * through the discovered AUMID. Returns without relaunching if anything
264
- * survived, because launching a second shell beside a stuck one is worse than
265
- * leaving the user to restart it.
266
- */
267
- export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopAppRestartResult {
193
+ export function listCodexDesktopAppPids(io: DesktopAppRestartIo = {}): number[] | null {
268
194
  const platform = io.platform ?? process.platform;
195
+ const selected = ADAPTERS[platform];
196
+ const adapter = io.adapter ?? selected?.adapter;
197
+ if (!adapter) return null;
198
+ const exec = io.execFile ?? selected?.exec;
199
+ if (!exec) return null;
200
+ const install = adapter.discover(exec);
201
+ if (!install) return null;
202
+ const processes = adapter.listProcesses(exec, install);
203
+ return processes === null ? null : processes.map(entry => entry.pid);
204
+ }
205
+
206
+ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopAppRestartResult {
269
207
  const skipped = (reason: DesktopAppRestartReason): DesktopAppRestartResult => ({
270
208
  attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason,
271
209
  });
272
- if (platform !== "win32") return skipped("windows_only");
273
210
 
274
- const exec = io.execFile ?? defaultExecFile;
275
- const pkg = discoverPackage(exec);
276
- if (!pkg) return skipped("package_discovery_failed");
211
+ const platform = io.platform ?? process.platform;
212
+ const selected = ADAPTERS[platform];
213
+ const adapter = io.adapter ?? selected?.adapter;
214
+ const exec = io.execFile ?? selected?.exec;
215
+ if (!adapter || !exec) return skipped("unsupported_platform");
277
216
 
278
- const processes = listPackageProcesses(exec, pkg.installLocation);
279
- // A probe that could not run is not evidence of absence. Reporting it as `no_targets` told
280
- // the user the app was not running and silently skipped the restart they asked for (#2557).
281
- if (processes === null) return skipped("process_probe_failed");
282
- const roots = rootProcesses(processes);
283
- if (roots.length === 0) return skipped("no_targets");
217
+ // Step 0. Two restarts at once are destructive rather than merely wasteful: the
218
+ // first quits and relaunches, the second sees the freshly started shell as a target
219
+ // and kills it. Own-pid reentrancy means the wp5 helper runs this same step and
220
+ // finds the lock its caller made out to it.
221
+ const acquisition = acquireDesktopRestartLock(io.lock);
222
+ if (!acquisition.acquired) return skipped("restart_in_flight");
284
223
 
285
- const ancestryPids = io.ancestryPids ? io.ancestryPids() : windowsAncestryPids(exec);
286
- if (ancestryPids.length === 0) {
287
- // Fail closed: an unreadable ancestry chain cannot prove we are outside the
288
- // tree we are about to terminate.
289
- return skipped("self_ancestry");
290
- }
291
- const ancestry = new Set(ancestryPids);
292
- if (processes.some(p => ancestry.has(p.pid))) {
293
- // Terminating our own tree would kill this command mid-flight and leave the
294
- // user with neither a restarted app nor an explanation.
295
- return skipped("self_ancestry");
296
- }
224
+ let handedOff = false;
225
+ try {
226
+ const install = adapter.discover(exec);
227
+ if (!install) return skipped("package_discovery_failed");
297
228
 
298
- const isAlive = io.isAlive ?? defaultIsAlive;
299
- const sleep = io.sleep ?? defaultSleep;
300
- const now = io.now ?? (() => Date.now());
301
- const stopped: number[] = [];
302
- const surviving: number[] = [];
229
+ const processes = adapter.listProcesses(exec, install);
230
+ // A probe that could not run is not evidence of absence. Reporting it as no_targets
231
+ // told users the app was not running and silently skipped the restart they asked
232
+ // for (#2557).
233
+ if (processes === null) return skipped("process_probe_failed");
303
234
 
304
- for (const root of roots) {
305
- const pid = root.pid;
306
- // Re-verify immediately before the graceful close: the listing is already
307
- // one probe old.
308
- if (!stillSameProcess(exec, pkg.installLocation, root)) {
309
- stopped.push(pid);
310
- continue;
311
- }
312
- try {
313
- exec(resolveTrustedWindowsPowerShellExe(), [
314
- "-NoProfile", "-NonInteractive", "-Command",
315
- `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($p) { [void]$p.CloseMainWindow() }`,
316
- ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
317
- } catch {
318
- /* a refused graceful close still gets the forced pass below */
235
+ const shells = rootShells(processes, install, adapter);
236
+ if (shells.length === 0) return skipped("no_targets");
237
+
238
+ const ancestryPids = io.ancestryPids ? io.ancestryPids() : adapter.ancestryPids(exec);
239
+ // An empty chain means "could not establish that we are outside the tree", which
240
+ // covers both an unreadable hop and a walk that hit its bound.
241
+ const insideTree = ancestryPids.length === 0
242
+ || processes.some(entry => ancestryPids.includes(entry.pid));
243
+ if (insideTree) {
244
+ if (io.allowHandoff === false || !io.startHandoff) return skipped("self_ancestry");
245
+ const handoff = io.startHandoff();
246
+ if (!handoff) return skipped("self_ancestry");
247
+ handedOff = true;
248
+ return {
249
+ attempted: false, stopped: [], surviving: [],
250
+ relaunch: "skipped", reason: "handoff_started", handoff,
251
+ };
319
252
  }
320
- if (waitForExit(pid, GRACEFUL_EXIT_TIMEOUT_MS, isAlive, sleep, now)) {
321
- stopped.push(pid);
322
- continue;
253
+
254
+ // Captured while the tree is still ALIVE. On Linux the relaunch needs the
255
+ // graphical session variables, and after termination there is nothing to read them
256
+ // from. Ordering this wrongly works on macOS and Windows and produces a Linux app
257
+ // that cannot reach the compositor.
258
+ const context = adapter.captureRelaunchContext(exec, install, processes);
259
+
260
+ const isAlive = io.isAlive ?? defaultIsAlive;
261
+ const sleep = io.sleep ?? defaultSleep;
262
+ const now = io.now ?? (() => Date.now());
263
+ const stopped: number[] = [];
264
+ const surviving: number[] = [];
265
+
266
+ for (const shell of shells) {
267
+ const pid = shell.pid;
268
+ // The listing is already one probe old.
269
+ const before = checkIdentity(adapter, exec, install, shell);
270
+ if (before === "gone") {
271
+ stopped.push(pid);
272
+ continue;
273
+ }
274
+ if (before === "unknown") {
275
+ // We could not look, so we cannot claim this exited and we must not signal a
276
+ // process we failed to re-verify. Reporting it as surviving is the honest answer:
277
+ // it blocks the relaunch, which is exactly right when the tree state is unknown.
278
+ surviving.push(pid);
279
+ continue;
280
+ }
281
+ try {
282
+ adapter.requestQuit(exec, install, shell);
283
+ } catch {
284
+ /* a refused graceful close still gets the forced pass below */
285
+ }
286
+ // Liveness AND enumeration have to agree before a stop is claimed. A pid-based
287
+ // liveness probe is a weaker instrument than the platform's own process list, and
288
+ // on a packaged app the two disagree in BOTH directions.
289
+ if (waitUntilGone(adapter, exec, install, shell, GRACEFUL_EXIT_TIMEOUT_MS, isAlive, sleep, now)) {
290
+ stopped.push(pid);
291
+ continue;
292
+ }
293
+ // The wait window is long enough for a pid to be recycled, and the next step is a
294
+ // hard kill. Confirm it is still the process we verified, or leave it alone.
295
+ const afterGraceful = checkIdentity(adapter, exec, install, shell);
296
+ if (afterGraceful === "gone") {
297
+ stopped.push(pid);
298
+ continue;
299
+ }
300
+ if (afterGraceful === "unknown") {
301
+ surviving.push(pid);
302
+ continue;
303
+ }
304
+ try {
305
+ adapter.forceStop(exec, shell);
306
+ } catch {
307
+ /* the process state decides, not the exit code */
308
+ }
309
+ // Same rule after the forced pass: only an enumeration that no longer contains this
310
+ // process proves it stopped. Everything else is a survivor, and a survivor blocks
311
+ // the relaunch rather than producing a second shell beside a live one.
312
+ if (waitUntilGone(adapter, exec, install, shell, FORCED_EXIT_TIMEOUT_MS, isAlive, sleep, now)) {
313
+ stopped.push(pid);
314
+ } else {
315
+ surviving.push(pid);
316
+ }
323
317
  }
324
- // The wait window is long enough for Windows to recycle a PID, and the next
325
- // step is `/T /F` against a whole tree. Confirm the PID is still the process
326
- // we verified, or leave it alone.
327
- if (!stillSameProcess(exec, pkg.installLocation, root)) {
328
- stopped.push(pid);
329
- continue;
318
+
319
+ if (surviving.length > 0) {
320
+ // Launching a second shell beside a stuck one is worse than leaving the operator
321
+ // to restart it.
322
+ return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" };
330
323
  }
324
+
331
325
  try {
332
- exec(resolveTrustedWindowsTaskkillExe(), ["/PID", String(pid), "/T", "/F"], {
333
- timeout: PROBE_TIMEOUT_MS, windowsHide: true,
334
- });
326
+ adapter.relaunch(exec, install, context);
335
327
  } catch {
336
- /* fall through to the liveness check: the process state decides, not the exit code */
328
+ // Distinct from targets_survived on purpose. Everything DID die and the relaunch
329
+ // is what failed; the old code reported the two as one and sent operators looking
330
+ // for processes that were not there.
331
+ return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "relaunch_failed" };
337
332
  }
338
- if (waitForExit(pid, FORCED_EXIT_TIMEOUT_MS, isAlive, sleep, now)) stopped.push(pid);
339
- else surviving.push(pid);
340
- }
341
-
342
- if (surviving.length > 0) {
343
- return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" };
333
+ return { attempted: true, stopped, surviving: [], relaunch: "started" };
334
+ } finally {
335
+ // On the handoff path ownership was transferred to the helper, so releasing here
336
+ // would drop a lock that is still protecting a restart about to happen.
337
+ if (!handedOff) releaseDesktopRestartLock(io.lock);
344
338
  }
345
-
346
- try {
347
- exec(resolveTrustedWindowsPowerShellExe(), [
348
- "-NoProfile", "-NonInteractive", "-Command",
349
- `Start-Process 'shell:AppsFolder\\${pkg.aumid}'`,
350
- ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
351
- } catch {
352
- return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" };
353
- }
354
- return { attempted: true, stopped, surviving, relaunch: "started" };
355
339
  }
340
+