Skip to content

fix(runtime): createProxiedFetchTransport().close() never settles when a proxy is configured, hanging connection verify #5897

Description

@kyrierekkok123-del

Summary

createProxiedFetchTransport().close() in @maka/runtime never settles when a network proxy is configured. It aborts the shared AbortController first, which destroys the proxied CONNECT tunnel socket out from under undici's ProxyAgent; ProxyAgent.destroy() then never resolves, so every caller that awaits close() in a finally hangs forever.

The most visible consequence is that adding a custom connection in the CLI hangs indefinitely at the verify step, with no error and no timeout — connection.onboarding.verify never returns, and the Host's per-provider lane stays wedged for the life of the process.

This only reproduces with policy.networkProxy.enabled === true. Without a proxy the direct dispatcher settles normally. Introduced by #5043.

Affected code

packages/runtime/src/network/scoped-fetch-transport.ts (shipped as dist/network/scoped-fetch-transport.js; the two are identical in 0.2.0-dev.64.20260929):

const close = () => {
    if (closePromise) return closePromise;
    closed = true;
    connections.abort(new Error('Connection effect fetch transport closed'));   // (1)
    closePromise = Promise.all([
        directDispatcher.destroy(...).catch(() => {}),
        proxyDispatcher?.destroy(...).catch(() => {}),                          // (2)
    ]).then(() => undefined);
    return closePromise;                                                         // (3)
};

(1) destroys the CONNECT tunnel socket, because #5043 registers that socket with abortSocket(signal, socket) in buildProxyDispatcher's clientFactory:

handler.onRequestUpgrade = (controller, status, headers, socket) => {
    abortSocket(signal, socket);
    onUpgrade?.call(handler, controller, status, headers, socket);
};

undici never observes that external socket.destroy(), so the ProxyAgent client promise stays unsettled and (2) never settles, making (3) hang.

Reproduction

Environment: maka-agent 0.2.0-dev.64.20260929, undici 8.11.2, Node 26.7.0, HTTP proxy on 127.0.0.1:10808, policy.networkProxy.enabled = true.

Minimal, no proxy fixtures needed — a real proxy and a real endpoint:

import { createProxiedFetchTransport } from '@maka/runtime/dist/network/scoped-fetch-transport.js';

const t = createProxiedFetchTransport({
  enabled: true, type: 'http', host: '127.0.0.1', port: 10808,
  bypassList: [], username: '', password: '',
});
const r = await t.fetch('https://example.com/models', {
  headers: { authorization: 'Bearer …' }, signal: AbortSignal.timeout(20_000),
});
await r.json();          // succeeds — 200
await t.close();         // never resolves

Observed: fetch succeeds, close() still pending after 20s. The same script with type/proxy disabled, or against a bare new ProxyAgent(...) instead of this wrapper, closes in ~1ms.

Evidence narrowing the cause

Bisecting the dispatcher construction, with ac.abort() performed before destroy() in each case:

Dispatcher configuration destroy()
plain ProxyAgent settles, 1ms
+ factory (cancellable connect) settles, 0ms
+ clientFactory overriding onRequestUpgrade — what Maka builds never settles

Probes injected into a running Host isolate it to the exact await:

DISCOVERY:after ok=true n=39     ← model discovery succeeds, 39 models
CLOSE:before                      ← enters transport.close()
CLOSEIMPL:direct-destroyed        ← directDispatcher resolves immediately
                                   ← proxy-destroyed never fires

So the discovery work is fine; only teardown wedges. buildSocks5Dispatcher builds its own Agent and is not affected — this is specific to the HTTP proxy ProxyAgent path.

End-to-end impact

Driving the real CLI onboarding flow (custom connection → base URL → API key), the verify step never returns:

before with a bounded teardown
connection.onboarding.verify over RPC never returns; client gives up at 25s ~1.5s, kind: 'verified', 39 models
Host activeOperations +1 per attempt, never released flat across 5 consecutive runs

The second row matters beyond the single request: because the lane is never released, every later create for the same provider queues behind it, so one wedge poisons the rest of the process's lifetime.

Suggested fix

Teardown must not gate the caller. The sockets are already aborted by (1); awaiting undici's dispatcher teardown afterwards is best-effort and should be bounded, e.g. race the destroy against a short grace period:

const TRANSPORT_CLOSE_GRACE_MS = 1_000;
// …
return Promise.race([
    closePromise,
    new Promise((resolve) => setTimeout(resolve, TRANSPORT_CLOSE_GRACE_MS, undefined)),
]);

Verified locally: close() returns in ~1s, verify completes in ~1.5s with all 39 models, and activeOperations stays flat over repeated runs.

Fixing it at the root instead — having abortSocket notify undici rather than unilaterally destroying the socket — would also be reasonable, and #5043's own follow-up note about releasing cancellation listeners suggests that area is still in flux.

Notes

  • Not a regression in connection.onboarding.verify itself; the RPC is fine, its finally just never completes.
  • Anyone behind a system/network proxy is affected; this is likely why it has gone unnoticed — the untested default is the no-proxy path.
  • Worth a regression test in packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts that configures a proxy, performs one fetch, and asserts close() settles.

Provenance

Generative tooling contributed substantively to this analysis. The investigation (source reading, instrumented probes, bisection, reproduction scripts) was performed with an AI coding assistant, and this report was drafted with its assistance. All version numbers, the close() source, and the bisection results were verified directly against the repository and the published maka-agent@0.2.0-dev.64.20260929 package. The suggested patch is a mitigation derived from that analysis and is offered for discussion, not as a reviewed contribution — I have not built or run the repository's test suite, so please treat the diagnosis as the reportable part rather than a finished change.

Activity

  1. kyrierekkok123-del commented on Oct 1, 2026

    @kyrierekkok123-del
    Author

    Correction: two inaccuracies in the original report

    Reviewing this after posting, I re-verified every falsifiable claim against the
    published packages. Two statements in the original report are wrong. Neither
    changes the diagnosis; one removes evidence I do not actually have, and the other
    misstates a cause.

    1. The TS source is not byte-identical to the shipped dist

    The original report said the packages/runtime/src/network/scoped-fetch-transport.ts
    source and the shipped dist/network/scoped-fetch-transport.js "are identical in
    0.2.0-dev.64.20260929". That is not true. The shipped dist is the compiled form:
    types are stripped and import type lines are elided.

    What I should have claimed — and have now verified — is that the close()
    implementation is the same in both:

    const close = (): Promise<void> => {
      if (closePromise) return closePromise;
      closed = true;
      connections.abort(new Error('Connection effect fetch transport closed'));
      closePromise = Promise.all([
        directDispatcher.destroy(new Error('Connection effect fetch transport closed')).catch(() => {}),
        proxyDispatcher?.destroy(new Error('Connection effect fetch transport closed')).catch(() => {}),
      ]).then(() => undefined);
      return closePromise;
    };

    The close() body I quoted in the original report is accurate; only the
    "identical" framing was wrong.

    2. "never returns; client gives up at 25s" — the 25s was my own harness

    The original report's end-to-end table said the verify RPC "never returns; client
    gives up at 25s". That 25-second figure came from my test script's own race
    timeout
    , not from any timeout in Maka. I checked the shipped CLI to be sure:

    dist/runtime-host-onboarding.js calls the RPC with no timeout argument:

    const result = await connection.request('connection.onboarding.verify', {
        target: input.target,
        apiKey: trimmedOrNull(input.apiKey),
        baseUrl: trimmedOrNull(input.baseUrl),
    });

    (Contrast the OAuth paths in the same file, which do compute a remainingTimeout()
    and pass one to connection.request(...).) So the accurate statement is: the
    request has no client-side deadline, the RPC never returns, and the wizard's
    "Verifying…" state persists indefinitely. My harness supplied the 25s.

    The underlying point stands and is if anything stronger — the absence of any
    timeout on this call path is part of why the failure is a silent hang rather than
    a visible error.

    What I have now verified independently

    Re-checked against the published packages rather than my earlier notes:

    • #5043 attribution holds. I fetched 0.2.0-dev.51.20260925 (pre-#5043)
      and ran the same reproduction against it: close() returns in 1ms. Against
      pristine 0.2.0-dev.64.20260929 (confirmed to contain no local modification)
      the identical script leaves close() still pending after 15s. dev.51 has
      neither connections.abort() in close() nor onRequestUpgrade in
      proxy-dispatcher.js; dev.64 has both. So #5043 did introduce this.
    • Proxy-gated behaviour holds. Same pristine dev.64 code: with
      createProxiedFetchTransport(null) → close() settles in 1ms; with an HTTP
      proxy → never settles. The no-proxy path is genuinely unaffected.
    • The bisect row holds. Against pristine dev.64: factory alone →
      destroy() settles in 1ms; factory + the clientFactory overriding
      onRequestUpgrade (what buildProxyDispatcher builds) → still pending after
      10s.

    The minimal reproduction in the original report does need a reachable HTTP proxy,
    since the failing path is a proxied CONNECT tunnel. That was stated.

    I have not built the repository or run its test suite, as noted in Provenance.

  2. kyrierekkok123-del commented on Oct 1, 2026

    @kyrierekkok123-del
    Author

    Local mitigation available, if useful for validating a fix

    To be explicit up front: how upstream fixes this is entirely the maintainers'
    decision.
    This comment only reports what we have actually measured on our own
    machine, in case it is useful as a validation target or a starting point. We have
    not built this repository or run its test suite, so treat the numbers below as
    observations about the published package, not as a reviewed patch.

    What we applied locally

    Two edits against the installed maka-agent@0.2.0-dev.64.20260929, applied to
    the shipped dist (not to packages/runtime/src). Both are recorded here in full
    so they can be reproduced or dismissed.

    1. Bound dispatcher teardown — @maka/runtime/dist/network/scoped-fetch-transport.js:

    +const TRANSPORT_CLOSE_GRACE_MS = 1_000;
    ...
         const close = () => {
             if (closePromise) return closePromise;
             closed = true;
             connections.abort(new Error('Connection effect fetch transport closed'));
             closePromise = Promise.all([...]).then(() => undefined);
    -        return closePromise;
    +        return Promise.race([
    +            closePromise,
    +            new Promise((resolve) => setTimeout(resolve, TRANSPORT_CLOSE_GRACE_MS, undefined)),
    +        ]);
         };

    2. Give the verify/save RPCs a deadline — dist/runtime-host-onboarding.js:

    +const ONBOARDING_VERIFY_TIMEOUT_MS = 30_000;
    +const ONBOARDING_SAVE_TIMEOUT_MS  = 30_000;
    ...
    -                const result = await connection.request('connection.onboarding.verify', { … });
    +                const result = await connection.request('connection.onboarding.verify', { … }, ONBOARDING_VERIFY_TIMEOUT_MS);

    Edit 2 exists only because edit 1 means close() can no longer hang forever; it is
    defence in depth, so that any future wedge on this path surfaces as the
    wizard's existing unavailable failure (which renders via
    copy.onboardingRequestFailed in dist/pi-tui-pickers.js) instead of an
    indefinite "Verifying…" state.

    Measured effect

    Same reproduction as above, against the published package, proxy reachable:

    pristine dev.64 with edit 1
    close() after one proxied fetch pending after 15s ~1.0s
    connection.onboarding.verify over RPC no return ~1.5s, kind: 'verified', 39 models
    Host activeOperations after the call +1, never released flat across 5 consecutive calls

    The activeOperations row is the one that matters most for us: with the pristine
    build a single wedge wedges that provider's lane for the life of the Host process,
    so every later attempt queues behind it. We also completed the real onboarding
    wizard end-to-end twice against a real endpoint — the flow reaches the model
    picker and persists the connection, where it previously stopped at the verify step.

    One thing we checked before recommending it, since a bounded close() could plausibly leak

    Bounding teardown means close() can return while undici's destroy() is still
    pending, so we measured whether that retains sockets. Three cycles of
    create → one proxied fetch → close(), counting /proc/self/fd:

    • pristine dev.64: 23 fds before, 23 after, flat across all three cycles
    • with edit 1: 23 fds before, 23 after, flat across all three cycles
    • both processes exited on their own in both cases

    So on this path we saw no descriptor retention, but that is a single observation
    on one platform and one proxy type, not a proof. The SOCKS path builds its own
    Agent and was never affected, so we did not test it under edit 1.

    Provenance

    Same as the original report: this investigation and this comment were produced
    with the assistance of an AI coding assistant, disclosed per
    CONTRIBUTING.md. Every
    number above was re-measured against the pristine published package, not against
    our own patched working tree.

  3. kyrierekkok123-del commented on Oct 1, 2026

    @kyrierekkok123-del
    Author

    Re-filed against the Bug report template as #5898, which also corrects the stated impact (the same hang affects WebFetch/WebSearch and model metadata refresh, not just onboarding) and drops two claims that needed qualifying. Closing this one; the discussion continues on #5898.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions