Skip to content

fix(runtime): bound proxied dispatcher teardown in the remaining call sites - #6003

Merged
Astro-Han merged 2 commits into
apache:mainfrom
ggbdpq:fix/proxy-teardown-order
Oct 10, 2026
Merged

Astro-Han merged 2 commits into
apache:mainfrom
ggbdpq:fix/proxy-teardown-order

Conversation

@ggbdpq

@ggbdpq ggbdpq commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #5978. Two call sites build their own proxy dispatcher and dispose it themselves, so the abort-before-close teardown that the shared transport fix (#5900) reordered survives in them:

  • testProxyConnection (packages/runtime/src/network/proxy-test.ts) aborted its controller and then awaited the dispatcher's graceful close(). Behind a real proxy that close never settles, so the call stays pending long past its 8s probe timeout — the Desktop "Test current configuration" button spins forever and the serialized settings lane it occupies wedges. The fix starts dispatcher teardown before the abort and races it against a 1s grace, mirroring the transport pattern.
  • proxiedFetch error path (packages/runtime/src/bots/proxied-fetch.ts) awaited the same graceful close after aborting, so a stalled close delays the rejection the caller is owed. Same ordering and bound applied.

The success path of proxiedFetch keeps its fire-and-forget close: it owns the streaming body lifecycle (awaiting it was the previous streaming bug), and bounding or re-chaining it is the separate semantic decision the issue itself flags for maintainers. That is why this PR uses Refs instead of Fixes — the dispatcher-lifecycle question on the success path stays open on the issue.

Both regression tests stall ProxyAgent.prototype.close — the same Undici/Node-version-independent technique the transport tests in #5900 use — and assert the calls still settle. Both fail on the previous ordering.

Verification

Check Result
node --test dist/network/__tests__/proxy-test.test.js (before fix, with new tests) fail — new test settles after a successful probe even when the dispatcher close stalls hung to its 5s guard; 3 existing tests passed
node --test dist/bots/__tests__/proxied-fetch.test.js (before fix, with new tests) fail — new test rejects promptly on request failure even when the dispatcher close stalls hung to its 5s guard; 5 existing tests passed
node --test dist/network/__tests__/proxy-test.test.js (after fix) pass 4/4 (stall test settles at ~1s, the grace bound)
node --test dist/bots/__tests__/proxied-fetch.test.js (after fix) pass 6/6
node --test dist/network/__tests__/*.test.js dist/bots/__tests__/*.test.js (adjacent suites) pass 165/165
npx biome check on the 4 changed files clean
npm run check:asf-headers pass (4308 covered, 253 excluded)
Full-repo format:check, full runtime/runtime-host suites not run

AI use

  • Generative tooling made a substantive contribution

Tool(s) and scope: GLM-5.3-Flash (ZCode) — issue triage, root-cause confirmation against #5900's pattern, TDD implementation (tests written first and confirmed failing), and PR drafting; all checks executed locally on macOS arm64 (Node 24).

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above

… sites

Two call sites build their own proxy dispatcher and dispose it themselves,
so the abort-before-close teardown pattern that the shared transport fix
reordered (apache#5900) survives in them:

- testProxyConnection's finally aborts the controller and then awaits the
  dispatcher's graceful close. Behind a real proxy that close never
  settles, so the call stays pending long past its 8s probe timeout and
  wedges the serialized settings lane it occupies on the Desktop.
  Start the dispatcher teardown before the abort and race it against a
  1s grace, mirroring the transport pattern.
- proxiedFetch's error path awaits the same graceful close after
  aborting, so a stalled close delays the rejection the caller is owed.
  Apply the same ordering and bound there. The success path keeps its
  fire-and-forget close, which owns the streaming body lifecycle and is
  intentionally not bounded.

Regression tests stall ProxyAgent.prototype.close (the same
Undici/Node-version-independent technique the transport tests use) and
assert both calls still settle; both tests fail on the previous ordering.

Refs apache#5978

Generated-by: GLM-5.3-Flash (ZCode)
@github-actions github-actions Bot added the effort/M Under 500 readable lines label Oct 8, 2026

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.

Full review of c4730b24. The reordering is correct: close()/destroy() starts synchronously before abort(). teardown never rejects, so the bounded race can't replace the probe result, and the grace timer is cleared on both outcomes. Both regression tests stub ProxyAgent.prototype.close and restore it via t.mock. There's no overlap with #5900 (it touches only scoped-fetch-transport.ts).

One P3 (inline): after the grace expires, the dispatcher is never escalated to destroy().

CI test passes, and the PR is mergeable. I did not run the tests or a real proxy.

Comment thread packages/runtime/src/network/proxy-test.ts Outdated
…he grace expires

Root cause: the bounded dispatcher teardown in testProxyConnection and the
proxiedFetch error path raced the graceful close against a 1s grace, but
when the grace won, the dispatcher was simply abandoned with its close()
still pending, leaking the agent (flagged as a P3 inline on apache#6003).

Fix: track who wins the grace race and, when it expires, fire-and-forget
destroy() with a distinguishing reason. The destroy error is swallowed so
teardown can never replace the result or rejection the caller is already
owed. The grace duration and its external semantics are unchanged; the
success path of proxiedFetch keeps its unbounded close, which owns the
streaming body lifecycle.

Verified: new regressions in proxy-test.test.ts and proxied-fetch.test.ts
stub ProxyAgent#close to never settle and assert destroy is called; both
failed before the fix (callCount 0) and pass after. runtime network+bots
suites 167/167, biome check clean, check:asf-headers clean.

Generated-by: GLM-5.3-Flash (ZCode)

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.

Incremental review: c4730b2 to 83a5cf8. There was no rebase; the merge-base is still f7fa724. The delta is a single new commit, 83a5cf8, which touches the same 4 files (+106/-8) and nothing from main.

Prior findings

  • P3 (after the grace expires, the dispatcher is left in a pending close() and never destroyed): fixed. In both proxy-test.ts (finally) and the proxied-fetch.ts error path, the race now reports whether the grace won. If it did, destroy() is called fire-and-forget with its rejection swallowed. This brings both sites in line with #5900's transport.
  • Nit (the grace race is duplicated in both files): still open, and the duplicated block is now about 20 lines. A shared helper would be worth considering, but this is not blocking.

Verified on the delta

  • teardown cannot reject because disposeDispatcher swallows errors from close/destroy. So teardown.then(() => false) cannot reject either, and the race cannot throw out of finally or replace the rejection the caller is owed. The grace timer is still cleared on both outcomes.
  • In undici 8.x (^8.7.0; checked against 8.11.2), DispatcherBase.destroy(err) called without a callback runs inside a new Promise executor. A synchronous failure therefore becomes a rejection, which .catch(() => {}) swallows. Nothing can escape synchronously from the finally block. destroy() after a pending close() is a legal transition and settles the pending close callbacks.
  • In proxiedFetch, dispatcher can be undefined when the URL is bypassed. In that case disposeDispatcher returns immediately, so teardown always wins the race and the escalation branch cannot run. The cast is harmless.
  • The success path keeps its unbounded graceful close, which owns the streaming body. That is correct and unchanged.
  • New tests: each stubs ProxyAgent#close to never settle and destroy as a no-op, then asserts destroy was called within a 5 s withTimeout. In proxy-test, port 1 is refused, so it takes the close branch rather than the timeout branch. In proxied-fetch, timeoutMs: 0 with a proxy that resets the connection takes the close branch. Both exercise the new code, and the mocks restore through t.mock.

Findings
None at P0–P3 in this delta.

Status
CI test passes on 83a5cf8. The PR is MERGEABLE and BLOCKED only on review. No protocol files are touched, so there is no epoch impact. Not run locally.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at @Astro-Han's explicit request: the automated review of this exact head found no blocking (P0–P2) issues, and CI is green.

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

Labels

effort/M Under 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants