Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/adr/0001-gui-update-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ return and remain healthy for a short stability window before marking the job su
keeps `update-job.json` honest on Windows cases where npm leaves the bundled Bun runtime in a bad
state and the restarted proxy dies a few seconds later.

For npm installs specifically, `node ocx.mjs update` already stops the proxy and reinstalls /
starts the managed service (or falls back to a direct start). When a background service was
installed, the worker therefore confirms that self-update restart first — but only skips the
redundant second `service install` when /healthz shows update-correlated evidence (a new PID
vs the pre-update capture, and/or the job's target version). A bare healthy identity is not
enough: a surviving pre-update process would otherwise look like success. Direct (non-service)
npm installs skip the probe-first path entirely, because the launcher only prints `ocx start`
and never brings the proxy back on its own — waiting would always burn the full health timeout
before the worker's explicit restart. A second install would call `stopWindows()` on the healthy
listener and often fail elevation from the non-interactive worker, leaving the captured port
(default 10100) dead until a manual restart. Bun global installs still always take the explicit
restart path because `bun add -g` does not restart the proxy.

## Consequences

- The GUI request handler stays responsive and does not overwrite its own running module graph.
Expand Down
11 changes: 11 additions & 0 deletions gui/src/client-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,14 @@ export function setClientResourceData<T>(key: string, data: T) {
store.snapshot = { data, error: undefined, loading: false };
emit(store);
}

/** Test-only: drop every module cache entry so suite order cannot skip cold-start fetches. */
export function clearClientResourceStoresForTests(): void {
for (const store of stores.values()) {
clearPollTimer(store);
store.inflight?.abort();
store.inflight = null;
store.inflightOwner = null;
}
stores.clear();
}
36 changes: 34 additions & 2 deletions gui/tests/debug-put-install-order.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { afterEach, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act } from "react";
import type { Root } from "react-dom/client";
import { clearClientResourceStoresForTests } from "../src/client-resource";
import { LanguageProvider } from "../src/i18n/provider";
import Debug from "../src/pages/Debug";
import type { DebugSettings } from "../src/pages/debug-shared";

const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT", "ResizeObserver"] as const;
/** Survives afterEach restore so a late React 19 dispatchSetState can read window.event. */
const WINDOW_EVENT_STUB: { event: undefined } = { event: undefined };
let previousGlobals: Record<(typeof globals)[number], unknown>;
let testWindow: Window;
const originalFetch = globalThis.fetch;
Expand Down Expand Up @@ -63,8 +66,13 @@ function installLayoutStubs(win: Window): void {
}

beforeEach(() => {
// Prior Debug tests share `debug-settings:http://localhost`; a warm module cache skips
// the cold-start GET and leaves pollCount at 0 (seen on Windows CI after mutation-busy).
clearClientResourceStoresForTests();
previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals;
testWindow = new Window({ url: "http://localhost/#debug" });
// React 19 resolveUpdatePriority reads window.event; happy-dom omits the IE legacy field.
Object.defineProperty(testWindow, "event", { configurable: true, writable: true, value: undefined });
Object.defineProperties(globalThis, {
document: { configurable: true, value: testWindow.document },
window: { configurable: true, value: testWindow },
Expand All @@ -75,11 +83,35 @@ beforeEach(() => {
installLayoutStubs(testWindow);
});

afterEach(() => {
afterEach(async () => {
globalThis.fetch = originalFetch;
// Drain deferred React 19 work before restoring globals (same pattern as rail-hover).
await act(async () => {
for (let i = 0; i < 5; i++) {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.resolve();
}
});
testWindow.close();
clearClientResourceStoresForTests();
for (const key of globals) {
Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] });
let value = previousGlobals[key];
if (key === "window") {
if (value == null || typeof value !== "object") {
value = WINDOW_EVENT_STUB;
} else if (!Object.prototype.hasOwnProperty.call(value, "event")) {
try {
Object.defineProperty(value, "event", {
configurable: true,
writable: true,
value: undefined,
});
} catch {
value = WINDOW_EVENT_STUB;
}
}
}
Object.defineProperty(globalThis, key, { configurable: true, value });
}
});

Expand Down
Loading
Loading