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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ These bite from outside the subsystem that owns them, so they live here rather t
every request to that origin fails until restart (#233); Node 24 CI cannot see that. Do not gate
the install on proxy env again and do not drop the explicit `allowH2: false` because "undici 7
already defaults to it" — the option is what survives an undici 8 bump.
- **Never call `process.exit()` in `src/`; end through `exitAfterDrain()` (`src/process-exit.ts`).**
On Node 24 `process.exit()` can hang forever (nodejs/node#64274), so both bins set the exit code
and let the event loop drain, falling back to `process.exit()` after one second. That only works
if nothing holds the loop: background work that nothing awaits (the models.dev refresh, the
pricing enrichment, a WebSocket's closing handshake, outbound connections still being opened)
registers `cancelOnExit()` so it is cut off as `process.exit()` used to cut it off. Run with `CLODEX_TRACE=1` to see what held the loop when the fallback fires.
- **Every AI SDK generation entry point must resolve its timeout and retry budget through
`src/upstream-retry.ts`.** Anthropic- and OpenAI-format `streamText` consumers abort at idle and
total deadlines; `generateText` consumers abort at total only. Cancellation remains cooperative
Expand Down
23 changes: 17 additions & 6 deletions src/claude-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
wrapperSubstitutionEligible,
} from './wrapper-env.js';
import { finalizeWrapperTarget, prepareWrapperTarget } from './wrapper-target.js';
import { exitAfterDrain } from './process-exit.js';

const isWindows = process.platform === 'win32';
const WRAPPER_SERVER_READY_TIMEOUT_MS = 500;
Expand Down Expand Up @@ -155,7 +156,8 @@ async function main(): Promise<void> {

if (!checkOnly && !claudePath) {
process.stderr.write('clodex-claude: could not find the claude binary (set CLODEX_CLAUDE_PATH)\n');
process.exit(127);
exitWrapper(127);
return;
}

// Selection policy (see orderWrapperServerCandidates): proxy-mode servers
Expand All @@ -171,10 +173,14 @@ async function main(): Promise<void> {
WRAPPER_SERVER_READY_TIMEOUT_MS,
{ retryFailure: result => result === 'timeout' },
);
if (checkOnly) process.exit(state ? 0 : 1);
if (checkOnly) {
exitWrapper(state ? 0 : 1);
return;
}
if (!state && wrapperRequiresServer(process.env)) {
process.stderr.write('clodex-claude: no live clodex server is available\n');
process.exit(1);
exitWrapper(1);
return;
}
if (state && process.env[OAUTH_ACCOUNT_ENV]?.trim()) {
process.stderr.write(
Expand Down Expand Up @@ -230,15 +236,20 @@ async function main(): Promise<void> {

child.on('error', err => {
process.stderr.write(`clodex-claude: failed to launch ${claudePath}: ${err.message}\n`);
process.exit(127);
exitWrapper(127);
});
child.on('exit', (code, signal) => {
if (signal) {
const signum = osConstants.signals[signal as keyof typeof osConstants.signals];
process.exit(signum ? 128 + signum : 1);
exitWrapper(signum ? 128 + signum : 1);
return;
}
process.exit(code ?? 0);
exitWrapper(code ?? 0);
});
}

function exitWrapper(code: number): void {
exitAfterDrain(code, { label: 'clodex-claude' });
}

void main();
14 changes: 6 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
} from './http-proxy/index.js';
import { runPatchCommand, runLaunchPatchCheck } from './patcher.js';
import { installOutboundDispatcher } from './outbound-proxy.js';
import { exitAfterDrain } from './process-exit.js';
import { runInstallVscodeLauncherCommand } from './vscode-launcher.js';
import { CHECKED_EDITORS_DESCRIPTION } from './editor-extension-version.js';
const STARTER_CLAUDE_FLAGS = new Set(['--dry-run', '--trace', '--fast', '--endpoint', '--proxy', '--save-mode', '--help', '-h', '--version', '-v']);
Expand Down Expand Up @@ -1878,13 +1879,10 @@ function isCliEntryPoint(): boolean {
}

if (isCliEntryPoint()) {
main().then((exitCode) => {
process.exit(exitCode);
}).catch((err: unknown) => {
if (err === Symbol.for('clack:cancel')) {
process.exit(0);
}
console.error(pc.red('\nUnexpected error:'), err);
process.exit(1);
const exitOptions = { trace: Boolean(parseArgs(process.argv.slice(2)).trace) };
main().then((exitCode) => exitAfterDrain(exitCode, exitOptions), (err: unknown) => {
const cancelled = err === Symbol.for('clack:cancel');
if (!cancelled) console.error(pc.red('\nUnexpected error:'), err);
exitAfterDrain(cancelled ? 0 : 1, exitOptions);
});
}
31 changes: 20 additions & 11 deletions src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,21 +125,30 @@ export function launchClaude(
shell: isWindows,
});

const forward = (signal: NodeJS.Signals): void => {
child.kill(signal);
const forwardSigint = (): void => {
child.kill('SIGINT');
};
const forwardSigterm = (): void => {
child.kill('SIGTERM');
};

process.once('SIGINT', () => forward('SIGINT'));
process.once('SIGTERM', () => forward('SIGTERM'));
process.once('SIGINT', forwardSigint);
process.once('SIGTERM', forwardSigterm);
// Once claude is gone there is nothing to forward to: a Ctrl-C while clodex winds down must
// terminate it rather than be swallowed by a stale forwarder.
const stopForwarding = (): void => {
process.off('SIGINT', forwardSigint);
process.off('SIGTERM', forwardSigterm);
};

child.on('exit', (code) => {
// Both ways the child can end go through here, so neither can skip the cleanup.
const finish = (code: number): void => {
stopForwarding();
restore();
resolve(code ?? 0);
});
resolve(code);
};

child.on('error', (err) => {
restore();
resolve(1);
});
child.on('exit', (code) => finish(code ?? 0));
child.on('error', () => finish(1));
});
}
19 changes: 16 additions & 3 deletions src/oauth/responses-websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { FetchFunction } from '@ai-sdk/provider-utils';
import type { RawData, WebSocket as WsWebSocket } from 'ws';
import { CODEX_RESPONSES_WEBSOCKETS_BETA } from '../constants.js';
import { outboundWsProxyAgent } from '../outbound-proxy.js';
import { cancelOnExit } from '../process-exit.js';
import { emitParentNotice } from '../parent-notice.js';
import {
anthropicErrorType,
Expand Down Expand Up @@ -1573,9 +1574,21 @@ function deleteEntry(entry: ConnectionEntry, closeSocket = true): void {
entry.inFlight = false;
entry.current = undefined;
unregisterEntry(entry);
if (closeSocket) {
try { entry.socket.close(); } catch { /* ignore */ }
}
if (closeSocket) closeGracefully(entry.socket);
}

/** ws `readyState` while a closing handshake is in progress. */
const WS_CLOSING = 2;

/**
* Start a closing handshake that cannot hold up the process exiting. The handshake waits for the
* peer to answer behind ws's ref'd 30-second close timer; an exit requested meanwhile terminates
* the socket instead, as process.exit() used to.
*/
function closeGracefully(socket: WsWebSocket): void {
try { socket.close(); } catch { return; }
if (socket.readyState !== WS_CLOSING) return;
socket.once('close', cancelOnExit(() => socket.terminate()));
}

function failContext(
Expand Down
45 changes: 45 additions & 0 deletions src/outbound-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@

import type { Agent as HttpAgent } from 'node:http';
import { randomUUID } from 'node:crypto';
import diagnosticsChannel from 'node:diagnostics_channel';
import { Socket } from 'node:net';
import { networkInterfaces } from 'node:os';
import { TLSSocket } from 'node:tls';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { emitParentNotice } from './parent-notice.js';
import { cancelOnExit } from './process-exit.js';

// Private to this process. Sent only to an outbound proxy (not to the origin)
// and removed if a request returns to our listener, regardless of its value.
Expand Down Expand Up @@ -138,6 +142,46 @@ export function proxyUrlTargetsListener(

let dispatcherInstalled = false;

/**
* Destroy outbound connections still being opened when the process is asked to exit.
*
* Aborting a request does not abort the connection being opened for it: undici keeps dialling
* until the TCP connect and TLS handshake finish or its 10 s connect timeout fires, and
* `Agent.destroy()` cannot reach a socket it has not been handed yet. A peer that stalls the
* handshake would therefore hold a requested exit until the fallback fires. process.exit() used
* to cut these off; this does the same. Established connections are left to their owners, which
* abort or unref them.
*
* Each socket is destroyed WITH an error. undici settles a connection attempt only on connect or
* error, so a bare destroy() (which emits only 'close') would strand the request it was opened for:
* an un-aborted fetch would then never settle, and its own ref'd timeout would hold the exit
* instead. A no-op listener keeps the error from becoming an uncaught exception on a socket whose
* owner never listened for one. Two cases are not seen, and a stall there still ends through the exit
* fallback: TLS sockets on Node 22, which does not report them on this channel, and the TLS
* handshake inside an HTTP(S)_PROXY tunnel, whose socket is tracked only until the TCP connection
* to the proxy is made.
*/
function abandonPendingConnectionsOnExit(): void {
const pending = new Set<Socket>();
const track = (message: unknown): void => {
const socket = (message as { socket?: unknown }).socket;
if (!(socket instanceof Socket)) return;
pending.add(socket);
const settled = () => pending.delete(socket);
socket.once(socket instanceof TLSSocket ? 'secureConnect' : 'connect', settled);
socket.once('close', settled);
};
diagnosticsChannel.subscribe('net.client.socket', track);
cancelOnExit(() => {
diagnosticsChannel.unsubscribe('net.client.socket', track);
for (const socket of pending) {
socket.on('error', () => {});
socket.destroy(Object.assign(new Error('clodex is exiting'), { code: 'ECONNABORTED' }));
}
pending.clear();
});
}

/** Reset the install-once latch (tests only). */
export function resetOutboundDispatcherForTests(): void {
dispatcherInstalled = false;
Expand All @@ -160,6 +204,7 @@ export async function installOutboundDispatcher(): Promise<boolean> {
? new EnvHttpProxyAgent({ allowH2: false })
: new Agent({ allowH2: false });
setGlobalDispatcher(dispatcher);
abandonPendingConnectionsOnExit();
dispatcherInstalled = true;
return true;
} catch (err) {
Expand Down
110 changes: 110 additions & 0 deletions src/process-exit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// src/process-exit.ts — how both bins (clodex, clodex-claude) end the process.
//
// Not process.exit(). On Node 24 it can hang forever: it joins V8's background threads before
// tearing down the isolate, so a background compile that is waiting for the main thread to collect
// garbage is never woken (https://github.com/nodejs/node/issues/64274). Letting the event loop
// drain with process.exitCode set tears the isolate down first, which releases that thread.
//
// A drain only happens if nothing still holds the loop, so work that nothing awaits registers a
// canceller here (see cancelOnExit) and is cut off when the exit is requested, exactly as
// process.exit() used to cut it off. If something still holds the loop after the grace period,
// the process falls back to process.exit() — the old behaviour, deadlock risk included — so an
// unforeseen leak costs a short delay rather than a process that never ends.

import { writeSync } from 'node:fs';

/**
* How long a requested exit may wait for the event loop to drain before falling back to
* process.exit(). Most measured drains ended before a 1 ms poll could sample them, and none took
* more than about 60 ms, so this only matters when something unexpected holds the loop. One second keeps that case
* short for someone at a terminal, while leaving room for the waits a drain can legitimately
* include: an uncancellable in-flight DNS lookup or connection attempt, a pipe reader catching up on
* buffered output (which process.exit() can cut short), and closing sockets finishing their
* shutdown. Waiting also gives V8's background compiles time to finish, which should make a
* fallback that does fire less likely to meet the deadlock than an immediate process.exit().
*/
export const EXIT_DRAIN_GRACE_MS = 1_000;

export interface ExitAfterDrainOptions {
/** Report what still held the loop when the fallback fires. CLODEX_TRACE=1 also turns it on. */
trace?: boolean;
/** Prefix for the trace line, e.g. `clodex` or `clodex-claude`. */
label?: string;
graceMs?: number;
}

const exitCancellers = new Set<() => void>();
let exitRequested = false;

/**
* Register work that nothing awaits and that must not keep the process alive once an exit is
* requested. Returns an unregister function; call it when the work settles. Work registered after
* an exit was requested is cancelled immediately.
*/
export function cancelOnExit(cancel: () => void): () => void {
if (exitRequested) {
runCanceller(cancel);
return () => {};
}
exitCancellers.add(cancel);
return () => {
exitCancellers.delete(cancel);
};
}

function runCanceller(cancel: () => void): void {
try {
cancel();
} catch {
// A canceller that throws must not stop the exit or the other cancellers.
}
}

/** Active resources by type, e.g. `TCPSocketWrap x2, Timeout`. */
export function describeActiveResources(resources: readonly string[] = process.getActiveResourcesInfo()): string {
if (resources.length === 0) return 'nothing reported';
const counts = new Map<string, number>();
for (const resource of resources) counts.set(resource, (counts.get(resource) ?? 0) + 1);
return [...counts].map(([name, count]) => (count > 1 ? `${name} x${count}` : name)).join(', ');
}

/**
* End the process with `code` once the event loop drains, the way process.exit(code) would have,
* without process.exit()'s Node 24 deadlock. Returns immediately: the caller must stop doing work
* (return) rather than rely on this call not returning. The first request wins, as with
* process.exit(); later requests are ignored.
*/
export function exitAfterDrain(code: number, options: ExitAfterDrainOptions = {}): void {
if (exitRequested) return;
exitRequested = true;
process.exitCode = code;
// process.exit() discarded a stdio failure that arrived after the exit: a reader that has gone
// away reports EPIPE asynchronously, and unhandled it would turn this exit code into 1.
// Errors from before the request are untouched.
const ignoreStdioError = () => {};
process.stdout.on('error', ignoreStdioError);
process.stderr.on('error', ignoreStdioError);

const cancellers = [...exitCancellers];
exitCancellers.clear();
for (const cancel of cancellers) runCanceller(cancel);

const graceMs = options.graceMs ?? EXIT_DRAIN_GRACE_MS;
// Unref'd, so it never holds the loop open itself: it fires only if something else does.
const fallback = setTimeout(() => {
if (options.trace || process.env.CLODEX_TRACE === '1') {
const label = options.label ?? 'clodex';
try {
writeSync(
2,
`${label}: event loop still busy ${graceMs}ms after exit was requested; `
+ `exiting with process.exit(${code}). Still active: ${describeActiveResources()}\n`,
);
} catch {
// stderr gone; the exit matters more than the diagnostic.
}
}
process.exit(code);
}, graceMs);
fallback.unref();
}
Loading
Loading