@bitkyc08/opencodex 2.8.0 → 2.9.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.
- package/README.md +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/update/job.ts
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
3
|
+
import { dirname, join, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
atomicWriteFile,
|
|
7
|
+
getConfigDir,
|
|
8
|
+
loadConfig,
|
|
9
|
+
readPid,
|
|
10
|
+
readRuntimePort,
|
|
11
|
+
removePid,
|
|
12
|
+
removeRuntimePort,
|
|
13
|
+
verifyPidIdentity,
|
|
14
|
+
} from "../config";
|
|
6
15
|
import { isProcessAlive, killProxy } from "../lib/process-control";
|
|
7
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
buildWindowsElevatedArgumentList,
|
|
18
|
+
resolveTrustedWindowsPowerShellExe,
|
|
19
|
+
} from "../lib/windows-elevation";
|
|
20
|
+
import { stopWinswService } from "../lib/winsw";
|
|
21
|
+
import { listListenPids, reclaimListenPort, scanListenPids, type ListenPidScan } from "../server/port-reclaim";
|
|
22
|
+
import { dropWindowsTcpRowsForLocalPort } from "../server/windows-tcp-drop";
|
|
8
23
|
import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity } from "../server/proxy-liveness";
|
|
9
|
-
import { isServiceInstalled } from "../service";
|
|
24
|
+
import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service";
|
|
10
25
|
import {
|
|
11
26
|
type Channel,
|
|
12
27
|
type Installer,
|
|
@@ -20,6 +35,7 @@ import {
|
|
|
20
35
|
updateCommandStr,
|
|
21
36
|
} from "./index";
|
|
22
37
|
import { isNewer } from "./notify";
|
|
38
|
+
import { isRealBunBinary } from "../lib/bun-binary-validator.mjs";
|
|
23
39
|
import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
|
|
24
40
|
|
|
25
41
|
const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest";
|
|
@@ -102,9 +118,103 @@ function nodeBin(): string {
|
|
|
102
118
|
return process.platform === "win32" ? "node.exe" : "node";
|
|
103
119
|
}
|
|
104
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Strict bind script: exit 0 only after listen+close. Any listen error (including
|
|
123
|
+
* Windows ghost-TCB failures under Bun) is busy — matches published `ocx start`
|
|
124
|
+
* probes that treat every listen error as unavailable.
|
|
125
|
+
*/
|
|
126
|
+
function strictBindProbeScript(port: number, hostname: string): string {
|
|
127
|
+
return [
|
|
128
|
+
"const net=require('net');",
|
|
129
|
+
"const s=net.createServer();",
|
|
130
|
+
"s.once('error',()=>process.exit(2));",
|
|
131
|
+
`s.listen(${Math.trunc(port)},${JSON.stringify(hostname)},()=>s.close(()=>process.exit(0)));`,
|
|
132
|
+
"setTimeout(()=>process.exit(3),2500);",
|
|
133
|
+
].join("");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function spawnBindProbe(bin: string, script: string): boolean {
|
|
137
|
+
try {
|
|
138
|
+
const r = spawnSync(bin, ["-e", script], {
|
|
139
|
+
windowsHide: true,
|
|
140
|
+
timeout: 4000,
|
|
141
|
+
stdio: "ignore",
|
|
142
|
+
});
|
|
143
|
+
return r.status === 0;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Live global package Bun — not the npm rename tree the update worker may still
|
|
151
|
+
* be executing from (`@bitkyc08/.opencodex-*`). Reject the tiny postinstall
|
|
152
|
+
* stub so probes fall back to the worker runtime instead of failing forever.
|
|
153
|
+
*/
|
|
154
|
+
function livePackageBunPath(): string | null {
|
|
155
|
+
const launcher = packageLauncherPath();
|
|
156
|
+
const root = join(dirname(launcher), "..");
|
|
157
|
+
for (const name of ["bun.exe", "bun"]) {
|
|
158
|
+
const candidate = join(root, "node_modules", "bun", "bin", name);
|
|
159
|
+
if (isRealBunBinary(candidate)) return candidate;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Port is free for post-update `ocx start` only when the runtime that will
|
|
166
|
+
* actually execute the start can bind. Prefer live package Bun; fall back to the
|
|
167
|
+
* worker runtime. Do not require a separate `node` binary (Bun-only installs).
|
|
168
|
+
*/
|
|
169
|
+
async function strictRuntimePortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
|
|
170
|
+
const script = strictBindProbeScript(port, hostname);
|
|
171
|
+
const bun = livePackageBunPath();
|
|
172
|
+
if (bun) return spawnBindProbe(bun, script);
|
|
173
|
+
return spawnBindProbe(process.execPath, script);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Wait until netstat reports no LISTEN owners on `port` AND the start runtime
|
|
178
|
+
* can bind. Dead PIDs still appear as holders while the ghost TCB lives;
|
|
179
|
+
* SetTcpEntry is a no-op without elevation (rc 317), so wait them out.
|
|
180
|
+
*/
|
|
181
|
+
async function waitForGhostListenClear(
|
|
182
|
+
port: number,
|
|
183
|
+
hostname: string,
|
|
184
|
+
listPids: (port: number) => number[],
|
|
185
|
+
timeoutMs: number,
|
|
186
|
+
sleep: (ms: number) => Promise<void>,
|
|
187
|
+
aliveFn: (pid: number) => boolean = isProcessAlive,
|
|
188
|
+
): Promise<{ ok: boolean; accessDenied: boolean }> {
|
|
189
|
+
const deadline = Date.now() + timeoutMs;
|
|
190
|
+
let accessDenied = false;
|
|
191
|
+
while (Date.now() < deadline) {
|
|
192
|
+
const holders = listPids(port).filter(pid => pid !== process.pid);
|
|
193
|
+
const liveHolders = holders.filter(pid => aliveFn(pid));
|
|
194
|
+
// Never SetTcpEntry while a live process still owns the port (foreign or ocx).
|
|
195
|
+
if (process.platform === "win32" && liveHolders.length === 0) {
|
|
196
|
+
try {
|
|
197
|
+
const drop = dropWindowsTcpRowsForLocalPort(port);
|
|
198
|
+
if (drop.accessDenied > 0) accessDenied = true;
|
|
199
|
+
} catch { /* best-effort */ }
|
|
200
|
+
}
|
|
201
|
+
if (holders.length === 0 && await strictRuntimePortAvailable(port, hostname)) {
|
|
202
|
+
return { ok: true, accessDenied };
|
|
203
|
+
}
|
|
204
|
+
await sleep(500);
|
|
205
|
+
}
|
|
206
|
+
return { ok: false, accessDenied };
|
|
207
|
+
}
|
|
208
|
+
|
|
105
209
|
function packageLauncherPath(): string {
|
|
106
210
|
// This module lives at src/update/job.ts — the launcher is <pkg-root>/bin/ocx.mjs.
|
|
107
|
-
|
|
211
|
+
// After `npm install -g`, import.meta.url can still point at npm's renamed temp
|
|
212
|
+
// tree (`@bitkyc08/.opencodex-*`). Prefer the live package path when that happens.
|
|
213
|
+
const fromMeta = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "ocx.mjs");
|
|
214
|
+
if (!/[\\/]\.opencodex-/i.test(fromMeta) && existsSync(fromMeta)) return fromMeta;
|
|
215
|
+
const live = fromMeta.replace(/[\\/]@bitkyc08[\\/]\.opencodex-[^\\/]+/i, `${sep}@bitkyc08${sep}opencodex`);
|
|
216
|
+
if (live !== fromMeta && existsSync(live)) return live;
|
|
217
|
+
return fromMeta;
|
|
108
218
|
}
|
|
109
219
|
|
|
110
220
|
function formatCommand(bin: string, args: string[]): string {
|
|
@@ -267,18 +377,68 @@ export function staleActiveUpdateJobReason(
|
|
|
267
377
|
return null;
|
|
268
378
|
}
|
|
269
379
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
380
|
+
/**
|
|
381
|
+
* Spawn the GUI update worker without inheriting the proxy's LISTEN socket.
|
|
382
|
+
*
|
|
383
|
+
* On Windows, `spawn(..., { detached: true, stdio: "ignore" })` still inherits
|
|
384
|
+
* inheritable handles — including Bun.serve's LISTEN socket. After stop-first
|
|
385
|
+
* update kills the proxy PID, netstat keeps showing that dead PID as LISTENING
|
|
386
|
+
* until every inheriting child exits. The update worker was that child, so the
|
|
387
|
+
* port stayed busy for the whole job. Launch via PowerShell Start-Process so
|
|
388
|
+
* the worker is a fresh process tree with no inherited LISTEN handle.
|
|
389
|
+
*/
|
|
390
|
+
export function spawnGuiUpdateWorker(
|
|
391
|
+
jobId: string,
|
|
392
|
+
channel: Channel,
|
|
393
|
+
restart: boolean,
|
|
394
|
+
): UpdateWorkerProcess {
|
|
395
|
+
const script = process.argv[1];
|
|
396
|
+
const args = [
|
|
397
|
+
script,
|
|
398
|
+
"__gui-update-worker",
|
|
399
|
+
jobId,
|
|
400
|
+
channel,
|
|
401
|
+
restart ? "restart" : "no-restart",
|
|
402
|
+
];
|
|
403
|
+
if (process.platform !== "win32") {
|
|
404
|
+
return spawn(process.execPath, args, {
|
|
276
405
|
detached: true,
|
|
277
406
|
stdio: "ignore",
|
|
278
407
|
windowsHide: true,
|
|
279
408
|
env: { ...process.env, OCX_SERVICE: "1" },
|
|
280
|
-
}
|
|
281
|
-
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Single -ArgumentList string with CommandLineToArgvW quoting so paths with
|
|
413
|
+
// spaces survive Start-Process's space-join (array elements lose outer quotes).
|
|
414
|
+
const psQuote = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
415
|
+
const argumentList = buildWindowsElevatedArgumentList(args);
|
|
416
|
+
const ps = [
|
|
417
|
+
`$env:OCX_SERVICE = '1'`,
|
|
418
|
+
`$p = Start-Process -FilePath ${psQuote(process.execPath)} -ArgumentList ${psQuote(argumentList)} -WindowStyle Hidden -PassThru`,
|
|
419
|
+
`if (-not $p) { exit 1 }`,
|
|
420
|
+
`Write-Output $p.Id`,
|
|
421
|
+
].join("; ");
|
|
422
|
+
const launched = spawnSync(
|
|
423
|
+
resolveTrustedWindowsPowerShellExe(),
|
|
424
|
+
["-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", ps],
|
|
425
|
+
{ encoding: "utf8", windowsHide: true, timeout: 15_000 },
|
|
426
|
+
);
|
|
427
|
+
const pid = Number(String(launched.stdout ?? "").trim().split(/\r?\n/).pop());
|
|
428
|
+
if (launched.status !== 0 || !Number.isSafeInteger(pid) || pid <= 0) {
|
|
429
|
+
const detail = String(launched.stderr ?? launched.stdout ?? "").trim() || `status ${launched.status}`;
|
|
430
|
+
throw new Error(`Windows update worker Start-Process failed: ${detail}`);
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
pid,
|
|
434
|
+
unref() { /* Start-Process already detached */ },
|
|
435
|
+
once() { /* startup errors are not wired across Start-Process */ },
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const defaultStartUpdateJobDeps: StartUpdateJobDeps = {
|
|
440
|
+
checkForUpdateFn: channel => checkForUpdate(channel),
|
|
441
|
+
spawnWorkerFn: spawnGuiUpdateWorker,
|
|
282
442
|
isProcessAliveFn: isProcessAlive,
|
|
283
443
|
nowMs: Date.now,
|
|
284
444
|
};
|
|
@@ -369,18 +529,90 @@ function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], time
|
|
|
369
529
|
return { status: result.status, signal: result.signal };
|
|
370
530
|
}
|
|
371
531
|
|
|
372
|
-
|
|
532
|
+
/**
|
|
533
|
+
* Tear down anything that would make `ocx start` exit 1 with "already running"
|
|
534
|
+
* (service wrapper respawn, stale pidfile + live /healthz) before a pinned spawn.
|
|
535
|
+
*/
|
|
536
|
+
function preparePortForPinnedStart(
|
|
537
|
+
job: UpdateJobState,
|
|
538
|
+
port: number,
|
|
539
|
+
listPids: (port: number) => number[],
|
|
540
|
+
aliveFn: (pid: number) => boolean,
|
|
541
|
+
verifyOcx: (pid: number) => number | null = verifyPidIdentity,
|
|
542
|
+
): void {
|
|
543
|
+
stopWindowsServiceWrappersBestEffort();
|
|
544
|
+
const pid = readPid();
|
|
545
|
+
if (pid) {
|
|
546
|
+
updateJob(job, {}, `Clearing pre-start proxy PID ${pid} before pinned start.`);
|
|
547
|
+
try { killProxy(pid); } catch { /* best-effort */ }
|
|
548
|
+
removePid(pid);
|
|
549
|
+
} else {
|
|
550
|
+
removePid();
|
|
551
|
+
}
|
|
552
|
+
removeRuntimePort();
|
|
553
|
+
for (const holder of listPids(port)) {
|
|
554
|
+
if (holder === process.pid || !aliveFn(holder)) continue;
|
|
555
|
+
if (verifyOcx(holder) !== holder) {
|
|
556
|
+
updateJob(
|
|
557
|
+
job,
|
|
558
|
+
{},
|
|
559
|
+
`Leaving foreign listen holder PID ${holder} on port ${port}; refusing collateral kill.`,
|
|
560
|
+
);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
updateJob(job, {}, `Stopping live ocx listen holder PID ${holder} on port ${port} before pinned start.`);
|
|
564
|
+
try { killProxy(holder); } catch { /* best-effort */ }
|
|
565
|
+
}
|
|
566
|
+
// Match reclaimListenPort: never SetTcpEntry while a live holder remains.
|
|
567
|
+
const liveRemain = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid));
|
|
568
|
+
if (process.platform === "win32" && liveRemain.length === 0) {
|
|
569
|
+
try { dropWindowsTcpRowsForLocalPort(port); } catch { /* best-effort */ }
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function spawnDetachedStart(
|
|
574
|
+
job: UpdateJobState,
|
|
575
|
+
installer: Installer,
|
|
576
|
+
port?: number,
|
|
577
|
+
): ChildProcess {
|
|
373
578
|
const cmd = restartCommand(false, installer, packageLauncherPath(), port);
|
|
374
579
|
const env = { ...process.env };
|
|
375
580
|
delete env.OCX_SERVICE;
|
|
376
581
|
updateJob(job, {}, `$ ${cmd.display}`);
|
|
582
|
+
let stdio: "ignore" | [ "ignore", number, number ] = "ignore";
|
|
583
|
+
let logFd: number | undefined;
|
|
584
|
+
try {
|
|
585
|
+
const logPath = join(getConfigDir(), "update-pinned-start.log");
|
|
586
|
+
mkdirSync(getConfigDir(), { recursive: true });
|
|
587
|
+
logFd = openSync(logPath, "a");
|
|
588
|
+
writeSync(logFd, `\n--- ${new Date().toISOString()} ---\n$ ${cmd.display}\n`);
|
|
589
|
+
stdio = ["ignore", logFd, logFd];
|
|
590
|
+
} catch { /* fall back to ignored stdio */ }
|
|
377
591
|
const child = spawn(cmd.bin, cmd.args, {
|
|
378
592
|
detached: true,
|
|
379
|
-
stdio
|
|
593
|
+
stdio,
|
|
380
594
|
windowsHide: true,
|
|
381
595
|
env,
|
|
382
596
|
});
|
|
597
|
+
child.once("error", err => {
|
|
598
|
+
try {
|
|
599
|
+
updateJob(job, {}, `Pinned start spawn error: ${err instanceof Error ? err.message : String(err)}`);
|
|
600
|
+
} catch { /* best-effort */ }
|
|
601
|
+
});
|
|
602
|
+
// Foreground `ocx start` keeps the listen process; EADDRINUSE/ghost races exit quickly
|
|
603
|
+
// with stdio ignored — surface that so the job log explains a silent miss.
|
|
604
|
+
child.once("exit", (code, signal) => {
|
|
605
|
+
if (code === 0 && !signal) return;
|
|
606
|
+
try {
|
|
607
|
+
updateJob(
|
|
608
|
+
job,
|
|
609
|
+
{},
|
|
610
|
+
`Pinned start exited early (code=${code ?? "null"} signal=${signal ?? "null"}).`,
|
|
611
|
+
);
|
|
612
|
+
} catch { /* best-effort */ }
|
|
613
|
+
});
|
|
383
614
|
child.unref();
|
|
615
|
+
return child;
|
|
384
616
|
}
|
|
385
617
|
|
|
386
618
|
/** Identity snapshot used to prove an npm self-update actually replaced the pre-update process. */
|
|
@@ -394,9 +626,17 @@ export interface RestartIo {
|
|
|
394
626
|
waitForPort?: typeof reclaimListenPort;
|
|
395
627
|
spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void;
|
|
396
628
|
serviceInstalledFn?: () => boolean;
|
|
629
|
+
/**
|
|
630
|
+
* After a service reinstall exits 0, only trust the service path when this is true.
|
|
631
|
+
* Defaults to {@link isServiceViable} — installed-but-stale assets must fall through
|
|
632
|
+
* to a direct proxy start so dashboard updates never leave /healthz dead.
|
|
633
|
+
*/
|
|
634
|
+
serviceViableFn?: () => boolean;
|
|
397
635
|
probeProxy?: (port: number, hostname?: string) => Promise<boolean>;
|
|
398
636
|
/** Richer /healthz read for update-correlated restart evidence (pid + version). */
|
|
399
637
|
probeProxyIdentity?: (port: number, hostname?: string) => Promise<RestartProxyIdentity | null>;
|
|
638
|
+
/** Override the /healthz appearance window (default {@link RESTART_HEALTH_TIMEOUT_MS}). */
|
|
639
|
+
healthTimeoutMs?: number;
|
|
400
640
|
sleepMs?: (ms: number) => Promise<void>;
|
|
401
641
|
now?: () => number;
|
|
402
642
|
/** Service-mode install/reinstall command (defaults to spawnSync via runLoggedCommand). */
|
|
@@ -411,6 +651,21 @@ export interface RestartIo {
|
|
|
411
651
|
captured?: { port: number; hostname: string; oldPid?: number },
|
|
412
652
|
io?: RestartIo,
|
|
413
653
|
) => Promise<void>;
|
|
654
|
+
/**
|
|
655
|
+
* PIDs currently LISTENing on the captured port. Used to widen the post-update
|
|
656
|
+
* kill allowlist beyond the pre-update PID (Windows often leaves a respawned
|
|
657
|
+
* ocx child that would otherwise be treated as a protected listener).
|
|
658
|
+
*/
|
|
659
|
+
listListenPidsFn?: (port: number) => number[];
|
|
660
|
+
/**
|
|
661
|
+
* Full listen-PID scan (ok/fail). When omitted, {@link scanListenPids} is used
|
|
662
|
+
* so a probe failure is not mistaken for "no listeners".
|
|
663
|
+
*/
|
|
664
|
+
scanListenPidsFn?: (port: number) => ListenPidScan;
|
|
665
|
+
/** Identity check for listeners discovered via {@link listListenPidsFn}. */
|
|
666
|
+
verifyOcxFn?: (pid: number) => number | null;
|
|
667
|
+
/** Liveness check when deciding whether a reclaim timeout still has live holders. */
|
|
668
|
+
isAliveFn?: (pid: number) => boolean;
|
|
414
669
|
}
|
|
415
670
|
|
|
416
671
|
async function restartAfterUpdate(
|
|
@@ -437,60 +692,291 @@ async function restartAfterUpdate(
|
|
|
437
692
|
}
|
|
438
693
|
const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port, svcArgs);
|
|
439
694
|
const waitFn = io.waitForPort ?? reclaimListenPort;
|
|
440
|
-
const
|
|
695
|
+
const listPids = io.listListenPidsFn ?? listListenPids;
|
|
696
|
+
const verifyOcx = io.verifyOcxFn ?? verifyPidIdentity;
|
|
697
|
+
const aliveFn = io.isAliveFn ?? isProcessAlive;
|
|
698
|
+
// Pre-update PID plus any ocx still LISTENing on the captured port. After a
|
|
699
|
+
// stop-first npm self-update Windows often leaves a respawned bun/node child
|
|
700
|
+
// that is not the captured PID; treating it as protected blocks reclaim and
|
|
701
|
+
// the direct-start fallback never binds.
|
|
702
|
+
const reclaimKillAllowlist = (): number[] => {
|
|
703
|
+
const allow = new Set<number>();
|
|
704
|
+
if (oldPid != null) allow.add(oldPid);
|
|
705
|
+
for (const pid of listPids(port)) {
|
|
706
|
+
if (pid === process.pid) continue;
|
|
707
|
+
if (verifyOcx(pid) === pid) allow.add(pid);
|
|
708
|
+
}
|
|
709
|
+
return [...allow];
|
|
710
|
+
};
|
|
711
|
+
const reclaimOptsFor = (onlyKillPids: number[]) => ({
|
|
441
712
|
timeoutMs: RESTART_PORT_RECLAIM_MS,
|
|
442
713
|
intervalMs: 100,
|
|
443
714
|
scanIntervalMs: 500,
|
|
444
|
-
killOcxHolders:
|
|
445
|
-
|
|
446
|
-
|
|
715
|
+
killOcxHolders: true,
|
|
716
|
+
// Windows scheduler wrappers can mint a *new* bun PID during the wait; keep
|
|
717
|
+
// killing every ocx listener on this port, not only the pre-wait snapshot.
|
|
718
|
+
// npm rename trees under `@bitkyc08/.opencodex-*` are classified as ocx by
|
|
719
|
+
// isOcxStartCommandLine — never kill unknown foreign claimants on this port.
|
|
720
|
+
killAllOcxOnPort: true,
|
|
721
|
+
onlyKillPids,
|
|
722
|
+
});
|
|
447
723
|
|
|
448
724
|
if (serviceInstalled) {
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
725
|
+
// schtasks /end often leaves the hidden cmd/wscript wrapper alive; its :loop
|
|
726
|
+
// respawns `ocx start` a few seconds later and races port reclaim. End the
|
|
727
|
+
// task again and best-effort kill those wrappers before we touch the socket.
|
|
728
|
+
stopWindowsServiceWrappersBestEffort();
|
|
729
|
+
// Stop-first update already unloaded the service; reclaim the socket, then
|
|
730
|
+
// reinstall wrappers that bake `--port`.
|
|
731
|
+
const preServiceAllow = reclaimKillAllowlist();
|
|
732
|
+
const freed = await waitFn(port, hostname, reclaimOptsFor(preServiceAllow));
|
|
733
|
+
let skipServiceInstall = false;
|
|
734
|
+
// Windows GUI update worker sets OCX_SERVICE=1 and is never elevated.
|
|
735
|
+
// `schtasks /create` will UAC-fail and can race the subsequent direct start.
|
|
736
|
+
// Keep systemd/launchd reinstall on non-Windows supervisors.
|
|
737
|
+
if (process.platform === "win32" && process.env.OCX_SERVICE === "1") {
|
|
738
|
+
updateJob(job, {}, "Skipping service reinstall from the non-elevated update worker; falling back to a direct proxy start.");
|
|
739
|
+
skipServiceInstall = true;
|
|
454
740
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
741
|
+
if (!freed && !skipServiceInstall) {
|
|
742
|
+
updateJob(
|
|
743
|
+
job,
|
|
744
|
+
{},
|
|
745
|
+
`Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s; refusing to hop — reinstall may fail until the port is free.`
|
|
746
|
+
+ ` ${formatPortHolders(port, listPids, verifyOcx, preServiceAllow)}`,
|
|
747
|
+
);
|
|
748
|
+
const liveAfter = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid));
|
|
749
|
+
if (liveAfter.length === 0) {
|
|
750
|
+
// Non-elevated `service install` will UAC-fail anyway; skip straight to
|
|
751
|
+
// the direct-start fallthrough instead of burning another minute on it.
|
|
752
|
+
updateJob(job, {}, "Skipping service reinstall after reclaim timeout with no live holders; falling back to a direct proxy start.");
|
|
753
|
+
skipServiceInstall = true;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (!skipServiceInstall) {
|
|
757
|
+
const prevBake = process.env.OCX_BAKE_PORT;
|
|
758
|
+
process.env.OCX_BAKE_PORT = String(Math.trunc(port));
|
|
759
|
+
let serviceOk = false;
|
|
760
|
+
try {
|
|
761
|
+
const run = io.runService ?? ((j, bin, args) => runLoggedCommand(j, bin, args, RESTART_TIMEOUT_MS));
|
|
762
|
+
const result = run(job, cmd.bin, cmd.args);
|
|
763
|
+
serviceOk = result.status === 0;
|
|
764
|
+
if (!serviceOk) {
|
|
765
|
+
// On Windows, `schtasks /create` requires an elevated token. The update worker
|
|
766
|
+
// inherits the (non-admin) proxy's privileges, so a service-managed install
|
|
767
|
+
// updated from the GUI or a normal terminal fails here with access denied.
|
|
768
|
+
// Falling back to a direct proxy start keeps the update from leaving the proxy
|
|
769
|
+
// stopped; the stale service manager can be refreshed later with an admin
|
|
770
|
+
// `ocx service install`.
|
|
771
|
+
updateJob(job, {}, `Service reinstall failed (exit ${result.status ?? "?"}); falling back to a direct proxy start. Run 'ocx service install' as administrator to refresh the background service manager.`);
|
|
772
|
+
}
|
|
773
|
+
} finally {
|
|
774
|
+
if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
|
|
775
|
+
else process.env.OCX_BAKE_PORT = prevBake;
|
|
776
|
+
}
|
|
777
|
+
if (serviceOk) {
|
|
778
|
+
// Exit 0 is not enough: a reinstall can leave stale/missing assets (or a
|
|
779
|
+
// disabled/conflicting manager) that never brings /healthz back. Fall through
|
|
780
|
+
// to a direct start so browser-dashboard updates do not require a viable
|
|
781
|
+
// Background Service for recovery.
|
|
782
|
+
const viable = (io.serviceViableFn ?? isServiceViable)();
|
|
783
|
+
if (viable) return;
|
|
784
|
+
updateJob(
|
|
785
|
+
job,
|
|
786
|
+
{},
|
|
787
|
+
"Service reinstall exited 0 but the background service is not viable (stale or missing assets, disabled, or conflicting); falling back to a direct proxy start.",
|
|
788
|
+
);
|
|
470
789
|
}
|
|
471
|
-
} finally {
|
|
472
|
-
if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
|
|
473
|
-
else process.env.OCX_BAKE_PORT = prevBake;
|
|
474
790
|
}
|
|
475
|
-
if (serviceOk) return;
|
|
476
791
|
// Fall through to the direct proxy start below so the update never leaves the
|
|
477
|
-
// proxy stopped when the service reinstall could not run
|
|
792
|
+
// proxy stopped when the service reinstall could not run or did not leave a
|
|
793
|
+
// viable supervisor.
|
|
478
794
|
}
|
|
479
795
|
|
|
480
796
|
const pid = readPid();
|
|
481
797
|
if (pid) {
|
|
482
798
|
updateJob(job, {}, `Stopping current proxy PID ${pid}.`);
|
|
483
|
-
|
|
799
|
+
try {
|
|
800
|
+
killProxy(pid);
|
|
801
|
+
} catch {
|
|
802
|
+
// A PID that resists taskkill must not abort recovery: reclaim + pinned start
|
|
803
|
+
// below are the path that repairs stuck Windows listeners.
|
|
804
|
+
}
|
|
484
805
|
}
|
|
806
|
+
if (serviceInstalled) stopWindowsServiceWrappersBestEffort();
|
|
485
807
|
// Reclaim the captured port before the pinned start. Spawning `--port` while the old
|
|
486
808
|
// socket is still busy is how Windows updates used to fail health checks (or hop).
|
|
487
|
-
//
|
|
488
|
-
const
|
|
809
|
+
// killAllOcxOnPort covers wrapper-respawned bun PIDs minted during the wait.
|
|
810
|
+
const directAllow = reclaimKillAllowlist();
|
|
811
|
+
const freed = await waitFn(port, hostname, reclaimOptsFor(directAllow));
|
|
489
812
|
if (!freed) {
|
|
490
|
-
|
|
813
|
+
const liveHolders = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid));
|
|
814
|
+
updateJob(
|
|
815
|
+
job,
|
|
816
|
+
{},
|
|
817
|
+
`Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s (reclaim could not free the socket).`
|
|
818
|
+
+ ` ${formatPortHolders(port, listPids, verifyOcx, directAllow)}`,
|
|
819
|
+
);
|
|
820
|
+
if (liveHolders.length > 0) {
|
|
821
|
+
updateJob(job, {}, `Live holder(s) remain on port ${port}; not starting on another port. Retry 'ocx start --port ${port}'.`);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
// Dead PIDs can still own LISTEN rows. SetTcpEntry needs elevation (rc 317 on a
|
|
825
|
+
// normal update worker), so poll until netstat is empty and the start runtime can bind.
|
|
826
|
+
updateJob(
|
|
827
|
+
job,
|
|
828
|
+
{},
|
|
829
|
+
`No live holders on port ${port}; waiting for ghost LISTEN rows to clear before pinned start.`,
|
|
830
|
+
);
|
|
831
|
+
// Injected spawnStart is the unit-test seam — skip the long OS wait.
|
|
832
|
+
if (!io.spawnStart) {
|
|
833
|
+
const sleep = io.sleepMs ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
|
|
834
|
+
const cleared = await waitForGhostListenClear(port, hostname, listPids, 90_000, sleep);
|
|
835
|
+
if (cleared.accessDenied) {
|
|
836
|
+
updateJob(job, {}, "SetTcpEntry is non-elevated (access denied); relying on OS ghost-LISTEN expiry.");
|
|
837
|
+
}
|
|
838
|
+
if (!cleared.ok) {
|
|
839
|
+
updateJob(
|
|
840
|
+
job,
|
|
841
|
+
{},
|
|
842
|
+
`Ghost LISTEN rows on port ${port} did not clear in time. `
|
|
843
|
+
+ `${formatPortHolders(port, listPids, verifyOcx, directAllow)} `
|
|
844
|
+
+ `Retry 'ocx start --port ${port}'.`,
|
|
845
|
+
);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// Injected spawnStart keeps unit tests deterministic (one call). Production path
|
|
851
|
+
// retries on missing /healthz after prepare + ghost-LISTEN clear.
|
|
852
|
+
if (io.spawnStart) {
|
|
853
|
+
io.spawnStart(job, job.installer, port);
|
|
491
854
|
return;
|
|
492
855
|
}
|
|
493
|
-
|
|
856
|
+
const sleep = io.sleepMs ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
|
|
857
|
+
const probe = io.probeProxy ?? (async (p: number, host?: string) => (
|
|
858
|
+
!!(await proxyIdentityAt(p, { hostname: host }))
|
|
859
|
+
));
|
|
860
|
+
const probeIdentity = io.probeProxyIdentity ?? defaultProbeProxyIdentity;
|
|
861
|
+
const expectedVersion = typeof job.latestVersion === "string" && job.latestVersion.length > 0
|
|
862
|
+
? job.latestVersion
|
|
863
|
+
: null;
|
|
864
|
+
// Service wrappers can respawn a listener during reclaim; if it already reports the
|
|
865
|
+
// update target version, do not spawn a second start that exits "already running".
|
|
866
|
+
{
|
|
867
|
+
const identity = await probeIdentity(port, hostname);
|
|
868
|
+
if (identity && expectedVersion && identity.version === expectedVersion) {
|
|
869
|
+
updateJob(
|
|
870
|
+
job,
|
|
871
|
+
{},
|
|
872
|
+
`Proxy already healthy on ${hostname}:${port} at ${expectedVersion}; skipping pinned start.`,
|
|
873
|
+
);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
const attempts = 3;
|
|
878
|
+
// Longer than published hard-pin reclaim (30s) so a slow start can still report healthy.
|
|
879
|
+
const perAttemptHealthMs = 70_000;
|
|
880
|
+
let lastChild: ChildProcess | null = null;
|
|
881
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
882
|
+
if (attempt > 1) {
|
|
883
|
+
updateJob(
|
|
884
|
+
job,
|
|
885
|
+
{},
|
|
886
|
+
`Pinned start attempt ${attempt - 1} did not become healthy on port ${port}; `
|
|
887
|
+
+ `retrying (${attempt}/${attempts}).`,
|
|
888
|
+
);
|
|
889
|
+
if (lastChild?.pid && aliveFn(lastChild.pid)) {
|
|
890
|
+
try { killProxy(lastChild.pid); } catch { /* best-effort */ }
|
|
891
|
+
}
|
|
892
|
+
lastChild = null;
|
|
893
|
+
}
|
|
894
|
+
preparePortForPinnedStart(job, port, listPids, aliveFn, verifyOcx);
|
|
895
|
+
const ready = await waitForGhostListenClear(
|
|
896
|
+
port,
|
|
897
|
+
hostname,
|
|
898
|
+
listPids,
|
|
899
|
+
attempt === 1 ? (freed ? 15_000 : 5_000) : 30_000,
|
|
900
|
+
sleep,
|
|
901
|
+
);
|
|
902
|
+
if (!ready.ok) {
|
|
903
|
+
updateJob(
|
|
904
|
+
job,
|
|
905
|
+
{},
|
|
906
|
+
`Port ${port} not bindable before pinned start attempt ${attempt}; `
|
|
907
|
+
+ `${formatPortHolders(port, listPids, verifyOcx, directAllow)}`,
|
|
908
|
+
);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
lastChild = spawnDetachedStart(job, job.installer, port);
|
|
912
|
+
const healthDeadline = Date.now() + perAttemptHealthMs;
|
|
913
|
+
while (Date.now() < healthDeadline) {
|
|
914
|
+
if (await probe(port, hostname)) return;
|
|
915
|
+
await sleep(500);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// Exhausted retries: do not leave a hung pinned-start child owning the port.
|
|
919
|
+
if (lastChild?.pid && aliveFn(lastChild.pid)) {
|
|
920
|
+
try { killProxy(lastChild.pid); } catch { /* best-effort */ }
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Compact listen-holder summary for update-job logs when reclaim fails. */
|
|
925
|
+
function formatPortHolders(
|
|
926
|
+
port: number,
|
|
927
|
+
listPids: (port: number) => number[],
|
|
928
|
+
verifyOcx: (pid: number) => number | null,
|
|
929
|
+
allow: number[],
|
|
930
|
+
): string {
|
|
931
|
+
const allowSet = new Set(allow);
|
|
932
|
+
const holders = listPids(port).map(pid => {
|
|
933
|
+
const tags = [
|
|
934
|
+
verifyOcx(pid) === pid ? "ocx" : "foreign",
|
|
935
|
+
allowSet.has(pid) ? "allow" : "deny",
|
|
936
|
+
isProcessAlive(pid) ? "live" : "dead",
|
|
937
|
+
];
|
|
938
|
+
return `${pid}(${tags.join(",")})`;
|
|
939
|
+
});
|
|
940
|
+
return `holders=[${holders.join(", ") || "none"}] allow=[${allow.join(", ") || "none"}]`;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Stop the installed Windows backend and best-effort kill surviving :loop wrappers. */
|
|
944
|
+
function stopWindowsServiceWrappersBestEffort(): void {
|
|
945
|
+
if (process.platform !== "win32") return;
|
|
946
|
+
try {
|
|
947
|
+
if (readServiceBackend() === "native") {
|
|
948
|
+
stopWinswService();
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
stopWindows();
|
|
952
|
+
} catch { /* already stopped */ }
|
|
953
|
+
killWindowsServiceWrapperProcesses();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Best-effort termination of surviving Windows scheduler launcher/wrapper processes.
|
|
958
|
+
* `schtasks /end` ends the task instance but often leaves wscript/cmd running the
|
|
959
|
+
* `:loop` batch, which brings the proxy back during post-update reclaim.
|
|
960
|
+
*/
|
|
961
|
+
function killWindowsServiceWrapperProcesses(): void {
|
|
962
|
+
if (process.platform !== "win32") return;
|
|
963
|
+
try {
|
|
964
|
+
const ps = [
|
|
965
|
+
"$pats = @('opencodex-service.cmd','opencodex-service-launcher.vbs');",
|
|
966
|
+
"Get-CimInstance Win32_Process | Where-Object {",
|
|
967
|
+
" if ($_.ProcessId -eq $PID) { return $false };",
|
|
968
|
+
" $c = $_.CommandLine; if (-not $c) { return $false };",
|
|
969
|
+
" foreach ($p in $pats) { if ($c -like ('*' + $p + '*')) { return $true } };",
|
|
970
|
+
" $false",
|
|
971
|
+
"} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
|
|
972
|
+
].join(" ");
|
|
973
|
+
spawnSync(resolveTrustedWindowsPowerShellExe(), [
|
|
974
|
+
"-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden",
|
|
975
|
+
"-Command", ps,
|
|
976
|
+
], { stdio: "ignore", timeout: 5000, windowsHide: true });
|
|
977
|
+
} catch {
|
|
978
|
+
/* best-effort */
|
|
979
|
+
}
|
|
494
980
|
}
|
|
495
981
|
|
|
496
982
|
/** Exposed for tests: drives the non-service restart path with injected io. */
|
|
@@ -522,8 +1008,10 @@ async function awaitRestartedProxyHealthy(
|
|
|
522
1008
|
captured: { port: number; hostname: string },
|
|
523
1009
|
io: RestartIo = {},
|
|
524
1010
|
): Promise<AwaitHealthyResult> {
|
|
1011
|
+
// Fresh post-update starts are busy with catalog sync / OAuth; a single 750ms
|
|
1012
|
+
// /healthz miss must not fail the job. Use a longer probe and tolerate brief blips.
|
|
525
1013
|
const probe = io.probeProxy ?? (async (port: number, hostname?: string) => (
|
|
526
|
-
!!(await proxyIdentityAt(port, { hostname }))
|
|
1014
|
+
!!(await proxyIdentityAt(port, { hostname }, { timeoutMs: 2_000, attempts: 3 }))
|
|
527
1015
|
));
|
|
528
1016
|
const sleep = io.sleepMs ?? (async (ms: number) => {
|
|
529
1017
|
await new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -531,7 +1019,9 @@ async function awaitRestartedProxyHealthy(
|
|
|
531
1019
|
const now = io.now ?? (() => Date.now());
|
|
532
1020
|
const port = captured.port;
|
|
533
1021
|
const hostname = captured.hostname;
|
|
534
|
-
const startDeadline = now() + RESTART_HEALTH_TIMEOUT_MS;
|
|
1022
|
+
const startDeadline = now() + (io.healthTimeoutMs ?? RESTART_HEALTH_TIMEOUT_MS);
|
|
1023
|
+
/** Consecutive failed probes before the stability window counts as a flap. */
|
|
1024
|
+
const stabilityMissLimit = 3;
|
|
535
1025
|
|
|
536
1026
|
while (true) {
|
|
537
1027
|
// Always make one identity-aware probe at or after the boundary. A replacement
|
|
@@ -540,10 +1030,16 @@ async function awaitRestartedProxyHealthy(
|
|
|
540
1030
|
if (await probe(port, hostname)) {
|
|
541
1031
|
updateJob(job, {}, `Proxy reported healthy on ${hostname}:${port}; confirming it stays up...`);
|
|
542
1032
|
const stableUntil = now() + RESTART_STABILITY_WINDOW_MS;
|
|
1033
|
+
let misses = 0;
|
|
543
1034
|
while (now() < stableUntil) {
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
|
|
1035
|
+
if (await probe(port, hostname)) {
|
|
1036
|
+
misses = 0;
|
|
1037
|
+
} else {
|
|
1038
|
+
misses += 1;
|
|
1039
|
+
if (misses >= stabilityMissLimit) {
|
|
1040
|
+
updateJob(job, {}, `Proxy became unhealthy on ${hostname}:${port} during the stability window.`);
|
|
1041
|
+
return { ok: false, reason: "flapped" };
|
|
1042
|
+
}
|
|
547
1043
|
}
|
|
548
1044
|
await sleep(500);
|
|
549
1045
|
}
|
|
@@ -679,6 +1175,10 @@ export function npmSelfUpdateRestartEvidence(
|
|
|
679
1175
|
* and/or target version) so a surviving pre-update process cannot look like success.
|
|
680
1176
|
* After an explicit npm restart the same evidence is required again — health alone is
|
|
681
1177
|
* not enough when a no-op restart or failed port reclaim leaves the old proxy up.
|
|
1178
|
+
*
|
|
1179
|
+
* Browser-dashboard update recovery must not require a viable Background Service: when
|
|
1180
|
+
* no service is installed (or reinstall leaves a non-viable/stale manager), the explicit
|
|
1181
|
+
* path always falls through to a direct `ocx start --port` so /healthz can recover.
|
|
682
1182
|
*/
|
|
683
1183
|
export async function finishGuiUpdateRestart(
|
|
684
1184
|
job: UpdateJobState,
|
|
@@ -689,28 +1189,53 @@ export async function finishGuiUpdateRestart(
|
|
|
689
1189
|
if (installer === "npm") {
|
|
690
1190
|
const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)();
|
|
691
1191
|
if (serviceInstalled) {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1192
|
+
// Stop-first npm update leaves a dead PID's LISTEN row. Polling /healthz for the
|
|
1193
|
+
// full 30s against that zombie keeps ESTABLISHED TCBs alive and blocks bind.
|
|
1194
|
+
// If nothing live owns the port, skip straight to explicit restart. A failed
|
|
1195
|
+
// listener scan must not look like "no listeners" — fall back to /healthz.
|
|
1196
|
+
const aliveFn = io.isAliveFn ?? isProcessAlive;
|
|
1197
|
+
const scan: ListenPidScan = io.scanListenPidsFn
|
|
1198
|
+
? io.scanListenPidsFn(captured.port)
|
|
1199
|
+
: io.listListenPidsFn
|
|
1200
|
+
// Test seam: injected list is always a successful scan.
|
|
1201
|
+
? { ok: true, pids: io.listListenPidsFn(captured.port) }
|
|
1202
|
+
: scanListenPids(captured.port);
|
|
1203
|
+
const liveListeners = scan.ok
|
|
1204
|
+
? scan.pids.filter(pid => pid !== process.pid && aliveFn(pid))
|
|
1205
|
+
: null;
|
|
1206
|
+
if (liveListeners !== null && liveListeners.length === 0) {
|
|
1207
|
+
updateJob(job, {}, "npm self-update did not leave a live listener; performing explicit restart...");
|
|
1208
|
+
} else {
|
|
1209
|
+
if (!scan.ok) {
|
|
700
1210
|
updateJob(
|
|
701
1211
|
job,
|
|
702
1212
|
{},
|
|
703
|
-
|
|
1213
|
+
"Listener scan inconclusive after npm self-update; probing /healthz before deciding on explicit restart...",
|
|
704
1214
|
);
|
|
705
|
-
return true;
|
|
706
1215
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
1216
|
+
const already = await awaitRestartedProxyHealthy(job, captured, io);
|
|
1217
|
+
if (already.ok) {
|
|
1218
|
+
const identity = await (io.probeProxyIdentity ?? defaultProbeProxyIdentity)(
|
|
1219
|
+
captured.port,
|
|
1220
|
+
captured.hostname,
|
|
1221
|
+
);
|
|
1222
|
+
const evidence = npmSelfUpdateRestartEvidence(job, captured, identity);
|
|
1223
|
+
if (evidence.ok) {
|
|
1224
|
+
updateJob(
|
|
1225
|
+
job,
|
|
1226
|
+
{},
|
|
1227
|
+
`Proxy already healthy on ${captured.hostname}:${captured.port} after npm self-update (${evidence.detail}); skipping redundant restart.`,
|
|
1228
|
+
);
|
|
1229
|
+
return true;
|
|
1230
|
+
}
|
|
1231
|
+
updateJob(
|
|
1232
|
+
job,
|
|
1233
|
+
{},
|
|
1234
|
+
`npm self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`,
|
|
1235
|
+
);
|
|
1236
|
+
} else {
|
|
1237
|
+
updateJob(job, {}, "npm self-update did not leave a healthy proxy; performing explicit restart...");
|
|
1238
|
+
}
|
|
714
1239
|
}
|
|
715
1240
|
}
|
|
716
1241
|
}
|