Skip to content

[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

Closed

Description

@cybertingzhen

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/completions

The 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):

  1. curl and Node fetch/undici against the same endpoint stream the full SSE body 100 % of the time — the upstream is not at fault.
  2. With a TCP relay between ocx and the upstream, ocx receives 200 OK plus the first chunked SSE chunk and then RSTs the socket ~1 ms later (relay logs CLIENT error ECONNRESET; the upstream logs interrupted).
  3. Replacing the Go gateway with a 40-line Node SSE server reproduces it exactly, so this is not provider-specific.
  4. 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

  1. added
    platformOS/service/tray/ACL (Windows-heavy, not Windows-only)
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    on Oct 6, 2026
  2. github-actions commented on Oct 6, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  3. Ingwannu commented on Oct 7, 2026

    @Ingwannu
    Owner

    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.

  4. cybertingzhen commented on Oct 7, 2026

    @cybertingzhen
    Author

    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 one res.write() + Content-Length; repro-multi = the identical body in six res.write() calls 150 ms apart, chunked, with res.close recorded. 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:http vs Bun.fetch (Windows, Bun 1.4.0, fresh connection per client)

    client result data events bytes end local destroy
    pinnedHttpPost (helper verbatim) completes 6 953 yes, res.close complete=true none
    node:http completes 11 1893 yes none
    Bun.fetch fails 1 13 no Bun's own, right after chunk #1

    Ordered 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-chat provider pointing at the fixture; twelve streaming requests to /v1/chat/completions. 12/12 failed: 502 upstream_server_error, failureStage=protocol-prelude, failureCause=upstream-fault, firstOutputMs absent. The upstream received

    ua=Bun/1.4.0  conn=keep-alive  cl=121  ae=identity
    hdr-keys=accept-encoding,authorization,content-type,connection,user-agent,accept,host,content-length
    

    and every request ended write #1 13B ok=true → 1–4 ms later res.close writableEnded=false destroyed=true afterWrite=1 cum=13B → next write ok=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=false every 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, computed content-length, no content-encoding on the response — that is the fetch lane's shape. The pinned helper sends host + accept-encoding: identity and no accept. Running ocx's own header shape on both transports, same server, same run:

    lane result upstream sees
    Bun.fetch (1.4.0), ocx's header shape TypeError: The socket connection was closed unexpectedly…, 1 data event, 13 B write #1 13B ok=true → res.close writableEnded=false destroyed=true afterWrite=1 cum=13B
    pinned node:http (1.4.0), same body completes, 6 data events, 953 B, no error all six writes delivered, res.close writableEnded=true

    Four consumption patterns against the identical body:

    runtime eager 50 ms quiet after chunk #1 200 ms quiet after chunk #1 stop after chunk #1
    Bun 1.4.0 fetch fail (1 chunk, 13 B) fail fail no error, upstream still writableEnded=false
    Bun 1.3.14 fetch completes completes completes completes, writableEnded=true
    Bun 1.4.2 fetch completes completes completes completes, writableEnded=true
    Bun 1.4.0 pinned node:http completes completes completes —
    Bun 1.3.14 pinned completes completes completes —
    Bun 1.4.2 pinned completes completes completes —

    Every 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=off switch, for the case where that handoff needs measuring rather than reasoning about; it answers --version and status, but start refuses on this machine with EPERM … mkdir '<home>\service-state.json.mutation.lock' from acquireOwnershipMutationLease, 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

    Happy 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.

  5. cybertingzhen commented on Oct 7, 2026

    @cybertingzhen
    Author

    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: custom openai-chat pointing at it. Windows 11 10.0.26200.

    ocx 2.78.0 / 2.79.0 with the bundled Bun 1.4.0 — upstream sees:

    ua=Bun/1.4.0  conn=keep-alive  cl=121  ae=identity
    hdr-keys=accept-encoding,authorization,content-type,connection,user-agent,accept,host,content-length
      write #1/6 13B ok=true
      res.close writableEnded=false destroyed=true afterWrite=1 cum=13B
      ABORT before write #2: socket already gone
    

    ocx 2.79.0 from npm, same code, with OPENCODEX_BUN_PATH pointed at the official Bun 1.4.2 — upstream sees:

    ua=Bun/1.4.2  conn=keep-alive  cl=121  ae=identity
    hdr-keys=accept-encoding,authorization,content-type,connection,user-agent,accept,host,content-length
      write #1/6 13B ok=true cum=13B
      write #2/6 188B ok=true cum=201B
      write #3/6 187B ok=true cum=388B
      write #4/6 188B ok=true cum=576B
      write #5/6 191B ok=true cum=767B
      write #6/6 186B ok=true cum=953B
      all 6 writes delivered, cum=953B
      res.close writableEnded=true destroyed=true afterWrite=6 cum=953B
    

    Client side on the 1.4.2 run: HTTP 200, content-type=text/event-stream; charset=utf-8, 6 data: events, : heartbeat comments preserved, data: [DONE] reached, no error frame, 1028 B of body.

    Identical request shape (accept present, accept-encoding: identity, computed content-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.fetch against it, ten attempts per runtime:

    runtime six-write body one-write body
    Bun 1.4.0 7/10 failed 0/3 failed
    Bun 1.4.1 0/10 failed 0/3 failed
    Bun 1.4.2 0/10 failed 0/3 failed

    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 status reports Runtime source: override (OPENCODEX_BUN_PATH).

    $env:OPENCODEX_HOME     = 'D:\ocx-pin-test\home'
    $env:OPENCODEX_BUN_PATH = 'D:\ocx-pin-test\bun-1.4.2.exe'
    bun 'D:\ocx-pin-test\bun-global\install\global\node_modules\@bitkyc08\opencodex\bin\ocx.mjs' start --port 10131

    Two notes for anyone reproducing this on Windows:

    • The npm package's declared bun dependency is 1.4.0, i.e. the published launcher defaults to the affected runtime; the override is what moves it.
    • On this machine a Bun binary launched from C:\Users\<user>\Documents\... cannot create directories under %USERPROFILE%\.opencodex (EPERM on service-state.json.mutation.lock, from acquireOwnershipMutationLease), while the same file launched from D:\ can — so the install and runtime live under D:\.

    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_PATH remains a usable escape hatch in the meantime.

  6. lidge-jun commented on Oct 7, 2026

    @lidge-jun
    Owner

    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.

  7. lidge-jun commented on Oct 8, 2026

    @lidge-jun
    Owner

    Fixed on dev by #6713 (merge 8d30945d50).

    Cause: Bun 1.4.0's Bun.fetch on 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 returned 502 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 bun dependency, 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.json testRunnerBun), because Bun 1.4.2 still crashes in multi-file bun 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    platformOS/service/tray/ACL (Windows-heavy, not Windows-only)providerProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportsstreamingSSE, WebSocket, terminal stream frames

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions