diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md index 761862d2ea..887493f193 100644 --- a/docs-site/src/content/docs/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -95,6 +95,13 @@ This integration installs no certificate, network listener, PAC, or background watcher. It does not log the app's messages or environment. Status reports whether the running ChatGPT bundle process carries the expected launcher override. +If app discovery or process inspection fails, `status` reports `unknown` rather +than `not running`. `launch` and `restore` refuse to reopen an app whose exit +cannot be confirmed; restore keeps the launcher. Resolve the inspection error +and retry. Launch checks before writing its launcher, but a later inspection can +still fail after that atomic update; the commands do not guarantee an all-or-nothing +restart transaction. + ## Failure behavior and known limits When the platform is not macOS, the OpenCodex runtime is missing, or the filter diff --git a/docs-site/src/content/docs/reference/codex-retry-diagnostics.md b/docs-site/src/content/docs/reference/codex-retry-diagnostics.md index 532ada6ddf..ba4d7d81ab 100644 --- a/docs-site/src/content/docs/reference/codex-retry-diagnostics.md +++ b/docs-site/src/content/docs/reference/codex-retry-diagnostics.md @@ -23,6 +23,24 @@ waiting visible: those items can contaminate history or imply work happened. Do not shorten the provider delay or provoke an extra request to advance the client's retry counter. +## Triage: `exceeded retry limit, last status: 429 Too Many Requests` + +The message is the client's retry budget summary, not a diagnosis. One refusal +that the client does not retransmit can end a turn in the same words as a +fully exhausted budget, so treat the text as a question. Three cases look +identical at this layer and are checked differently. + +| Check | Distinguishing evidence | If the check is positive | +| --- | --- | --- | +| Main-account hard lock (local admission policy) | The dashboard Settings or `GET /api/settings` reports the policy state, such as `mainAccountHardLock.state` being `blocked`, `ready`, or `off`, with `thresholds` and an optional `resetAt`. A non-streaming request may also carry an explicit main-policy body instead of a bare upstream 429. | Refresh usage with `ocx account refresh openai`; fresh usage below every blocking threshold or validated window absence releases the lock. If it remains blocked, wait for fresh lower usage or explicitly turn `codexMainAccountHardLock` off in Settings. Builds before [#6287](https://github.com/lidge-jun/opencodex/pull/6287) could keep an obsolete short-window block across restarts when no fresh observation ever replaced it; the release semantics are described in the [closing note on #6244](https://github.com/lidge-jun/opencodex/issues/6244). | +| Upstream ChatGPT plan window | The account quota section shows a 5-hour or weekly window at or near its limit while the lock reports `ready` or `off`. | Wait for the window to reset, inspect any reset option ChatGPT offers ([banked reset rules](https://help.openai.com/en/articles/20001498-how-banked-codex-resets-work), [plan windows](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan)), or serve the request on another account with `ocx account use openai `. For explicitly authorized paid-credit use after the limit, see [account credit policy](/reference/cli/providers-accounts/#pool-policy-account-thresholds-and-paid-credit-intent). The credit-balance rediscovery defect in [#6845](https://github.com/lidge-jun/opencodex/issues/6845) was fixed by [#6886](https://github.com/lidge-jun/opencodex/pull/6886). | +| Provider incident or transport failure | Quota stays low, the lock stays released, and `ocx doctor` reports transport or ChatGPT reachability problems. | Retry after the incident clears or reroute through an existing combo. Changing lock or threshold policy is not a cure for a transport failure. | + +A locked main credential closes request admission; it does not reserve the +account's remaining quota, and a predicted reset never releases the block on +its own. The thresholds (by default `short` 90, `long` 98) are visible in the +status payload and configurable through `codexMainAccountHardLockThresholds`. + ## Why the first reconnect row can be absent The public Codex `rust-v0.158.0-alpha.2.1` source gates the ordinary stream-retry diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index d71c2fc920..ac9be7cc15 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -126,6 +126,10 @@ SOCKS5 (Clash mixed-port listeners included) belongs on `ALL_PROXY`, not `HTTP_P `ocx start --socks5` writes that value; `ocx start --socks5-off` clears it. +HTTPS requests through SOCKS5 have a separate 30-second TLS handshake deadline. +A stalled handshake fails without disabling certificate verification; caller +cancellation can stop it sooner. + If an older development build changed resume-history metadata before backup support existed, run `ocx recover-history --legacy-openai --yes` to force native-provider recovery. It force-relabels every user-message `opencodex` row, including legitimate dedicated-provider diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 348c0e37c2..4541ff558f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1728,7 +1728,7 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", - "socks5-fetch.test.ts": "lib", + "socks5-fetch.test.ts": "lib", "socks5-tls-lifecycle.test.ts": "lib", "socks5-upload-lifecycle.test.ts": "lib", "spend-ceiling-enforcement.test.ts": "lib", "spend-instrumentation-log.test.ts": "server", diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index f870e66ff7..3006f44638 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -20,7 +20,7 @@ const USAGE = `Usage (experimental, macOS only): function run(command: string, args: string[]) { const result = spawnSync(command, args, { encoding: "utf8", timeout: 5000 }); - return { ok: result.status === 0, output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() }; + return { ok: result.status === 0, status: result.status, output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() }; } /** @@ -42,11 +42,12 @@ function chatgptDesktopIssueInFile(): string | null { * desktop restart adapter does. "ChatGPT" is a display name another app can share, so quitting, * relaunching and the binary path all key on this verified bundle rather than on the name. */ -function discoverApp(): DesktopAppInstall | null { +function discoverApp(): DesktopAppInstall | null | undefined { try { return darwinDesktopAppAdapter.discover(darwinDefaultExec); } catch { - return null; + // An inspection error is not evidence that the app is uninstalled. + return undefined; } } @@ -55,14 +56,20 @@ function discoverApp(): DesktopAppInstall | null { * `-a` keeps ancestors in the match: when ocx runs inside a ChatGPT/Codex session the app is * one of this process's ancestors, and plain `pgrep -x` would report it as not running. */ -function appState(install: DesktopAppInstall, launcher: string): { running: boolean; shim: boolean } { +function appState(install: DesktopAppInstall, launcher: string): { running: boolean | null; shim: boolean | null } { const shell = join(install.root, "Contents", "MacOS", "ChatGPT"); // Only this user's processes: another account's ChatGPT can neither be quit nor relaunched here. const uid = typeof process.getuid === "function" ? process.getuid() : undefined; const pids = run("pgrep", [...(uid === undefined ? [] : ["-U", String(uid)]), "-a", "-x", "ChatGPT"]); - if (!pids.ok) return { running: false, shim: false }; - for (const pid of pids.output.split(/\s+/).filter(value => /^\d+$/.test(value))) { + // pgrep exit 1 means no match; timeout, signal, or command errors are unknown. + if (!pids.ok) return pids.status === 1 + ? { running: false, shim: false } : { running: null, shim: null }; + const candidates = pids.output.split(/\s+/).filter(value => /^\d+$/.test(value)); + if (candidates.length === 0) return { running: null, shim: null }; + let unreadable = false; + for (const pid of candidates) { const command = run("ps", ["eww", "-o", "command=", "-p", pid]); + if (!command.ok || !command.output) unreadable = true; if (command.ok && (command.output === shell || command.output.startsWith(`${shell} `))) { const marker = `CODEX_CLI_PATH=${launcher}`; const start = command.output.indexOf(marker); @@ -70,20 +77,24 @@ function appState(install: DesktopAppInstall, launcher: string): { running: bool && (start + marker.length === command.output.length || command.output[start + marker.length] === " ") }; } } - return { running: false, shim: false }; + return unreadable ? { running: null, shim: null } : { running: false, shim: false }; } /** Adapted from #5947: open ignores new launch settings until the previous app exits. */ async function quitApp(install: DesktopAppInstall, launcher: string): Promise { - if (!appState(install, launcher).running) return true; + const initial = appState(install, launcher).running; + if (initial === false) return true; + if (initial === null) return false; for (let attempt = 0; attempt < 3; attempt++) { run("/usr/bin/osascript", ["-e", `quit app id "${install.id}"`]); for (let poll = 0; poll < 20; poll++) { - if (!appState(install, launcher).running) return true; + const running = appState(install, launcher).running; + if (running === false) return true; + if (running === null) return false; await Bun.sleep(250); } } - return !appState(install, launcher).running; + return appState(install, launcher).running === false; } export async function handleChatgptCommand(args: string[], platform: NodeJS.Platform = process.platform): Promise { @@ -105,16 +116,21 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat const launcher = chatgptShimLauncherPath(); const install = discoverApp(); if (sub === "status") { - const app = install ? appState(install, launcher) : { running: false, shim: false }; + const app = install ? appState(install, launcher) + : install === undefined ? { running: null, shim: null } : { running: false, shim: false }; const configIssue = config.chatgptDesktop?.appServerShim === true ? null : chatgptDesktopIssueInFile(); const flag = config.chatgptDesktop?.appServerShim === true ? "on" : configIssue ? `off (config.json ${configIssue}; the whole chatgptDesktop block is ignored)` : "off"; console.log(`app-server shim (experimental): ${flag} launcher: ${existsSync(launcher) ? "present" : "absent"} -app: ${install ? (app.running ? "running" : "not running") : "not installed"} -CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); +app: ${install === undefined ? "unknown (app discovery failed)" : install ? (app.running === null ? "unknown (process inspection failed)" : app.running ? "running" : "not running") : "not installed"} +CODEX_CLI_PATH launcher: ${app.shim === null ? "unknown" : app.shim ? "yes" : "no"}`); return 0; } + if (install === undefined) { + console.error("ChatGPT app discovery failed; no launcher was changed and no app was opened. Retry after resolving discovery."); + return 1; + } if (!install) { if (sub === "restore") rmSync(launcher, { force: true }); console.error("ChatGPT (com.openai.codex) was not found; install or open it once, then retry."); @@ -143,9 +159,14 @@ CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); console.error(`Refusing to ${sub === "launch" ? "launch the shim" : "restore ChatGPT"}: ${untrusted}.`); return 1; } + // Preflight before changing the launcher. quitApp rechecks at the action boundary. + if (appState(install, launcher).running === null) { + console.error("ChatGPT process inspection failed; no launcher was changed and no app was opened. Retry after resolving inspection."); + return 1; + } if (binary) writeChatgptShimLauncher(undefined, binary); if (!(await quitApp(install, launcher))) { - console.error("ChatGPT did not quit; quit it manually and retry."); + console.error("ChatGPT exit could not be confirmed; no new app was opened. Check the running app and retry."); return 1; } // Remove an inherited override too: restore must launch without CODEX_CLI_PATH. diff --git a/src/lib/socks5-fetch.ts b/src/lib/socks5-fetch.ts index 52bdc3d3b5..ed2145c9e9 100644 --- a/src/lib/socks5-fetch.ts +++ b/src/lib/socks5-fetch.ts @@ -341,25 +341,50 @@ function requestHeaders(request: Request, target: URL): { text: string; chunked: async function secureSocket(socket: Socket, target: URL, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { + if (signal.aborted) { + socket.destroy(); + reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + return; + } const tlsSocket = tls.connect({ socket, servername: target.hostname, rejectUnauthorized: true, }); - const onAbort = () => { + let settled = false; + // Absolute deadline: a peer sending occasional TLS bytes must not extend the handshake. + const timer = setTimeout(() => { + fail(new Socks5FetchError("SOCKS5 TLS handshake timed out")); + }, SOCKS5_CONNECT_TIMEOUT_MS); + const cleanup = () => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + tlsSocket.removeListener("secureConnect", onSecureConnect); + tlsSocket.removeListener("error", onError); + tlsSocket.removeListener("close", onClose); + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + // No error argument: pending event observers must not receive a second error. tlsSocket.destroy(); - reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + reject(error); }; + const onAbort = () => fail(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted")); + const onError = (error: Error) => fail(error); + const onClose = () => fail(new Socks5FetchError("SOCKS5 upstream closed during TLS handshake")); const onSecureConnect = () => { - signal.removeEventListener("abort", onAbort); + if (settled) return; + settled = true; + cleanup(); resolve(tlsSocket); }; tlsSocket.once("secureConnect", onSecureConnect); - tlsSocket.once("error", error => { - signal.removeEventListener("abort", onAbort); - reject(error); - }); + tlsSocket.once("error", onError); + tlsSocket.once("close", onClose); signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); }); } diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index daabf923d9..dca30b1d07 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -61,7 +61,12 @@ app-server binary. It relaunches without that override and removes the launcher `com.openai.codex` bundle is found it removes the launcher, relaunches nothing and exits 1. Status reports the experimental flag, launcher presence, and the verified bundle process's override -without printing its environment. Other platforms reject all three operations. +without printing its environment. Discovery exceptions and failed process inspection +are unknown rather than evidence of an absent app. Launch preflights process inspection +before writing the launcher; both operations refuse to open when exit cannot be confirmed. +Restore keeps the launcher on those failures. A later probe may fail after launch has +atomically updated it; this is not a transaction spanning the launcher and app restart. +Other platforms reject all three operations. The launcher and its executable paths are local code-execution inputs. This integration installs no network listener, CA, PAC or background watcher, and diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 4956c0e716..38dd6813dc 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -307,6 +307,12 @@ admission or account-snapshot pairing. The forwarding contract is covered in ## SOCKS5 dispatch boundary +HTTPS tunnels in `src/lib/socks5-fetch.ts` give the TLS handshake its own absolute +30-second deadline after SOCKS CONNECT. Successful TLS clears that deadline before +the response timer takes over; timeout, caller cancellation, error and premature +close release the owned socket and listeners. Certificate verification stays enabled. +`tests/lib/socks5-tls-lifecycle.test.ts` exercises stalled and interrupted handshakes. + `src/config/proxy-env.ts` activates configured SOCKS5 through `src/lib/proxy-env.ts`; the compatibility config facade does not own a second activation path. The library captures `startupOutboundProxyConfigured` at module evaluation, before config diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts index 0f89aeea61..db53fc7fd0 100644 --- a/tests/clients/desktop-app-server-shim-launcher.test.ts +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -186,7 +186,7 @@ describe("restore validates the discovered app before any quit or open", () => { expect(result.status).toBe(0); return { ...JSON.parse(result.stdout.trim().split("\n").at(-1)!), stdout: result.stdout, stderr: result.stderr } as { code: number; calls: Array<{ command: string; args: string[]; override?: string }>; - launcherExists: boolean; root: string; shell: string; binary: string; stdout: string; stderr: string; + launcherExists: boolean; launcherContent: string | null; root: string; shell: string; binary: string; stdout: string; stderr: string; }; } finally { removeTreeWithRetry(dir); } }; @@ -199,6 +199,37 @@ describe("restore validates the discovered app before any quit or open", () => { expect(result.calls.filter(call => ["pgrep", "ps", "/usr/bin/osascript", "/usr/bin/open"].includes(call.command))).toEqual([]); }); } + for (const sub of ["launch", "restore"]) { + for (const scenario of [ + { pgrepFails: true }, { pgrepFails: true, pgrepTimeout: true }, + { psFails: true, running: true }, { invalidPgrep: true }, { discoveryFails: true }, + ]) { + test(`${sub} preserves the launcher and refuses unknown app state ${JSON.stringify(scenario)}`, () => { + const result = run({ sub, flag: true, ...scenario }); + expect(result.code).toBe(1); + expect(result.launcherContent).toBe("preserve until restore succeeds"); + expect(result.calls.some(call => ["/usr/bin/osascript", "/usr/bin/open"].includes(call.command))).toBe(false); + expect(result.stderr).not.toContain("private"); + }); + } + } + test("an inspection failure after quit does not authorize open or launcher removal", () => { + const result = run({ running: true, probeFailsAfterQuit: true }); + expect(result.code).toBe(1); + expect(result.launcherContent).toBe("preserve until restore succeeds"); + expect(result.calls.some(call => call.command === "/usr/bin/osascript")).toBe(true); + expect(result.calls.some(call => call.command === "/usr/bin/open")).toBe(false); + }); + for (const scenario of [{ pgrepFails: true }, { discoveryFails: true }, { psFails: true, running: true }]) { + test(`status reports unknown without mutation ${JSON.stringify(scenario)}`, () => { + const result = run({ sub: "status", ...scenario }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("app: unknown"); + expect(result.stdout).toContain("CODEX_CLI_PATH launcher: unknown"); + expect(result.launcherContent).toBe("preserve until restore succeeds"); + expect(result.stdout).not.toContain("private"); + }); + } test("valid restore works with the flag off and no bundled app-server", () => { const result = run({ flag: false, missingBinary: true, running: true }); expect(result.code).toBe(0); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 74748aff5f..038319490c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1068,7 +1068,8 @@ "sidecar-settings-web-search-gate.test.ts": "vision", "sidecar-settings-web-search-off.test.ts": "vision", "sidecar-settings-web-search-stream.test.ts": "vision", "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", - "socks5-fetch.test.ts": "lib", "socks5-upload-lifecycle.test.ts": "lib", + "socks5-fetch.test.ts": "lib", "socks5-tls-lifecycle.test.ts": "lib", + "socks5-upload-lifecycle.test.ts": "lib", "spend-ceiling-enforcement.test.ts": "lib", "spend-instrumentation-log.test.ts": "server", "spend-ledger-file-journal.test.ts": "lib", "spend-ledger-lifecycle.test.ts": "server", "spend-ledger-owner-startup.test.ts": "server", "spend-ledger-owner.test.ts": "lib", diff --git a/tests/helpers/desktop-app-server-shim-command-child.ts b/tests/helpers/desktop-app-server-shim-command-child.ts index 1143cb5dde..72362b026b 100644 --- a/tests/helpers/desktop-app-server-shim-command-child.ts +++ b/tests/helpers/desktop-app-server-shim-command-child.ts @@ -19,6 +19,7 @@ for (let parent = dirname(root); ; parent = dirname(parent)) { } const calls: Array<{ command: string; args: string[]; override?: string }> = []; let running = scenario.running === true; +let pgrepCalls = 0; const uid = typeof process.getuid === "function" ? process.getuid() : -1; mock.module("node:fs", () => ({ ...fs, lstatSync(path: string, ...args: unknown[]) { @@ -48,8 +49,15 @@ mock.module("node:child_process", () => ({ ...childProcess, spawnSync(command: s status: scenario.trust === "unsigned" ? 1 : 0, stdout: args[0] === "--verify" ? "" : `TeamIdentifier=${scenario.trust === "foreign-team" ? "OTHERTEAM" : "2DC432GLL2"}\n`, stderr: "", }; - if (command === "pgrep") return { status: running ? 0 : 1, stdout: running ? "123" : "", stderr: "" }; - if (command === "ps") return { status: 0, stdout: shell, stderr: "" }; + if (command === "pgrep") { + pgrepCalls++; + if (scenario.pgrepFails || (scenario.probeFailsAfterQuit && pgrepCalls > 2)) return { + status: scenario.pgrepTimeout ? null : 2, stdout: "", stderr: "private fixture diagnostic", + }; + if (scenario.invalidPgrep) return { status: 0, stdout: "not-a-pid", stderr: "" }; + return { status: running ? 0 : 1, stdout: running ? "123" : "", stderr: "" }; + } + if (command === "ps") return { status: scenario.psFails ? 1 : 0, stdout: scenario.psFails ? "" : shell, stderr: "" }; if (command === "/usr/bin/osascript") running = false; if (command === "/usr/bin/open" && scenario.openFails) return { status: 1, stdout: "", stderr: "fixture open failed" }; return { status: 0, stdout: "", stderr: "" }; @@ -60,7 +68,10 @@ mock.module("../../src/config", () => ({ loadConfig: () => ({ chatgptDesktop: { mock.module("../../src/config/diagnostics", () => ({ readConfigFileSnapshot: () => ({ raw: scenario.configRaw }) })); mock.module("../../src/codex/desktop-app/darwin", () => ({ darwinDefaultExec: () => "", - darwinDesktopAppAdapter: { discover: () => scenario.noInstall ? null : { id: "com.openai.codex", root, relaunch: "com.openai.codex" } }, + darwinDesktopAppAdapter: { discover: () => { + if (scenario.discoveryFails) throw new Error("private discovery diagnostic"); + return scenario.noInstall ? null : { id: "com.openai.codex", root, relaunch: "com.openai.codex" }; + } }, })); mock.module("../../src/chatgpt/app-server-shim/launcher", () => ({ chatgptShimLauncherPath: () => launcher, @@ -69,4 +80,4 @@ mock.module("../../src/chatgpt/app-server-shim/launcher", () => ({ })); const { handleChatgptCommand } = await import("../../src/cli/chatgpt-command"); const code = await handleChatgptCommand([scenario.sub ?? "restore"], "darwin"); -console.log(JSON.stringify({ code, calls, launcherExists: fs.existsSync(launcher), root, shell, binary })); +console.log(JSON.stringify({ code, calls, launcherExists: fs.existsSync(launcher), launcherContent: fs.existsSync(launcher) ? fs.readFileSync(launcher, "utf8") : null, root, shell, binary })); diff --git a/tests/lib/socks5-tls-lifecycle.test.ts b/tests/lib/socks5-tls-lifecycle.test.ts new file mode 100644 index 0000000000..08e6233ff7 --- /dev/null +++ b/tests/lib/socks5-tls-lifecycle.test.ts @@ -0,0 +1,123 @@ +import { describe, test } from "bun:test"; +import assert from "node:assert/strict"; +import { createServer, type AddressInfo, type Socket } from "node:net"; +import { socks5Fetch } from "../../src/lib/socks5-fetch"; + +const CONNECT_TIMEOUT_MS = 30_000; +type Failure = "deadline" | "abort" | "null-abort" | "close" | "invalid-tls"; + +/** SOCKS accepts CONNECT, then observes real TLS bytes without contacting an upstream. */ +async function checkHandshakeFailure(failure: Failure): Promise { + const sockets = new Set(); + let markTlsSeen: () => void = () => {}; + let markPeerClosed: () => void = () => {}; + const tlsSeen = new Promise(resolve => { markTlsSeen = resolve; }); + const peerClosed = new Promise(resolve => { markPeerClosed = resolve; }); + let peer: Socket | undefined; + const server = createServer(socket => { + peer = socket; + sockets.add(socket); + socket.once("error", () => { /* fixture teardown may reset the connection */ }); + socket.once("close", () => { sockets.delete(socket); markPeerClosed(); }); + let stage: "greeting" | "connect" | "tls" = "greeting"; + let buffer = Buffer.alloc(0); + socket.on("data", chunk => { + buffer = Buffer.concat([buffer, typeof chunk === "string" ? Buffer.from(chunk) : chunk]); + if (stage === "greeting") { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]!) return; + buffer = buffer.subarray(2 + buffer[1]!); + socket.write(Buffer.from([5, 0])); + stage = "connect"; + } + if (stage === "connect") { + if (buffer.length < 5 || buffer.length < 7 + buffer[4]!) return; + buffer = buffer.subarray(7 + buffer[4]!); + socket.write(Buffer.from([5, 0, 0, 1, 127, 0, 0, 1, 0, 80])); + stage = "tls"; + } + if (stage === "tls" && buffer.length > 0) { + markTlsSeen(); + buffer = Buffer.alloc(0); + } + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const deadlines = new Map, () => void>(); + let deadlinesArmed = 0; + // Capture, but do not shorten, the transport deadline. Fire it only after the + // fixture actually receives ClientHello, so machine load cannot race TCP setup. + globalThis.setTimeout = ((callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]) => { + const handle = originalSetTimeout(callback, ms, ...args); + if (ms === CONNECT_TIMEOUT_MS) { + deadlinesArmed++; + deadlines.set(handle, () => callback(...args)); + } + return handle; + }) as typeof setTimeout; + globalThis.clearTimeout = ((handle: ReturnType | undefined) => { + if (handle !== undefined) deadlines.delete(handle); + return originalClearTimeout(handle); + }) as typeof clearTimeout; + + const controller = new AbortController(); + const reason = failure === "null-abort" ? null : new Error("fixture caller cancellation"); + const watchdog = originalSetTimeout(() => controller.abort(new Error("fixture watchdog")), 2_000); + let outcome: Promise | undefined; + try { + const port = (server.address() as AddressInfo).port; + outcome = socks5Fetch("https://destination.invalid/", { signal: controller.signal }, `socks5://127.0.0.1:${port}`) + .then(() => new Error("unexpected TLS success"), (error: unknown) => error); + // Receipt is bounded by the same request outcome: a failure before ClientHello + // must fail this assertion instead of leaving the test waiting indefinitely. + const ready = await Promise.race([tlsSeen.then(() => true), outcome.then(() => false)]); + assert.equal(ready, true, "the peer must receive ClientHello before exercising TLS failure"); + // Bun also implements a socket inactivity timer through global setTimeout. + // Its already-cleared internal timer must not make the transport test Node-only. + assert.ok(deadlinesArmed >= 2, "TCP and TLS deadlines must both be observed"); + assert.equal(deadlines.size, 1, "only the TLS deadline remains active"); + + if (failure === "deadline") deadlines.values().next().value!(); + else if (failure === "abort" || failure === "null-abort") controller.abort(reason); + else if (failure === "close") peer!.destroy(); + else peer!.write("fixture invalid TLS"); + + const result = await outcome; + if (failure === "abort" || failure === "null-abort") assert.equal(result, reason); + else { + assert.ok(result instanceof Error); + if (failure === "deadline") assert.match(result.message, /SOCKS5 TLS handshake timed out/); + else if (failure === "close") assert.match(result.message, /TLS handshake|disconnected before secure TLS/); + else assert.match(result.message, /ssl|wrong version|packet|tls/i); + } + assert.equal(deadlines.size, 0, "terminal failure clears the owned deadline"); + let closeWatchdog: ReturnType | undefined; + try { + await Promise.race([peerClosed, new Promise((_, reject) => { + closeWatchdog = originalSetTimeout(() => reject(new Error("TLS peer remained open")), 2_000); + })]); + } finally { originalClearTimeout(closeWatchdog); } + } finally { + controller.abort(); + await outcome; + originalClearTimeout(watchdog); + for (const handle of deadlines.keys()) originalClearTimeout(handle); + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } +} + +describe("SOCKS5 TLS handshake lifecycle", () => { + test("a stalled TLS handshake reaches an absolute deadline and releases its socket", () => checkHandshakeFailure("deadline")); + test("caller cancellation preserves its reason and clears the TLS deadline", () => checkHandshakeFailure("abort")); + test("a null cancellation reason is preserved", () => checkHandshakeFailure("null-abort")); + test("premature peer close releases the TLS deadline", () => checkHandshakeFailure("close")); + test("TLS protocol failure releases the TLS deadline", () => checkHandshakeFailure("invalid-tls")); +});