From 01e27373189a7c9e061978bbd0a97fd8963b04f2 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:50:38 +0900 Subject: [PATCH 1/4] test: isolate remaining Desktop lifecycle fixtures --- .../claude-desktop-picker.test.ts | 9 +++++++-- .../claude-management-api.test.ts | 4 ++++ .../claude-picker-relay-intent.test.ts | 4 ++++ .../claude-picker-runtime.test.ts | 4 ++++ .../claude-picker-startup.test.ts | 14 ++++++++++++-- .../claude-picker-windows.test.ts | 14 ++++++++++++-- .../native-claude-desktop-toggle.test.ts | 4 ++++ 7 files changed, 47 insertions(+), 6 deletions(-) diff --git a/tests/claude-integration/claude-desktop-picker.test.ts b/tests/claude-integration/claude-desktop-picker.test.ts index 0463ea3c52e..785fff68c17 100644 --- a/tests/claude-integration/claude-desktop-picker.test.ts +++ b/tests/claude-integration/claude-desktop-picker.test.ts @@ -15,8 +15,10 @@ import { createCertificateAuthority } from "../../src/claude/intercept/local-ca" import { PICKER_CA_COMMON_NAME, PICKER_HOST } from "../../src/claude/intercept/picker-ca"; import { memoryPickerCaStore } from "../helpers/picker-ca-store"; import type { OcxConfig } from "../../src/types"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; let root = ""; +let lifecycleLock: ReturnType; function config(extra: Partial = {}): OcxConfig { return { @@ -136,8 +138,11 @@ function controllerFor(options: { }); } -beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-picker-controller-")); }); -afterEach(() => { rmSync(root, { recursive: true, force: true }); }); +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-picker-controller-")); + lifecycleLock = isolateClientLifecycleLock(root); +}); +afterEach(() => { lifecycleLock.restore(); rmSync(root, { recursive: true, force: true }); }); test("enable orders trust, fresh recheck, profile, and rearm", async () => { const events: string[] = []; diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 5da59871e28..86f96646ed9 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -15,12 +15,14 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/iso import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { desktopFirstPartyTarget } from "../../src/claude/desktop-first-party"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; // Full-suite Windows load: startServer + multi-PUT management flows often exceed bun's // default 5s per-test budget (same flake class as 810fa115 / kiro-oauth). setDefaultTimeout(30_000); let testDir = ""; +let lifecycleLock: ReturnType; let previousHome: string | undefined; let previousClaudeConfigDir: string | undefined; let previousDesktopConfigDir: string | undefined; @@ -45,6 +47,7 @@ beforeEach(() => { previousDesktopConfigDir = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; isolatedCodexHome = installIsolatedCodexHome("ocx-claude-mgmt-"); testDir = mkdtempSync(join(tmpdir(), "ocx-claude-mgmt-")); + lifecycleLock = isolateClientLifecycleLock(testDir); process.env.OPENCODEX_HOME = testDir; // These API tests intentionally toggle agent injection off. Never let that // prune the developer's real ~/.claude/agents directory. @@ -60,6 +63,7 @@ beforeEach(() => { }); afterEach(() => { + lifecycleLock.restore(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; diff --git a/tests/claude-integration/claude-picker-relay-intent.test.ts b/tests/claude-integration/claude-picker-relay-intent.test.ts index 66366aaff25..dd2c7444f62 100644 --- a/tests/claude-integration/claude-picker-relay-intent.test.ts +++ b/tests/claude-integration/claude-picker-relay-intent.test.ts @@ -11,10 +11,12 @@ import { getClaudePickerRuntime, startClaudeIntercept } from "../../src/claude/i import type { OcxConfig } from "../../src/types"; import { memoryPickerCaStore } from "../helpers/picker-ca-store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; const envKeys = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; let previous: Array; let root: string; +let lifecycleLock: ReturnType; let config: OcxConfig; let handle: Awaited>; let upstream: Server | undefined; @@ -23,6 +25,7 @@ const binds: Array<{ requested: number; port: number; options: ConnectProxyOptio beforeEach(() => { previous = envKeys.map(key => process.env[key]); root = mkdtempSync(join(tmpdir(), "ocx-picker-relay-intent-")); + lifecycleLock = isolateClientLifecycleLock(root); process.env.OPENCODEX_HOME = root; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop"); process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); @@ -38,6 +41,7 @@ afterEach(async () => { await handle?.stop(); if (upstream) await new Promise((resolve, reject) => upstream!.close(error => error ? reject(error) : resolve())); } finally { + lifecycleLock.restore(); handle = null; upstream = undefined; envKeys.forEach((key, index) => { diff --git a/tests/claude-integration/claude-picker-runtime.test.ts b/tests/claude-integration/claude-picker-runtime.test.ts index 095938bcad6..f3971edf214 100644 --- a/tests/claude-integration/claude-picker-runtime.test.ts +++ b/tests/claude-integration/claude-picker-runtime.test.ts @@ -23,8 +23,10 @@ import type { SecurityRunner } from "../../src/claude/intercept/picker-trust"; import { getClaudePickerRuntime, startClaudeIntercept } from "../../src/claude/intercept/runtime"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; let root = ""; +let lifecycleLock: ReturnType; const previous: Record = {}; const ENV_KEYS = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; const running: PickerRuntime[] = []; @@ -87,6 +89,7 @@ const INTERCEPT = { kind: "intercept", port: LISTENER_PORT }; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-picker-runtime-")); + lifecycleLock = isolateClientLifecycleLock(root); for (const key of ENV_KEYS) previous[key] = process.env[key]; process.env.OPENCODEX_HOME = root; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop-library"); @@ -96,6 +99,7 @@ beforeEach(() => { afterEach(async () => { for (const created of running.splice(0)) await created.stop(); for (const close of closeFixtures.splice(0)) await close(); + lifecycleLock.restore(); for (const key of ENV_KEYS) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; diff --git a/tests/claude-integration/claude-picker-startup.test.ts b/tests/claude-integration/claude-picker-startup.test.ts index ce90a5a581b..0c3a772126d 100644 --- a/tests/claude-integration/claude-picker-startup.test.ts +++ b/tests/claude-integration/claude-picker-startup.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; @@ -17,8 +17,10 @@ import { flushConfigDirHardeningAndReaps, hardenConfigDir } from "../../src/conf import { resetHardenedStateForTests, setAsyncIcaclsRunnerForTests, setPlatformForTests } from "../../src/lib/windows-secret-acl"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import type { OcxConfig } from "../../src/types"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; const roots: string[] = []; +let lifecycleLock: ReturnType; const handles: Array>> = []; const priorHome = process.env.OPENCODEX_HOME; const priorDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -43,7 +45,15 @@ async function cleanupPickerStartup(remove: (dir: string) => void = removeTreeWi if (failures.length === 1) throw failures[0]; if (failures.length > 1) throw new AggregateError(failures, "picker-startup cleanup failed"); } -afterEach(() => cleanupPickerStartup()); +// Cases own multiple roots; keep their cleanup oracle intact and use the preload fixture. +beforeEach(() => { + if (priorHome === undefined) throw new Error("picker startup requires an isolated preload home"); + lifecycleLock = isolateClientLifecycleLock(priorHome); +}); +afterEach(async () => { + try { await cleanupPickerStartup(); } + finally { lifecycleLock.restore(); } +}); const routes = async () => ({ nativeSlugs: [], routedModels: [] }); function setup() { const root = mkdtempSync(join(tmpdir(), "ocx-picker-startup-")); roots.push(root); diff --git a/tests/claude-integration/claude-picker-windows.test.ts b/tests/claude-integration/claude-picker-windows.test.ts index e67da03ab17..de711d06f29 100644 --- a/tests/claude-integration/claude-picker-windows.test.ts +++ b/tests/claude-integration/claude-picker-windows.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { createServer as createHttpsServer } from "node:https"; import { connect } from "node:net"; @@ -26,8 +26,10 @@ import { import type { SecurityResult, SecurityRunner } from "../../src/claude/intercept/picker-trust"; import { filePickerCaStore, memoryPickerCaStore } from "../helpers/picker-ca-store"; import type { OcxConfig } from "../../src/types"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; const roots: string[] = []; +let lifecycleLock: ReturnType; const handles: Array>> = []; const priorHome = process.env.OPENCODEX_HOME; const priorDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -52,7 +54,15 @@ async function cleanupPickerWindows(remove: (dir: string) => void = removeTreeWi if (failures.length === 1) throw failures[0]; if (failures.length > 1) throw new AggregateError(failures, "picker-windows cleanup failed"); } -afterEach(() => cleanupPickerWindows()); +// Cases own multiple roots; keep their cleanup oracle intact and use the preload fixture. +beforeEach(() => { + if (priorHome === undefined) throw new Error("Windows picker requires an isolated preload home"); + lifecycleLock = isolateClientLifecycleLock(priorHome); +}); +afterEach(async () => { + try { await cleanupPickerWindows(); } + finally { lifecycleLock.restore(); } +}); const routes = async () => ({ nativeSlugs: [], routedModels: [] }); diff --git a/tests/codex-integration/native-claude-desktop-toggle.test.ts b/tests/codex-integration/native-claude-desktop-toggle.test.ts index 7e13259cbaa..5c2dd99da6c 100644 --- a/tests/codex-integration/native-claude-desktop-toggle.test.ts +++ b/tests/codex-integration/native-claude-desktop-toggle.test.ts @@ -10,8 +10,10 @@ import { armClaudeCodeBaseline, saveConfigPreservingClaudeCode } from "../../src import type { ManagementApiDeps } from "../../src/server/management/context"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; let root = ""; +let lifecycleLock: ReturnType; let library = ""; let previousHome: string | undefined; let previousLibrary: string | undefined; @@ -106,6 +108,7 @@ function oversizedTrackedBody(): { beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-desktop-toggle-")); + lifecycleLock = isolateClientLifecycleLock(root); library = join(root, "desktop-library"); previousHome = process.env.OPENCODEX_HOME; previousLibrary = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -117,6 +120,7 @@ beforeEach(() => { }); afterEach(() => { + lifecycleLock.restore(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; From 6ab84079040956a3647640a4d242e3afa09553ad Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:29:31 +0900 Subject: [PATCH 2/4] test(claude): restore lifecycle isolation after failed teardown and own the lock root picker-runtime restores the lifecycle spies, environment and root in finally even when a stop or close rejects. picker-startup and picker-windows give the lock a fixture-owned temporary root instead of the inherited OPENCODEX_HOME. --- .../claude-picker-runtime.test.ts | 18 +++++++++++------- .../claude-picker-startup.test.ts | 9 +++++---- .../claude-picker-windows.test.ts | 9 +++++---- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/claude-integration/claude-picker-runtime.test.ts b/tests/claude-integration/claude-picker-runtime.test.ts index f3971edf214..e96c379f13a 100644 --- a/tests/claude-integration/claude-picker-runtime.test.ts +++ b/tests/claude-integration/claude-picker-runtime.test.ts @@ -97,14 +97,18 @@ beforeEach(() => { }); afterEach(async () => { - for (const created of running.splice(0)) await created.stop(); - for (const close of closeFixtures.splice(0)) await close(); - lifecycleLock.restore(); - for (const key of ENV_KEYS) { - if (previous[key] === undefined) delete process.env[key]; - else process.env[key] = previous[key]; + try { + for (const created of running.splice(0)) await created.stop(); + for (const close of closeFixtures.splice(0)) await close(); + } finally { + // A rejected stop or close must not leave the lifecycle spies, environment or root behind. + lifecycleLock.restore(); + for (const key of ENV_KEYS) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + removeTreeWithRetry(root); } - removeTreeWithRetry(root); }); describe("pickerDesired", () => { diff --git a/tests/claude-integration/claude-picker-startup.test.ts b/tests/claude-integration/claude-picker-startup.test.ts index 0c3a772126d..75d19b6ccbe 100644 --- a/tests/claude-integration/claude-picker-startup.test.ts +++ b/tests/claude-integration/claude-picker-startup.test.ts @@ -21,6 +21,7 @@ import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; const roots: string[] = []; let lifecycleLock: ReturnType; +let lockRoot = ""; const handles: Array>> = []; const priorHome = process.env.OPENCODEX_HOME; const priorDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -45,14 +46,14 @@ async function cleanupPickerStartup(remove: (dir: string) => void = removeTreeWi if (failures.length === 1) throw failures[0]; if (failures.length > 1) throw new AggregateError(failures, "picker-startup cleanup failed"); } -// Cases own multiple roots; keep their cleanup oracle intact and use the preload fixture. +// Cases own multiple roots; keep their cleanup oracle intact and give the lock its own temp root. beforeEach(() => { - if (priorHome === undefined) throw new Error("picker startup requires an isolated preload home"); - lifecycleLock = isolateClientLifecycleLock(priorHome); + lockRoot = mkdtempSync(join(tmpdir(), "ocx-picker-startup-lock-")); + lifecycleLock = isolateClientLifecycleLock(lockRoot); }); afterEach(async () => { try { await cleanupPickerStartup(); } - finally { lifecycleLock.restore(); } + finally { lifecycleLock.restore(); removeTreeWithRetry(lockRoot); } }); const routes = async () => ({ nativeSlugs: [], routedModels: [] }); function setup() { diff --git a/tests/claude-integration/claude-picker-windows.test.ts b/tests/claude-integration/claude-picker-windows.test.ts index de711d06f29..58fa15813a0 100644 --- a/tests/claude-integration/claude-picker-windows.test.ts +++ b/tests/claude-integration/claude-picker-windows.test.ts @@ -30,6 +30,7 @@ import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; const roots: string[] = []; let lifecycleLock: ReturnType; +let lockRoot = ""; const handles: Array>> = []; const priorHome = process.env.OPENCODEX_HOME; const priorDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -54,14 +55,14 @@ async function cleanupPickerWindows(remove: (dir: string) => void = removeTreeWi if (failures.length === 1) throw failures[0]; if (failures.length > 1) throw new AggregateError(failures, "picker-windows cleanup failed"); } -// Cases own multiple roots; keep their cleanup oracle intact and use the preload fixture. +// Cases own multiple roots; keep their cleanup oracle intact and give the lock its own temp root. beforeEach(() => { - if (priorHome === undefined) throw new Error("Windows picker requires an isolated preload home"); - lifecycleLock = isolateClientLifecycleLock(priorHome); + lockRoot = mkdtempSync(join(tmpdir(), "ocx-picker-windows-lock-")); + lifecycleLock = isolateClientLifecycleLock(lockRoot); }); afterEach(async () => { try { await cleanupPickerWindows(); } - finally { lifecycleLock.restore(); } + finally { lifecycleLock.restore(); removeTreeWithRetry(lockRoot); } }); const routes = async () => ({ nativeSlugs: [], routedModels: [] }); From 0d091157acf3dce1d986cf178fc1f428e7e08576 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:00:38 +0900 Subject: [PATCH 3/4] test(claude): join the detached picker restore before fixture teardown A restart with an applied profile starts controller.enable without awaiting it. Teardown now waits on the controller transition before stopping handles, so the enable cannot run after the lifecycle spy is restored and reach the account-wide lock. --- .../claude-picker-runtime.test.ts | 27 ++++++++++++------- .../claude-picker-startup.test.ts | 3 +++ .../claude-picker-windows.test.ts | 3 +++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/tests/claude-integration/claude-picker-runtime.test.ts b/tests/claude-integration/claude-picker-runtime.test.ts index e96c379f13a..b42a5e5378d 100644 --- a/tests/claude-integration/claude-picker-runtime.test.ts +++ b/tests/claude-integration/claude-picker-runtime.test.ts @@ -20,7 +20,7 @@ import { type PickerRuntime, } from "../../src/claude/intercept/picker-runtime"; import type { SecurityRunner } from "../../src/claude/intercept/picker-trust"; -import { getClaudePickerRuntime, startClaudeIntercept } from "../../src/claude/intercept/runtime"; +import { getClaudePickerController, getClaudePickerRuntime, startClaudeIntercept } from "../../src/claude/intercept/runtime"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; @@ -308,6 +308,13 @@ async function heldEgress(dialUpstream?: Parameters[1] return { port: proxy.port, startProxy, close: proxy.close }; } +// A restart with an applied profile starts a detached controller.enable; join it before stopping, +// so it cannot run after afterEach restores the lifecycle spy and reach the account-wide lock. +async function stopAfterPickerSettles(handle: { stop(): Promise } | null | undefined): Promise { + try { await getClaudePickerController()?.transition(async () => {}); } + finally { await handle?.stop(); } +} + const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Claude/2.7032.0"; function connectStatusLine(port: number, host: string, userAgent?: string): Promise { @@ -444,7 +451,7 @@ describe("startClaudeIntercept wiring", () => { expect(names).not.toContain("add-trusted-cert"); expect(getClaudePickerRuntime()?.selectTunnel("claude.ai", 443)).toEqual(INTERCEPT); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } expect(getClaudePickerRuntime()).toBeNull(); }); @@ -477,7 +484,7 @@ describe("startClaudeIntercept wiring", () => { expect(names).not.toContain("remove-trusted-cert"); expect(names).not.toContain("delete-certificate"); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } expect(getClaudePickerRuntime()).toBeNull(); }); @@ -528,7 +535,7 @@ describe("startClaudeIntercept wiring", () => { expect(pickerCaFingerprints(readFileSync(pickerCaCertPath(root), "utf8")).sha1).not.toBe(foreignSha1); expect(getClaudePickerRuntime()).toBe(fake); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } expect(getClaudePickerRuntime()).toBeNull(); }); @@ -564,7 +571,7 @@ describe("startClaudeIntercept wiring", () => { expect(names).not.toContain("remove-trusted-cert"); expect(names).not.toContain("delete-certificate"); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } expect(getClaudePickerRuntime()).toBeNull(); }); @@ -636,7 +643,7 @@ describe("startClaudeIntercept wiring", () => { expect(await canBind(handle!.proxyPort)).toBe(false); expect(await connectStatusLine(handle!.proxyPort, "api.anthropic.com")).toContain("200"); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); await new Promise(resolve => upstream.close(() => resolve())); } expect(getClaudePickerRuntime()).toBeNull(); @@ -677,7 +684,7 @@ describe("startClaudeIntercept wiring", () => { expect(readFileSync(join(pickerStateDir(root), "profile-state.json"), "utf8")).toBe(stateBefore); expect(existsSync(pickerCaPendingUntrustPath(root))).toBe(true); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); await new Promise(resolve => squatter.close(() => resolve())); } }); @@ -709,7 +716,7 @@ describe("startClaudeIntercept wiring", () => { expect(calls).toEqual([]); expect(readFileSync(pickerCaPendingUntrustPath(root), "utf8")).toBe("{corrupt"); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } }); @@ -777,7 +784,7 @@ describe("startClaudeIntercept wiring", () => { const metadata = JSON.parse(readFileSync(join(library, "_meta.json"), "utf8")) as { entries: { name: string }[] }; expect(metadata.entries.some(entry => entry.name === "opencodex-standard")).toBe(false); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } }); @@ -894,7 +901,7 @@ describe("startClaudeIntercept wiring", () => { expect(await connectStatusLine(pickerPort, "spoofed-ua.invalid", `${BROWSER_UA} spoofed`)).toContain("502"); expect(asked).toEqual(["picker-probe.invalid", "spoofed-ua.invalid"]); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } expect(getClaudePickerRuntime()).toBeNull(); expect(await canBind(pickerPort)).toBe(true); diff --git a/tests/claude-integration/claude-picker-startup.test.ts b/tests/claude-integration/claude-picker-startup.test.ts index 75d19b6ccbe..1b48c490c50 100644 --- a/tests/claude-integration/claude-picker-startup.test.ts +++ b/tests/claude-integration/claude-picker-startup.test.ts @@ -32,6 +32,9 @@ const priorDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; // even when an earlier one fails, the environment is always restored, and failures are rethrown. async function cleanupPickerStartup(remove: (dir: string) => void = removeTreeWithRetry): Promise { const failures: unknown[] = []; + // A restart with an applied profile starts a detached controller.enable; join it before the + // handles stop and the lifecycle spy is restored, so it cannot reach the account-wide lock. + try { await getClaudePickerController()?.transition(async () => {}); } catch (error) { failures.push(error); } for (const handle of handles.splice(0)) { try { await handle?.stop(); } catch (error) { failures.push(error); } } diff --git a/tests/claude-integration/claude-picker-windows.test.ts b/tests/claude-integration/claude-picker-windows.test.ts index 58fa15813a0..ba5b2e6d58e 100644 --- a/tests/claude-integration/claude-picker-windows.test.ts +++ b/tests/claude-integration/claude-picker-windows.test.ts @@ -40,6 +40,9 @@ const priorClaude = process.env.CLAUDE_CONFIG_DIR; // Drain hardening and reaps before retrying removal; retain failures and restore env. async function cleanupPickerWindows(remove: (dir: string) => void = removeTreeWithRetry): Promise { const failures: unknown[] = []; + // A restart with an applied profile starts a detached controller.enable; join it before the + // handles stop and the lifecycle spy is restored, so it cannot reach the account-wide lock. + try { await getClaudePickerController()?.transition(async () => {}); } catch (error) { failures.push(error); } for (const handle of handles.splice(0)) { try { await handle?.stop(); } catch (error) { failures.push(error); } } From 967055007390845db75ee12a6382f3ededeb1003 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:15:35 +0900 Subject: [PATCH 4/4] test(claude): route the busy-picker retry stops through the restore barrier The retry case stopped both intercepts directly, so a detached restore-enable could still take the lifecycle lock after teardown restored the spy. --- tests/claude-integration/claude-picker-runtime.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/claude-integration/claude-picker-runtime.test.ts b/tests/claude-integration/claude-picker-runtime.test.ts index b42a5e5378d..695fdb15efe 100644 --- a/tests/claude-integration/claude-picker-runtime.test.ts +++ b/tests/claude-integration/claude-picker-runtime.test.ts @@ -842,7 +842,7 @@ describe("startClaudeIntercept wiring", () => { expect(inspectDesktopPickerProfile({ configDir: root, platform: "darwin" })) .toMatchObject({ kind: "applied", entryId: original.entryId, proxyUrl: original.proxyUrl }); } finally { - await first?.stop(); + await stopAfterPickerSettles(first); } // The next restart adopts the held relay; the surviving selection restores in place. retry = true; @@ -860,7 +860,7 @@ describe("startClaudeIntercept wiring", () => { expect(inspectDesktopPickerProfile({ configDir: root, platform: "darwin" })) .toMatchObject({ kind: "applied", entryId: original.entryId, proxyUrl: restoredUrl }); } finally { - await second?.stop(); + await stopAfterPickerSettles(second); } } finally { await egress.close(); } });