diff --git a/CHANGELOG.md b/CHANGELOG.md index b8521ad5..c32c38b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. - **Settings:** OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech). - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. - **Chat:** an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech). +- **Stability/Proxy:** the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja). - Usage/Claude: Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes. - Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech). - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. diff --git a/packages/web/server/lib/opencode/proxy-agent-wiring.test.js b/packages/web/server/lib/opencode/proxy-agent-wiring.test.js new file mode 100644 index 00000000..33a32de5 --- /dev/null +++ b/packages/web/server/lib/opencode/proxy-agent-wiring.test.js @@ -0,0 +1,152 @@ +import http from 'node:http'; +import https from 'node:https'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { createProxyMiddlewareMock } = vi.hoisted(() => ({ + createProxyMiddlewareMock: vi.fn(), +})); + +vi.mock('http-proxy-middleware', () => ({ + createProxyMiddleware: createProxyMiddlewareMock, +})); + +const { registerOpenCodeProxy } = await import('./proxy.js'); + +const createStubApp = () => { + const settings = new Map(); + const noop = () => {}; + + return { + get: (...args) => (args.length === 1 ? settings.get(args[0]) : undefined), + set: (key, value) => { + settings.set(key, value); + }, + use: noop, + post: noop, + put: noop, + patch: noop, + delete: noop, + all: noop, + }; +}; + +/** + * `state` is intentionally mutable so a test can model the production ordering: + * the proxy is registered before OpenCode bootstraps, so the port/base URL only + * become resolvable afterwards. + */ +const createStubDeps = (state) => ({ + fs: { promises: { realpath: async (value) => value } }, + os: {}, + path: {}, + OPEN_CODE_READY_GRACE_MS: 0, + LONG_REQUEST_TIMEOUT_MS: 1_000, + getRuntime: () => ({ openCodePort: state.port, openCodeBaseUrl: state.baseUrl }), + getOpenCodeAuthHeaders: () => ({}), + // Mirrors network-runtime.js: throws until the port is known. + buildOpenCodeUrl: (pathname) => { + if (!state.port) { + throw new Error('OpenCode port is not available'); + } + return `${state.baseUrl}${pathname}`; + }, + ensureOpenCodeApiPrefix: (pathname) => pathname, +}); + +const managedState = () => ({ port: 49303, baseUrl: 'http://127.0.0.1:49303' }); +const coldState = () => ({ port: null, baseUrl: null }); + +const agentsFromCalls = () => createProxyMiddlewareMock.mock.calls.map(([options]) => options.agent); + +describe('OpenCode API proxy agent wiring', () => { + beforeEach(() => { + createProxyMiddlewareMock.mockReset(); + createProxyMiddlewareMock.mockImplementation(() => (_req, _res, next) => next?.()); + }); + + it('constructs every proxy with a keep-alive agent', () => { + registerOpenCodeProxy(createStubApp(), createStubDeps(managedState())); + + expect(createProxyMiddlewareMock).toHaveBeenCalled(); + + for (const agent of agentsFromCalls()) { + // Without an explicit agent, http-proxy falls back to `agent: false`, + // which forces `Connection: close` and burns one ephemeral port per + // request. See createOpenCodeProxyAgent in ./proxy.js. + expect(agent).toBeTruthy(); + expect(agent.options?.keepAlive).toBe(true); + } + }); + + it('shares one agent instance across the API and OAuth proxies', () => { + registerOpenCodeProxy(createStubApp(), createStubDeps(managedState())); + + const agents = agentsFromCalls(); + + expect(agents.length).toBeGreaterThan(1); + expect(agents.every(Boolean)).toBe(true); + expect(new Set(agents).size).toBe(1); + }); + + it('memoizes the agent per scheme rather than allocating one per resolution', () => { + registerOpenCodeProxy(createStubApp(), createStubDeps(managedState())); + + const [options] = createProxyMiddlewareMock.mock.calls[0]; + + expect(options.agent).toBe(options.agent); + }); + + // Production ordering: startup-pipeline-runtime.js calls setupProxy() before + // bootstrapOpenCodeAtStartup(), so at registration the port is null, + // buildOpenCodeUrl throws, and resolveProxyTarget() falls back to the http + // loopback default. An external https server configured via OPENCODE_HOST is + // only visible after bootstrap, so the agent must be resolved lazily. + it('resolves an https agent after bootstrap even though registration ran cold', () => { + const state = coldState(); + registerOpenCodeProxy(createStubApp(), createStubDeps(state)); + + // Cold: nothing resolvable yet, so the http fallback target applies. + for (const agent of agentsFromCalls()) { + expect(agent).not.toBeInstanceOf(https.Agent); + } + + // Bootstrap completes against an external https server. + state.baseUrl = 'https://opencode.example.com:4096'; + + for (const agent of agentsFromCalls()) { + expect(agent).toBeInstanceOf(https.Agent); + // Asserted on the live resolver path, not just the exported factory: + // the https branch is the one a mutation could silently strip. + expect(agent.options?.keepAlive).toBe(true); + expect(agent.options?.maxFreeSockets).toBe(256); + } + }); + + it('keeps a plain http agent when bootstrap resolves an http target', () => { + const state = coldState(); + registerOpenCodeProxy(createStubApp(), createStubDeps(state)); + + Object.assign(state, managedState()); + + for (const agent of agentsFromCalls()) { + // https.Agent extends http.Agent, so the negative assertion is load-bearing. + expect(agent).toBeInstanceOf(http.Agent); + expect(agent).not.toBeInstanceOf(https.Agent); + } + }); + + it('derives an https agent when the target is already https at registration', () => { + registerOpenCodeProxy( + createStubApp(), + createStubDeps({ port: 4096, baseUrl: 'https://opencode.example.com:4096' }), + ); + + const agents = agentsFromCalls(); + + expect(agents.length).toBeGreaterThan(0); + for (const agent of agents) { + expect(agent).toBeInstanceOf(https.Agent); + } + }); +}); diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index 48a14f38..adcf9ae4 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -1,3 +1,6 @@ +import http from 'node:http'; +import https from 'node:https'; + import { createProxyMiddleware } from 'http-proxy-middleware'; import { @@ -11,6 +14,96 @@ import { recordStartupPerformance } from './startup-performance.js'; const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 20_000; +const OPENCODE_AGENT_KEEP_ALIVE_MS = 30_000; +// Node's own default. A lower cap evicts pooled sockets under concurrency, +// which reintroduces exactly the per-request connection churn this agent +// exists to prevent (measured: at 64 concurrent requests, a cap of 32 left +// 303 sockets in TIME_WAIT versus 0 at 256). +const OPENCODE_AGENT_MAX_FREE_SOCKETS = 256; +// Evicts idle free sockets from our side. Without it the only thing that +// retires an idle pooled socket is the upstream closing it. Note this is +// distinct from `keepAliveMsecs`, which is the TCP keep-alive probe delay. +const OPENCODE_AGENT_IDLE_TIMEOUT_MS = 60_000; + +const OPENCODE_AGENT_OPTIONS = { + keepAlive: true, + keepAliveMsecs: OPENCODE_AGENT_KEEP_ALIVE_MS, + maxSockets: Infinity, + maxFreeSockets: OPENCODE_AGENT_MAX_FREE_SOCKETS, + timeout: OPENCODE_AGENT_IDLE_TIMEOUT_MS, +}; + +const isHttpsProxyTarget = (target) => { + if (typeof target !== 'string') { + return false; + } + try { + return new URL(target).protocol === 'https:'; + } catch { + return /^https:/i.test(target.trim()); + } +}; + +/** + * Agent for proxied OpenCode API requests. + * + * When no agent is supplied, `http-proxy` falls back to `agent: false`, which + * both disables connection pooling and forces `Connection: close` on every + * proxied request (http-proxy/lib/http-proxy/common.js). That consumes one + * ephemeral port per request, and sustained traffic can exhaust the host's + * ephemeral port range — after which every process on the machine fails to + * open outbound connections with EADDRNOTAVAIL. + * + * The agent must match the target scheme: http-proxy dispatches through + * `https.request` when `target.protocol === 'https:'` + * (http-proxy/lib/http-proxy/passes/web-incoming.js), and an `http.Agent` + * would open a plaintext socket to a TLS port. External servers may be + * configured over https via `OPENCODE_HOST` (see env-config.js), so derive the + * agent class from the resolved target. + * + * `maxSockets: Infinity` preserves the unbounded concurrency of `agent: false`, + * so this changes connection reuse only, not request throughput. + */ +export const createOpenCodeProxyAgent = (target) => ( + isHttpsProxyTarget(target) + ? new https.Agent(OPENCODE_AGENT_OPTIONS) + : new http.Agent(OPENCODE_AGENT_OPTIONS) +); + +/** + * Lazily resolves the proxy agent, memoized per scheme. + * + * The scheme cannot be decided at registration time: `setupProxy()` runs before + * `bootstrapOpenCodeAtStartup()` (startup-pipeline-runtime.js), so on a cold + * start `state.openCodePort` is still null, `buildOpenCodeUrl()` throws + * (network-runtime.js) and `resolveProxyTarget()` falls back to the http + * loopback default. An external server configured over https via + * `OPENCODE_HOST` only becomes visible on `state.openCodeBaseUrl` after + * bootstrap completes. + * + * http-proxy-middleware rebuilds its per-request options with + * `Object.assign({}, this.proxyOptions)` inside `prepareProxyRequest`, which + * invokes getters, so exposing `agent` as a getter defers resolution to request + * time. Memoizing per scheme keeps a single shared pool per scheme rather than + * allocating an agent per request. + */ +const createOpenCodeProxyAgentResolver = (resolveTarget) => { + const agents = new Map(); + + return () => { + const target = resolveTarget(); + const scheme = isHttpsProxyTarget(target) ? 'https:' : 'http:'; + let agent = agents.get(scheme); + if (!agent) { + // Construct through the shared factory rather than inline, so both + // schemes are built from OPENCODE_AGENT_OPTIONS by the same code path. + agent = createOpenCodeProxyAgent(target); + agents.set(scheme, agent); + } + return agent; + }; +}; + export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => { const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions }); @@ -285,15 +378,22 @@ export const registerOpenCodeProxy = (app, deps) => { // and direct fetch helpers use. This avoids split-brain state where /health // succeeds against an external host but /api/* still proxies to 127.0.0.1. const resolveProxyTarget = () => { - try { - const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', '')); - if (resolved) { - return resolved; + const runtimeState = getRuntime(); + + // `buildOpenCodeUrl` throws while the port is unknown, and the port is + // nulled on several runtime paths (health-check failure, failed restart), + // not just cold start. Checking first keeps a degraded OpenCode from + // making every proxied request pay for a thrown-and-caught exception. + if (runtimeState.openCodePort) { + try { + const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', '')); + if (resolved) { + return resolved; + } + } catch { } - } catch { } - const runtimeState = getRuntime(); const externalBase = normalizeProxyTarget(runtimeState.openCodeBaseUrl); if (externalBase) { return externalBase; @@ -767,8 +867,18 @@ export const registerOpenCodeProxy = (app, deps) => { }); // Generic proxy for non-SSE OpenCode API routes. + // The agent is exposed as a getter so its class is resolved per request, not + // at registration: the proxy is registered before OpenCode bootstraps, so an + // https target configured via OPENCODE_HOST is not yet visible here. Agents + // are memoized per scheme, so this is still one shared pool per scheme across + // `apiProxy` and `interactiveOAuthProxy`. + const resolveOpenCodeProxyAgent = createOpenCodeProxyAgentResolver(resolveProxyTarget); + const createApiProxy = (timeoutMs) => createProxyMiddleware({ target: resolveProxyTarget(), + get agent() { + return resolveOpenCodeProxyAgent(); + }, changeOrigin: true, pathRewrite: { '^/api': '' }, timeout: timeoutMs, diff --git a/packages/web/server/lib/opencode/proxy.test.js b/packages/web/server/lib/opencode/proxy.test.js index 91326d71..77346d47 100644 --- a/packages/web/server/lib/opencode/proxy.test.js +++ b/packages/web/server/lib/opencode/proxy.test.js @@ -1,6 +1,14 @@ +import http from 'node:http'; +import https from 'node:https'; + +import { createProxyMiddleware } from 'http-proxy-middleware'; import { describe, expect, it } from 'vitest'; -import { createDirectoryQueryCanonicalizer, normalizeForwardedDirectoryHeaders } from './proxy.js'; +import { + createDirectoryQueryCanonicalizer, + createOpenCodeProxyAgent, + normalizeForwardedDirectoryHeaders, +} from './proxy.js'; describe('createDirectoryQueryCanonicalizer', () => { it('canonicalizes directory query params and preserves other params', async () => { @@ -93,3 +101,152 @@ describe('normalizeForwardedDirectoryHeaders', () => { }); }); }); + +const listen = (server) => new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(server.address().port); + }); +}); + +const closeServer = (server) => new Promise((resolve) => { + server.close(resolve); +}); + +const request = (port, agent) => new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path: '/', method: 'GET', agent }, (res) => { + res.resume(); + res.on('end', resolve); + res.on('error', reject); + }); + req.on('error', reject); + req.end(); +}); + +/** + * Proxies two sequential requests through `createProxyMiddleware` and reports + * what the upstream server observed for each one. + */ +const proxyTwoRequests = async (proxyAgent) => { + const seen = []; + let middleware; + const upstream = http.createServer((req, res) => { + seen.push({ connection: req.headers.connection, remotePort: req.socket.remotePort }); + res.end('ok'); + }); + const front = http.createServer((req, res) => { + middleware(req, res, () => { + res.statusCode = 502; + res.end(); + }); + }); + const clientAgent = new http.Agent({ keepAlive: true }); + + try { + const upstreamPort = await listen(upstream); + middleware = createProxyMiddleware({ + target: `http://127.0.0.1:${upstreamPort}`, + ...(proxyAgent ? { agent: proxyAgent } : {}), + }); + + const frontPort = await listen(front); + await request(frontPort, clientAgent); + await request(frontPort, clientAgent); + } finally { + clientAgent.destroy(); + proxyAgent?.destroy(); + await closeServer(front); + await closeServer(upstream); + } + + return seen; +}; + +describe('createOpenCodeProxyAgent', () => { + it('reuses a single upstream socket across sequential proxied requests', async () => { + const seen = await proxyTwoRequests(createOpenCodeProxyAgent('http://127.0.0.1')); + + expect(seen).toHaveLength(2); + expect(seen[0].connection).not.toBe('close'); + expect(seen[1].remotePort).toBe(seen[0].remotePort); + }); + + it('without an agent, http-proxy forces Connection: close and a new socket per request', async () => { + const seen = await proxyTwoRequests(null); + + expect(seen).toHaveLength(2); + expect(seen[0].connection).toBe('close'); + expect(seen[1].remotePort).not.toBe(seen[0].remotePort); + }); + + // http-proxy dispatches through `https.request` when the target protocol is + // `https:`, so an http.Agent would open a plaintext socket to a TLS port. + // External OpenCode servers can be configured over https via OPENCODE_HOST. + it('returns an https agent for https targets', () => { + const agent = createOpenCodeProxyAgent('https://opencode.example.com:4096'); + + expect(agent).toBeInstanceOf(https.Agent); + expect(agent.options.keepAlive).toBe(true); + }); + + it('returns a plain http agent for http targets', () => { + const agent = createOpenCodeProxyAgent('http://127.0.0.1:4096'); + + // https.Agent extends http.Agent, so the negative assertion is the load-bearing one. + expect(agent).toBeInstanceOf(http.Agent); + expect(agent).not.toBeInstanceOf(https.Agent); + expect(agent.options.keepAlive).toBe(true); + }); + + it('falls back to an http agent for missing or unparseable targets', () => { + expect(createOpenCodeProxyAgent(undefined)).not.toBeInstanceOf(https.Agent); + expect(createOpenCodeProxyAgent('not a url')).not.toBeInstanceOf(https.Agent); + }); + + // The cold-start fix relies on http-proxy-middleware rebuilding its per-request + // options via `Object.assign({}, this.proxyOptions)` in prepareProxyRequest, + // which invokes getters. If that ever changes to a cached or shallow-reference + // copy, the agent would freeze at its registration-time value and https targets + // would silently regress — so pin the behavior here against the real library. + it('http-proxy-middleware re-reads the agent option on every proxied request', async () => { + let reads = 0; + let middleware; + const agent = createOpenCodeProxyAgent('http://127.0.0.1'); + const upstream = http.createServer((_req, res) => res.end('ok')); + const front = http.createServer((req, res) => { + middleware(req, res, () => { + res.statusCode = 502; + res.end(); + }); + }); + const clientAgent = new http.Agent({ keepAlive: true }); + + try { + const upstreamPort = await listen(upstream); + middleware = createProxyMiddleware({ + target: `http://127.0.0.1:${upstreamPort}`, + get agent() { + reads += 1; + return agent; + }, + }); + + // Construction itself must not read the getter — otherwise the assertion + // below could be satisfied without any per-request resolution happening. + expect(reads).toBe(0); + + const frontPort = await listen(front); + await request(frontPort, clientAgent); + expect(reads).toBe(1); + + await request(frontPort, clientAgent); + expect(reads).toBe(2); + } finally { + clientAgent.destroy(); + agent.destroy(); + await closeServer(front); + await closeServer(upstream); + } + }); +});