Repository navigation
[Bug][Windows] Pinned transport kills the upstream socket after the first response-body chunk — local cleartext providers can never stream (502 upstream_server_error) #6684
Description
Client or integration
Codex App — also reproducible with a plain curl against the local ocx port, so no client-specific behaviour is involved.
Provider or upstream service
A custom local provider (source: custom) pointing at a local OpenAI-compatible gateway:
{
"adapter": "openai-chat",
"baseUrl": "http://127.0.0.1:7963/v1",
"authMode": "key",
"allowPrivateNetwork": true
}The upstream is a local Go server that streams OpenAI-compatible chat/completions SSE (token-by-token, : heartbeat comments, Transfer-Encoding: chunked).
OpenCodex version
opencodex 2.78.0 — standalone Windows x64 build, bundled Bun 1.4.0. ocx doctor reports ok service pid : Bun 1.4.0 on win32 and streamMode=auto (eager relay: on, auto-fixed-runtime). Platform: Windows 11 Pro 10.0.26200, AMD64.
Endpoint or capability
streaming — inbound /v1/responses (translated to chat) and inbound /v1/chat/completions both fail identically.
Current behaviour
Every streaming request to this local provider fails deterministically (see the usage.jsonl record under Actual response or error): the relay dies with upstream_server_error / failureStage=protocol-prelude / failureCause=upstream-fault right after the upstream starts streaming. Non-streaming inbound requests sometimes return a Bun 500 error page instead.
The upstream's own request log marks these requests as ok=false, outcome=interrupted with ttfb_ms ≈ duration_ms and user_agent = Bun/1.4.0 — i.e. the upstream believes the client hung up: 27 of 29 ocx requests were interrupted, while 162 requests from a non-ocx client (user_agent = deepseek-harness/...) all returned 200.
Expected behaviour
A chunked/streamed response body should relay correctly through the provider transport regardless of how many writes the upstream uses — exactly as Node's http client and curl handle the same endpoint. Alternatively, route local cleartext providers through the same outbound path used by proxied providers, or allow the HTTP-version pin on http:// targets.
Minimal redacted request or reproduction
The discriminator is how many writes the upstream uses, not the payload. The mock upstream below serves the same SSE body two ways, selected by model id:
node ocx-issue-repro-mock.mjs # 40-line Node SSE server on 127.0.0.1:7999, body in one write vs six writes
ocx provider add ocxrepro --adapter openai-chat --base-url http://127.0.0.1:7999/v1 \
--auth-mode key --api-key dummy --allow-private-network --live
# A) whole body in ONE res.write() + Content-Length
curl -s -N -H "Content-Type: application/json" \
-d '{"model":"ocxrepro/repro-single","messages":[{"role":"user","content":"hi"}],"stream":true}' \
http://127.0.0.1:10100/v1/chat/completions
# B) identical body, six res.write() calls 150 ms apart
curl -s -N -H "Content-Type: application/json" \
-d '{"model":"ocxrepro/repro-multi","messages":[{"role":"user","content":"hi"}],"stream":true}' \
http://127.0.0.1:10100/v1/chat/completionsThe mock server source is attached under Additional context and attachments.
Actual response or error
usage.jsonl (one of many identical rows):
{
"status": 502,
"errorCode": "upstream_server_error",
"failureStage": "protocol-prelude",
"failureCause": "upstream-fault",
"upstreamError": "Provider stream error: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()",
"attempts": [{ "deliverySummary": { "relayedEvents": 3, "terminalEvents": 1 } }]
}Case A (single write) streams to completion. Case B (six writes) ends with:
event: response.failed
data: {"type":"response.failed","response":{"status":"failed","error":{"message":"The socket connection was closed unexpectedly...","code":"upstream_server_error"}}}
data: [DONE]
HTTP 502
# mock upstream console, case B — note the 1 ms gap
POST received, mode=MULTI-WRITE
wrote first chunk
client socket closed (writableEnded=false)
subsequent writes: ok=false
A non-streaming inbound request instead returns a Bun 500 page whose payload names the failing frame:
{"name":"TypeError","message":"The socket connection was closed unexpectedly...","stack":{"frames":[{"function_name":"pull","file":"B:/~BUN/root/ocx.exe","source_lines":[{"text":"const { done, value: value2 } = await reader.read();"}]}]}}Upstream documentation
The upstream is a private local gateway; there is no public specification for it. It implements the standard OpenAI Chat Completions wire format with SSE streaming, for which the public reference is https://platform.openai.com/docs/api-reference/chat/streaming (data: {...} frames terminated by data: [DONE]). The same incompatibility reproduces against a hand-written Node SSE server (attached), so no third-party behaviour is required to trigger it.
Suggested mapping or implementation notes
src/lib/provider-outbound.ts sends a request through pinnedHttpPost/pinnedHttpGet (i.e. Bun's node:http client) whenever proxyApplies is false. For a local cleartext provider that is the only reachable path:
- cleartext POST + any outbound proxy →
ProviderOutboundPolicyError: a cleartext provider POST cannot use an outbound proxy - baseUrl switched to
https://with the proxy kept →provider URL resolves to a private-network destination; add <host> to NO_PROXY
Public https providers escape the pinned path (they use configuredOutboundFetch, Bun fetch + CONNECT, when a proxy is configured), which is why this lane looks different from the existing reports. Either the pinned transport should handle multi-write response bodies, or local cleartext providers should be allowed onto the configuredOutboundFetch path.
Additional context and attachments
Isolation performed (single-variable, deterministic):
curland Nodefetch/undici against the same endpoint stream the full SSE body 100 % of the time — the upstream is not at fault.- With a TCP relay between ocx and the upstream, ocx receives
200 OKplus the first chunked SSE chunk and then RSTs the socket ~1 ms later (relay logsCLIENT error ECONNRESET; the upstream logsinterrupted). - Replacing the Go gateway with a 40-line Node SSE server reproduces it exactly, so this is not provider-specific.
- Same body, different write strategy:
| Upstream writes the body as… | Result through ocx |
|---|---|
one res.write(wholeBody) + Content-Length |
completes (tested 400 B / 939 B / 4 KB / 16 KB, and a real 265 KB response) |
several res.write(), 150 ms apart, chunked keep-alive |
always 502 |
several res.write(), 150 ms apart, Connection: close |
always 502 |
several res.write(), no delay (burst) |
always 502 |
Independent of body size (16 KB in ONE write succeeds), SSE event count, and payload shape (empty deltas make no difference).
Relation to existing reports: #2885 is the same error string but on a public https provider with a Bun 1.4.0 vs 1.3.14 A/B; this report is the local cleartext / pinned lane with a deterministic discriminator. The #2908 workaround does not apply here — on an http:// target, ocx provider edit <name> --upstream-http-version http1.1 fails with Provider unreachable: upstream HTTP version pin requires an HTTPS target (received http:). #6187 was a different lane again (Clash fake-ip + DeepSeek, resolved with a provider-level proxy).
User-side workaround currently in place: a small local buffering reverse proxy in front of the upstream (read the whole SSE body, flush once with Content-Length). 100 % reliable including a 265 KB body, but it loses token-level streaming.
Mock upstream used for the reproduction (ocx-issue-repro-mock.mjs)
// Minimal reproduction for: ocx pinned transport drops the upstream socket
// when the response body arrives in more than one write.
//
// Run: node ocx-issue-repro-mock.mjs (listens on 127.0.0.1:7999)
// Then:
// ocx provider add ocxrepro --adapter openai-chat --base-url http://127.0.0.1:7999/v1 ^
// --auth-mode key --api-key dummy --allow-private-network --live
//
// curl -s -N -H "Content-Type: application/json" ^
// -d "{\"model\":\"ocxrepro/repro-single\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":true}" ^
// http://127.0.0.1:10100/v1/chat/completions
//
// curl -s -N -H "Content-Type: application/json" ^
// -d "{\"model\":\"ocxrepro/repro-multi\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":true}" ^
// http://127.0.0.1:10100/v1/chat/completions
//
// Expected after the fix: both complete.
// Observed: single-write completes, multi-write ends in 502 upstream_server_error
// ("The socket connection was closed unexpectedly").
import http from 'node:http';
const PORT = 7999;
const MODELS = ['repro-single', 'repro-multi'];
const t0 = Date.now();
const log = (m) => console.log('[' + ((Date.now() - t0) / 1000).toFixed(3) + 's] ' + m);
function chunk(delta, finish) {
return JSON.stringify({
choices: [{ delta: delta, finish_reason: finish || null, index: 0 }],
created: 1700000000, id: 'chatcmpl-repro', model: 'repro',
object: 'chat.completion.chunk', usage: null,
});
}
const deltas = [{ role: 'assistant' }, { content: 'Hello' }, { content: ' world' }, { content: ' from' }, { content: ' ocx' }, {}];
function sseBody() {
let s = '';
for (const d of deltas) s += 'data: ' + chunk(d) + '\n\n';
s += 'data: ' + chunk({}, 'stop') + '\n\n';
s += 'data: [DONE]\n\n';
return s;
}
http.createServer((req, res) => {
if (req.method === 'GET') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ object: 'list', data: MODELS.map((id) => ({ id: id, object: 'model', owned_by: 'repro' })) }));
return;
}
let body = '';
req.on('data', (d) => { body += d; });
req.on('end', async () => {
const multi = body.indexOf('multi') !== -1;
log('POST received, mode=' + (multi ? 'MULTI-WRITE' : 'SINGLE-WRITE'));
res.on('close', () => log(' client socket closed (writableEnded=' + res.writableEnded + ')'));
const payload = sseBody();
if (!multi) {
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'content-length': Buffer.byteLength(payload) });
res.end(payload);
log(' wrote whole body in ONE call: ' + Buffer.byteLength(payload) + 'B + content-length');
return;
}
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
for (const d of deltas) {
await new Promise((r) => setTimeout(r, 150));
res.write('data: ' + chunk(d) + '\n\n');
}
res.write('data: ' + chunk({}, 'stop') + '\n\n');
res.write('data: [DONE]\n\n');
res.end();
log(' multi write finished');
});
}).listen(PORT, '127.0.0.1', () => log('mock upstream listening on 127.0.0.1:' + PORT));Checks
- I searched existing provider and compatibility issues.
- The request and response were redacted.
- The expected behaviour is based on an upstream specification or a concrete client requirement.
Activity
github-actions commentedon Oct 6, 2026
Issue reopened
The report now contains the information required by the automated check. Thanks for updating it.
Ingwannu commentedon Oct 7, 2026
The single-write versus delayed multi-write fixture is a useful discriminator; this should remain separate from generic Bun socket-reset reports. Current dev c30b522 still routes direct outbound HTTP through src/lib/pinned-http.ts. Inspection does not by itself establish which Windows/Bun event destroys the socket, and I have not reproduced your Windows executable here.
For the next regression, please compare the standalone pinnedHttpPost/pinnedHttpGet helper with node:http and Bun.fetch against the same synthetic server on Windows Bun 1.4.0, preserving the six-write body and recording only event order (headers/data/end/error/close), byte count, and cancellation/deadline state. If the standalone pinned helper succeeds, repeat through the provider wrapper to isolate its ownership/abort handoff. A Linux result is a control, not proof that the Windows failure is fixed.
Please do not bypass validated-address pinning or route all direct/private HTTP through the proxy path as a workaround in the implementation; those are separate transport policies. Your buffering workaround loses incremental delivery and does not establish safe memory bounds for larger bodies. No production endpoint/configuration change is required to obtain this fixture.
cybertingzhen commentedon Oct 7, 2026
Ran the regression you asked for on Windows, then followed it to what I now believe is the actual cause. Consolidated into one comment; earlier separate comments added to the noise and one of my intermediate conclusions was wrong.
Fixture. One synthetic server, one SSE body (953 B), two write strategies:
repro-single= the whole body in oneres.write()+Content-Length;repro-multi= the identical body in sixres.write()calls 150 ms apart, chunked, withres.closerecorded. Only event order (socket/send/headers/data/end/error/close, plus destroy call sites), byte count, and cancellation/deadline state were recorded.1. Standalone helper vs
node:httpvsBun.fetch(Windows, Bun 1.4.0, fresh connection per client)pinnedHttpPost(helper verbatim)res.close complete=truenode:httpBun.fetchOrdered events for the pinned case:
socket.connect → req.finish → headers(200, chunked) → data(13) → data(188) → data(187) → data(188) → data(191) → data(186) → end → res.close(complete=true). No cancellation state ever becomes true.2. Through the provider wrapper
Standalone binary 2.78.0 with one custom
openai-chatprovider pointing at the fixture; twelve streaming requests to/v1/chat/completions. 12/12 failed:502 upstream_server_error,failureStage=protocol-prelude,failureCause=upstream-fault,firstOutputMsabsent. The upstream receivedand every request ended
write #1 13B ok=true→ 1–4 ms laterres.close writableEnded=false destroyed=true afterWrite=1 cum=13B→ next writeok=false destroyed=true.3. Time and client are not variables
First-write delay 0 / 150 / 400 / 1000 / 2000 ms, body and config unchanged: total request time tracks the delay linearly (170 → 2045 ms) while the teardown stays 3–4 ms after chunk #1 and always before write #2,
writableEnded=falseevery time. A client that aborts 0.30 s in produces an identical upstream timeline.4. What the failing request actually is: the fetch lane, and the second
read()ua=Bun/1.4.0(no+),accept: */*present,accept-encoding: identity, computedcontent-length, nocontent-encodingon the response — that is the fetch lane's shape. The pinned helper sendshost+accept-encoding: identityand noaccept. Running ocx's own header shape on both transports, same server, same run:Bun.fetch(1.4.0), ocx's header shapeTypeError: The socket connection was closed unexpectedly…, 1 data event, 13 Bwrite #1 13B ok=true→res.close writableEnded=false destroyed=true afterWrite=1 cum=13Bnode:http(1.4.0), same bodyres.close writableEnded=trueFour consumption patterns against the identical body:
fetchwritableEnded=falsefetchwritableEnded=truefetchwritableEnded=truenode:httpEvery 1.4.0 fetch failure lands on the second
reader.read(), immediately or after 50/200 ms of quiet; asking for only the first chunk raises no client error, yet the upstream still records the socket as destroyed. On this runtime the pinned transport is unaffected, and the same code is unaffected on 1.3.14 / 1.4.2.A Bun-only fixture (one process serves the six-write SSE and reads it back, no Node, no ocx, 10 attempts) fails 7/10 on 1.4.0, 0/10 on 1.3.14 and 0/10 on 1.4.2, and 0/3 on the one-write body in all three.
5. Correction, and where this leaves the issue
In an earlier comment I said the remaining variable was the provider wrapper's ownership/abort handoff. That was wrong: the pinned lane never fails here, and the lane that does fail reproduces without ocx in about 40 lines. I also built an instrumented copy of the standalone binary (one equal-length in-place edit, 108455936 bytes in and out, 59 differing bytes at offset 90167227) that turns the pinned body-cancel destroy into an
OCXPROBE=offswitch, for the case where that handoff needs measuring rather than reasoning about; it answers--versionandstatus, butstartrefuses on this machine withEPERM … mkdir '<home>\service-state.json.mutation.lock'fromacquireOwnershipMutationLease, while the unmodified binary starts in the same shell with the same home.None of this touches validated-address pinning, proxy selection, or egress policy, and no ocx code path has to change to remove the failure on this machine — the bundled runtime is the variable.
6. Attachments and raw output
quiet-reader.mjs,pinned-quiet-client.mjs,fetch-explicit.mjs, and the raw matrices: https://gist.github.com/cybertingzhen/3b20a6fdeaa28716140b4d51e97653a6Happy to bisect 1.4.0 → 1.4.1 → 1.4.2 to name the fixing release, or to run any variant you prefer on Windows.
cybertingzhen commentedon Oct 7, 2026
Closing the loop on this with a controlled runtime A/B on the same machine, same provider, same body.
The two runs
Fixture unchanged: one synthetic server, one 953 B SSE body delivered in six
res.write()calls 150 ms apart. Provider: customopenai-chatpointing at it. Windows 11 10.0.26200.ocx 2.78.0 / 2.79.0 with the bundled Bun 1.4.0 — upstream sees:
ocx 2.79.0 from npm, same code, with
OPENCODEX_BUN_PATHpointed at the official Bun 1.4.2 — upstream sees:Client side on the 1.4.2 run:
HTTP 200,content-type=text/event-stream; charset=utf-8, 6data:events,: heartbeatcomments preserved,data: [DONE]reached, no error frame, 1028 B of body.Identical request shape (
acceptpresent,accept-encoding: identity, computedcontent-length) on both runs — the only variable is the runtime, and only the 1.4.0 run tears the socket down.Version bisect
Same fixture,
Bun.fetchagainst it, ten attempts per runtime:The fix lands in 1.4.1. Failure signature on 1.4.0 is always the second
reader.read()— eager, or after 50/200 ms of quiet, and even a reader that stops after chunk #1 still leaves the socket destroyed upstream.Workaround that works today
Nothing in ocx needs to change for this: the override hook already exists and
ocx statusreportsRuntime source: override (OPENCODEX_BUN_PATH).Two notes for anyone reproducing this on Windows:
bundependency is1.4.0, i.e. the published launcher defaults to the affected runtime; the override is what moves it.C:\Users\<user>\Documents\...cannot create directories under%USERPROFILE%\.opencodex(EPERMonservice-state.json.mutation.lock, fromacquireOwnershipMutationLease), while the same file launched fromD:\can — so the install and runtime live underD:\.Suggestion
Bump the Bun version used for the release builds to 1.4.1 or newer. That removes the failure on Windows without touching the pinned transport, the ownership/abort handoff, or any egress policy — the pinned lane was never involved in this failure, and
OPENCODEX_BUN_PATHremains a usable escape hatch in the meantime.lidge-jun commentedon Oct 7, 2026
Status: the reporter's A/B attributes this to a Bun.fetch regression in Bun 1.4.0 that 1.4.1+ fixes. Moving the project pin to Bun 1.4.2 (#6713) is on hold because 1.4.2 crashes the project's multi-file test runner on all three CI platforms. Until a safe pin lands, running OpenCodex on a newer Bun (as in the report) avoids the dropped stream.
lidge-jun commentedon Oct 8, 2026
Fixed on
devby #6713 (merge8d30945d50).Cause: Bun 1.4.0's
Bun.fetchon Windows closed a local cleartext upstream socket after the first streamed response-body chunk, so a local OpenAI-compatible provider never finished streaming and OpenCodex returned502 upstream_server_error. Bun 1.4.1+ fixes it. Your controlled A/B showed the same thing: all six upstream writes arrived on Bun 1.4.2 and the stream dropped on 1.4.0.Fix: the shipped runtime is now exact Bun 1.4.2. That covers the npm
bundependency, the Docker image pinned by digest, and the standalone and desktop sidecar builds, which compile from the runtime role. OpenCodex's own CI test runner stays on Bun 1.4.0 (package.jsontestRunnerBun), because Bun 1.4.2 still crashes in multi-filebun test --isolate. That split affects only the repository's tests, not what users run.It ships in the next release. Until then, running OpenCodex on a newer Bun (as in your report) avoids the dropped stream. Thank you for the precise reproduction.