@bitkyc08/opencodex 2.7.25 → 2.7.26

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/src/update/job.ts CHANGED
@@ -2,8 +2,9 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { atomicWriteFile, getConfigDir, readPid } from "../config";
5
+ import { atomicWriteFile, getConfigDir, loadConfig, readPid, readRuntimePort } from "../config";
6
6
  import { killProxy } from "../lib/process-control";
7
+ import { waitForPortAvailable } from "../server/ports";
7
8
  import { isServiceInstalled } from "../service";
8
9
  import {
9
10
  type Channel,
@@ -158,18 +159,23 @@ export function restartCommand(
158
159
  serviceInstalled: boolean,
159
160
  installer: Installer,
160
161
  launcher = packageLauncherPath(),
162
+ port?: number,
161
163
  ): { mode: "service" | "proxy"; bin: string; args: string[]; display: string } {
162
164
  const mode = serviceInstalled ? "service" : "proxy";
165
+ const pinPort = !serviceInstalled && typeof port === "number" && Number.isFinite(port) && port > 0;
166
+ const startArgs = pinPort
167
+ ? [launcher, "start", "--port", String(Math.trunc(port))]
168
+ : [launcher, "start"];
163
169
  if (installer === "npm") {
164
170
  const bin = nodeBin();
165
- const args = serviceInstalled ? [launcher, "service", "install"] : [launcher, "start"];
171
+ const args = serviceInstalled ? [launcher, "service", "install"] : startArgs;
166
172
  return { mode, bin, args, display: formatCommand(bin, args) };
167
173
  }
168
174
  // bun/source installs: restart via the current runtime executable + package launcher (both real
169
175
  // .exe files), NOT the `ocx.cmd` shim. Spawning a `.cmd` shell-less throws EINVAL on Windows
170
176
  // Node/Bun ≥18.20/20.12 (CVE-2024-27980 hardening) — the same class the npm path (nodeBin) avoids.
171
177
  const bin = process.execPath;
172
- const args = serviceInstalled ? [launcher, "service", "install"] : [launcher, "start"];
178
+ const args = serviceInstalled ? [launcher, "service", "install"] : startArgs;
173
179
  return { mode, bin, args, display: formatCommand(bin, args) };
174
180
  }
175
181
 
@@ -264,8 +270,8 @@ function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], time
264
270
  return { status: result.status, signal: result.signal };
265
271
  }
266
272
 
267
- function spawnDetachedStart(job: UpdateJobState, installer: Installer): void {
268
- const cmd = restartCommand(false, installer);
273
+ function spawnDetachedStart(job: UpdateJobState, installer: Installer, port?: number): void {
274
+ const cmd = restartCommand(false, installer, packageLauncherPath(), port);
269
275
  const env = { ...process.env };
270
276
  delete env.OCX_SERVICE;
271
277
  updateJob(job, {}, `$ ${cmd.display}`);
@@ -278,9 +284,26 @@ function spawnDetachedStart(job: UpdateJobState, installer: Installer): void {
278
284
  child.unref();
279
285
  }
280
286
 
281
- function restartAfterUpdate(job: UpdateJobState): void {
282
- const serviceInstalled = isServiceInstalled();
283
- const cmd = restartCommand(serviceInstalled, job.installer);
287
+ /** Test seam: the wait/spawn pair is injectable so the restart path is verifiable. */
288
+ export interface RestartIo {
289
+ waitForPort?: typeof waitForPortAvailable;
290
+ spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void;
291
+ serviceInstalledFn?: () => boolean;
292
+ }
293
+
294
+ async function restartAfterUpdate(
295
+ job: UpdateJobState,
296
+ captured?: { port: number; hostname: string },
297
+ io: RestartIo = {},
298
+ ): Promise<void> {
299
+ const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)();
300
+ const config = loadConfig();
301
+ // The stop-first update flow has already cleared pid/runtime state by the time we run,
302
+ // so the pre-update capture (taken before the update command) is the authoritative
303
+ // port to wait on; config is only the cold-start fallback.
304
+ const port = captured?.port ?? config.port ?? 10100;
305
+ const hostname = captured?.hostname ?? config.hostname ?? "127.0.0.1";
306
+ const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port);
284
307
  if (serviceInstalled) {
285
308
  const result = runLoggedCommand(job, cmd.bin, cmd.args, RESTART_TIMEOUT_MS);
286
309
  if (result.status !== 0) {
@@ -294,13 +317,38 @@ function restartAfterUpdate(job: UpdateJobState): void {
294
317
  updateJob(job, {}, `Stopping current proxy PID ${pid}.`);
295
318
  killProxy(pid);
296
319
  }
297
- spawnDetachedStart(job, job.installer);
320
+ // The old socket can stay busy briefly after stop (Windows taskkill drain, or the
321
+ // stop-first update path that already killed the proxy before we got here) — wait
322
+ // unconditionally on the captured port so the pinned start does not race the drain.
323
+ const waitFn = io.waitForPort ?? waitForPortAvailable;
324
+ const freed = await waitFn(port, hostname, { timeoutMs: 2000, intervalMs: 25 });
325
+ if (!freed) {
326
+ updateJob(job, {}, `Port ${port} still busy after stop; starting with --port ${port} anyway.`);
327
+ }
328
+ (io.spawnStart ?? spawnDetachedStart)(job, job.installer, port);
298
329
  }
299
330
 
300
- export function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boolean): void {
331
+ /** Exposed for tests: drives the non-service restart path with injected io. */
332
+ export function restartAfterUpdateForTests(
333
+ job: UpdateJobState,
334
+ captured: { port: number; hostname: string },
335
+ io: RestartIo,
336
+ ): Promise<void> {
337
+ return restartAfterUpdate(job, captured, io);
338
+ }
339
+
340
+ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boolean): Promise<void> {
301
341
  let job = readUpdateJob(jobId);
302
342
  const check = checkForUpdate(channel);
303
343
  const now = new Date().toISOString();
344
+ // Capture the live listen target BEFORE the update command runs: the stop-first update
345
+ // flow clears pid/runtime state, so this is the last moment the real port is knowable.
346
+ const rt = readRuntimePort();
347
+ const preUpdateConfig = loadConfig();
348
+ const captured = {
349
+ port: rt?.port ?? preUpdateConfig.port ?? 10100,
350
+ hostname: rt?.hostname ?? preUpdateConfig.hostname ?? "127.0.0.1",
351
+ };
304
352
  if (!job) {
305
353
  job = {
306
354
  id: jobId,
@@ -362,7 +410,7 @@ export function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boo
362
410
 
363
411
  if (restart) {
364
412
  job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy...");
365
- restartAfterUpdate(job);
413
+ await restartAfterUpdate(job, captured);
366
414
  updateJob(job, { status: "succeeded", restarted: true }, "Restart requested.");
367
415
  return;
368
416
  }