Skip to content

Commit a1cdf49

Browse files
authored
fix(update): fall back to direct proxy start when service reinstall fails (#227)
* fix(update): fall back to direct proxy start when service reinstall fails On Windows, schtasks /create requires admin elevation. The update worker inherits the non-elevated proxy's privileges, so a service-managed install updated from the GUI or a normal terminal fails with access denied, leaving the proxy stopped. Both the GUI path (job.ts) and the CLI path (index.ts) now detect the service reinstall failure and fall back to spawning the proxy directly, so the update never leaves the user without a running proxy. The stale service manager can be refreshed later with an admin 'ocx service install'. * fix(update): mirror service-failure fallback in the npm launcher (bin/ocx.mjs) For npm global installs, 'ocx update' is intercepted by the Node launcher before Bun starts, so the src/update/index.ts fallback is never reached. Apply the same detached proxy start when schtasks /create fails with access denied, so the npm update path also never leaves the proxy stopped.
1 parent 62998b4 commit a1cdf49

4 files changed

Lines changed: 78 additions & 6 deletions

File tree

bin/ocx.mjs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,24 @@ function runNpmSelfUpdate() {
199199
try {
200200
const svcArgs = serviceReinstallArgs();
201201
const svc = spawnSync(process.execPath, svcArgs, { stdio: "inherit", windowsHide: true });
202-
if (svc.status !== 0) console.warn("opencodex: service refresh failed — run 'ocx service install' manually.");
202+
if (svc.status !== 0) {
203+
// On Windows, schtasks /create requires elevation. The launcher inherits the
204+
// user's (non-admin) token, so the service reinstall can fail with access
205+
// denied. Fall back to a direct detached proxy start so the update never
206+
// leaves the user without a running proxy.
207+
console.warn("opencodex: service refresh failed — starting the proxy directly instead.");
208+
console.warn(" Run 'ocx service install' as administrator to refresh the background service.");
209+
const env = { ...process.env };
210+
delete env.OCX_SERVICE;
211+
const child = spawn(process.execPath, [launcher, "start", "--port", String(bakePort)], {
212+
detached: true,
213+
stdio: "ignore",
214+
windowsHide: true,
215+
env,
216+
});
217+
child.unref();
218+
console.log(`Proxy starting on port ${bakePort}.`);
219+
}
203220
} finally {
204221
if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
205222
else process.env.OCX_BAKE_PORT = prevBake;

src/update/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { spawnSync } from "node:child_process";
1+
import { spawn, spawnSync } from "node:child_process";
22
import { readFileSync, readdirSync } from "node:fs";
33
import { fileURLToPath } from "node:url";
44
import { dirname, join } from "node:path";
@@ -260,7 +260,24 @@ export async function runUpdate(): Promise<void> {
260260
windowsHide: true,
261261
});
262262
if (svcStdio === "pipe") logSpawnOutput("", svc);
263-
if (svc.status !== 0) console.warn("⚠️ Service refresh failed — run 'ocx service install' manually.");
263+
if (svc.status !== 0) {
264+
// On Windows, schtasks /create requires elevation. The CLI inherits the
265+
// user's (non-admin) token, so the service reinstall can fail with access
266+
// denied. Fall back to a direct detached proxy start so the update never
267+
// leaves the user without a running proxy.
268+
console.warn("⚠️ Service refresh failed — starting the proxy directly instead.");
269+
console.warn(" Run 'ocx service install' as administrator to refresh the background service.");
270+
const env = { ...process.env };
271+
delete env.OCX_SERVICE;
272+
const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(capturedListen.port)], {
273+
detached: true,
274+
stdio: "ignore",
275+
windowsHide: true,
276+
env,
277+
});
278+
child.unref();
279+
console.log(`✅ Proxy starting on port ${capturedListen.port}.`);
280+
}
264281
} finally {
265282
if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
266283
else process.env.OCX_BAKE_PORT = prevBake;

src/update/job.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,17 +330,27 @@ async function restartAfterUpdate(
330330
}
331331
const prevBake = process.env.OCX_BAKE_PORT;
332332
process.env.OCX_BAKE_PORT = String(Math.trunc(port));
333+
let serviceOk = false;
333334
try {
334335
const run = io.runService ?? ((j, bin, args) => runLoggedCommand(j, bin, args, RESTART_TIMEOUT_MS));
335336
const result = run(job, cmd.bin, cmd.args);
336-
if (result.status !== 0) {
337-
throw new Error(`service restart failed (${cmd.display}, exit ${result.status ?? "?"})`);
337+
serviceOk = result.status === 0;
338+
if (!serviceOk) {
339+
// On Windows, `schtasks /create` requires an elevated token. The update worker
340+
// inherits the (non-admin) proxy's privileges, so a service-managed install
341+
// updated from the GUI or a normal terminal fails here with access denied.
342+
// Falling back to a direct proxy start keeps the update from leaving the proxy
343+
// stopped; the stale service manager can be refreshed later with an admin
344+
// `ocx service install`.
345+
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.`);
338346
}
339347
} finally {
340348
if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
341349
else process.env.OCX_BAKE_PORT = prevBake;
342350
}
343-
return;
351+
if (serviceOk) return;
352+
// Fall through to the direct proxy start below so the update never leaves the
353+
// proxy stopped when the service reinstall could not run.
344354
}
345355

346356
const pid = readPid();

tests/update-job.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,34 @@ describe("GUI update execution decisions", () => {
184184
}
185185
});
186186

187+
test("service reinstall failure falls back to a direct proxy start", async () => {
188+
const spawned: Array<{ port: number }> = [];
189+
const job: UpdateJobState = {
190+
id: "svc-fallback",
191+
status: "restarting",
192+
startedAt: new Date().toISOString(),
193+
updatedAt: new Date().toISOString(),
194+
currentVersion: "2.7.26",
195+
latestVersion: "2.7.28",
196+
channel: "latest",
197+
installer: "npm",
198+
restart: true,
199+
command: "",
200+
log: [],
201+
};
202+
writeFileSync(updateJobPath(job.id), JSON.stringify(job));
203+
await restartAfterUpdateForTests(job, { port: 19999, hostname: "127.0.0.1" }, {
204+
serviceInstalledFn: () => true,
205+
waitForPort: async () => true,
206+
runService: () => ({ status: 1 }),
207+
spawnStart: (_job, _installer, port) => {
208+
spawned.push({ port: port ?? 0 });
209+
},
210+
});
211+
// The fallback must fire: direct proxy start instead of throwing.
212+
expect(spawned).toEqual([{ port: 19999 }]);
213+
});
214+
187215
test("a running job prevents a second update job", () => {
188216
const now = new Date().toISOString();
189217
const job: UpdateJobState = {

0 commit comments

Comments
 (0)