Skip to content

fix(auth): keep the OAuth listTools request alive across every phase - #241

Open
umutkeltek wants to merge 6 commits into
openclaw:mainfrom
umutkeltek:fix/daemon-listtools-progress-heartbeat
Open

fix(auth): keep the OAuth listTools request alive across every phase#241
umutkeltek wants to merge 6 commits into
openclaw:mainfrom
umutkeltek:fix/daemon-listtools-progress-heartbeat

Conversation

@umutkeltek

@umutkeltek umutkeltek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Resubmission of #237 with the correctness gap fixed and a clean history, now updated for the Codex and ClawSweeper review round.

The gap in #237

resolveOperationSocketBudget sized the listTools socket at 2 * timeoutMs + 5s: one phase for OAuth, one for a single tools/list. But McpRuntime.listTools() walks nextCursor and gives every page its own timeoutMs, so an OAuth wait plus two or more slow pages outlives the socket. The client sees a transport timeout, restarts the daemon, resends the listing, and the user gets the second OAuth flow this PR exists to prevent.

Any constant multiple has the same defect — it encodes a phase count the runtime does not promise.

The fix: observe the daemon instead of predicting it

While a request is in flight the daemon host emits newline-delimited progress frames. The client treats each frame as proof of life and restarts its socket deadline. That turns the socket deadline from an operation budget into a liveness budget:

  • a request survives however many sequential phases it needs — OAuth wait plus N pages — with no arithmetic on either side;
  • a daemon that actually goes silent is still torn down and restarted, exactly as before;
  • the operation deadline stays where it belongs: the daemon enforces it and answers operation_timeout, which the client does not retry.

resolveOperationSocketBudget and its 5s grace are gone. The listTools socket uses the caller deadline verbatim. Both peers decode frames incrementally (DaemonFrameDecoder), so the daemon's own status probe and stop handshake stay readable and a response with no trailing newline still parses. The daemon protocol is versioned, so a daemon started before this change is replaced rather than left speaking the old wire format.

Real behavior proof

A local OAuth-protected MCP server (dynamic client registration, PKCE S256, authorization code, bearer-gated /mcp) that paginates tools/list across 4 pages at 4s each. mcporter auth runs the real interactive browser flow — the browser launch is shimmed so approval lands 4s after the URL opens — routed through the real keep-alive daemon (lifecycle.mode = keep-alive, confirmed daemon pid). MCPORTER_OAUTH_TIMEOUT_MS=6000, so every individual phase is comfortably inside its deadline and only the total exceeds a fixed budget.

Before — 462865b, the head that was closed (2 * 6000 + 5000 = 17s socket budget):

[14:45:41.300] mcp -> 401 (no valid bearer token), advertising protected resource metadata
[14:45:45.352] authorize -> issuing code, redirecting to loopback callback
[14:45:45.362] token -> PKCE verified, access token issued
[14:45:45.388] tools/list page 1/4
[14:45:49.394] tools/list page 2/4
[14:45:53.398] tools/list page 3/4
[14:45:57.402] tools/list page 4/4
[14:45:58.729] tools/list page 1/4      <-- socket expired at ~17s; daemon restarted, listing replayed
[14:46:02.732] tools/list page 2/4
[14:46:06.735] tools/list page 3/4
[14:46:10.737] tools/list page 4/4

CLI exit 0, elapsed 34s
tools/list page 1 requests : 2
tools/list requests total  : 8

After — this branch:

[14:44:51.258] mcp -> 401 (no valid bearer token), advertising protected resource metadata
[14:44:55.771] authorize -> issuing code, redirecting to loopback callback
[14:44:55.777] token -> PKCE verified, access token issued
[14:44:55.795] tools/list page 1/4
[14:44:59.800] tools/list page 2/4
[14:45:03.805] tools/list page 3/4
[14:45:07.810] tools/list page 4/4

CLI exit 0, elapsed 21s
tools/list page 1 requests : 1
tools/list requests total  : 4

One authorization, one completed listing, no replay, no daemon restart. The replay in the "before" run begins 17.3s after the listing started, which is exactly the 2 * timeoutMs + 5s budget — the failure mode described in the #237 review, reproduced and then removed.

(The provider is a local fixture rather than a third-party service so the run is inspectable and repeatable; it exercises the real SDK OAuth client path — discovery, registration, PKCE, code exchange, bearer-gated MCP — not a stub.)

Review round: what changed

[P1] Liveness frames could outlive a wedged request. The premise as stated — that listResources/readResource have no operation timeout — does not hold: every SDK request carries DEFAULT_REQUEST_TIMEOUT_MSEC (60s) and every OAuth wait carries DEFAULT_OAUTH_CODE_TIMEOUT_MS (300s). But the underlying worry was real and I found a stronger case than the one reported: McpRuntime.listTools() walks nextCursor with no guard, so a server that repeats a cursor pages forever — and heartbeats would then keep the client waiting forever. Both are now closed:

  • operations with a fixed phase count (at most one connect plus one MCP request) carry an absolute ceiling equal to the sum of the deadlines the daemon already applies to those phases. It is derived, not guessed, and can only fire once every real deadline has been blown; a server that accepts and never answers now yields a non-retryable operation_timeout instead of an indefinite wait.
  • listTools, whose page count is data-dependent, keeps its per-page deadline and gains a repeated-cursor guard in the runtime.

[P2] Short deadlines could expire before the first frame. Fixed, and slightly further than suggested: the first frame is written immediately when the request is dispatched, and the cadence is derived from the caller's own deadline (resolveProgressInterval, sent on the request envelope) rather than fixed at 250ms. An immediate frame alone would still lose a sub-250ms deadline on the second gap.

[P3] Release-owned changelog. Correct — checked against this repo's history: my own merged #234 did not touch CHANGELOG.md; the maintainer added the entry at release with the thanks @… credit line. The entry has been removed from this branch.

[P1, ClawSweeper] An upgraded client could stop a live shared daemon mid-request. The protocol-version check classified any pre-v2 daemon as stale and called stop() on it from ensureDaemon(). A second client upgrading mid-request — OAuth code wait or a delayed cursor page — could race ahead of the first client's in-flight call, kill the daemon, and force the very replay this PR is meant to remove. The replacement is now an idle-drain transition, not a preempt:

  • the host's status response reports the live in-flight count (StatusResult.activeRequests, sampled at response time so a long-running request still shows as in flight). The field is optional, so v1 daemons that cannot advertise it still answer old clients.
  • restartDaemon() polls the live daemon up to MCPORTER_DAEMON_DRAIN_TIMEOUT_MS (60s default, env-var overridable) for the counter to read zero before issuing stop. Pre-v2 daemons are treated as permanently busy, so the drain timeout is also their safety bound.
  • a busy daemon that another client replaces mid-drain is left alone — waitForDaemonIdle returns both drained and replacedByPeer, and the upgrading client bails out without stop.

If the daemon is still busy past the timeout the replacement is abandoned and the caller fails loud rather than killing the peer.

Regression coverage

tests/daemon-listtools-progress.test.ts drives the real client transport against the real host framing over a socket:

  • survives an OAuth wait followed by several paginated tools/list pages — an OAuth wait plus three delayed pages, every phase longer than the socket deadline. Asserts the tools resolve, listTools ran exactly once (no replay), one listTools request reached the daemon, and launchDaemonDetached was never called (no restart). Verified it fails without the mechanism: stubbing out the progress emitter makes it die with Daemon did not stop before restart could begin.
  • reaches a deadline shorter than the default progress interval — the P2 case.
  • emits the first progress frame before any cadence interval can elapse — pins the P2 fix at a 30ms deadline, well under the 250ms default cadence.
  • still trips the socket deadline when the daemon stops sending progress frames — silence is still fatal.
  • walks every cursor page but refuses to page forever on a repeated cursor — the P1 case.

tests/daemon-client-timeout.test.ts pins the budget itself: the listTools socket deadline equals the caller deadline, so a phase-count multiplier cannot come back unnoticed.

tests/daemon-host.test.ts pins the operation ceiling for the fixed-phase methods against a wedged runtime: callTool, listResources, and readResource all return operation_timeout after the sum of MCPORTER_OAUTH_TIMEOUT_MS and DEFAULT_REQUEST_TIMEOUT_MSEC, using fake timers so the test does not wait the production minute-plus.

tests/daemon-client-config-stale.test.ts pins the ClawSweeper P1 fix:

  • waits for a busy shared daemon to drain before sending stop — the upgrading client observes activeRequests flip to zero before issuing stop, proving no in-flight OAuth or cursor page was killed.
  • does not stop a peer fresh daemon if it replaces the busy one mid-drain — a second client that wins the race to replace the busy daemon during the drain window has its fresh daemon left alone; the upgrading client does not call stop and does not relaunch.
  • refuses to replace a daemon that never drains before the timeoutMCPORTER_DAEMON_DRAIN_TIMEOUT_MS=500 exercises the refusal branch without waiting the production minute, asserting the busy peer's daemon is left running.

Housekeeping

  • Clean commit history, no AI co-author trailers, single author.
  • Rebased onto current main.
  • pnpm check and pnpm test green locally (860 passed, 3 skipped); CI green on Ubuntu, macOS and Windows.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab62f071c3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/daemon/host.ts Outdated
preParsedRequest
);
socket.write(JSON.stringify(response), () => {
const stopProgress = startProgressFrames(socket, preParsedRequest?.id ?? 'unknown');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Limit heartbeat frames to operations with their own deadline

When a keep-alive server accepts resources/list or resources/read but never responds, this unconditional heartbeat continues while the daemon event loop remains healthy, and the client resets its 30-second socket deadline on every frame. Those runtime methods have no operation timeout, so the CLI now waits forever instead of timing out, restarting the daemon, and retrying as it did before this commit. Restrict these heartbeats to the bounded listTools path or retain an absolute recovery deadline for the other methods.

Useful? React with 👍 / 👎.

Comment thread src/daemon/protocol.ts
// deadline, so a request stays alive for as many phases as it needs -- an OAuth
// code wait plus any number of paginated `tools/list` pages -- without the
// client having to predict how many phases there will be.
export const DAEMON_PROGRESS_INTERVAL_MS = 250;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Emit a heartbeat before short idle deadlines

When an API caller supplies ListToolsOptions.timeoutMs <= 250 (also accepted by --oauth-timeout), the client arms that same socket timeout immediately, but the host waits 250 ms before its first progress frame. Because the daemon starts the operation timeout only after receiving and dispatching the request, the socket deadline can fire first, causing invoke() to classify this as a transport failure and restart/replay the request instead of returning the non-retryable operation_timeout. Send an initial frame immediately or coordinate the interval with the requested timeout.

Useful? React with 👍 / 👎.

@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from ab62f07 to 9a296ab Compare July 27, 2026 13:57
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P1 Urgent regression or broken agent/channel workflow affecting real users now. merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. labels Jul 27, 2026
@clawsweeper

clawsweeper Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs changes before merge. Reviewed August 1, 2026, 1:08 AM ET / 05:08 UTC.

ClawSweeper review

What this changes

The branch forwards OAuth timeouts through tool discovery and changes the keep-alive daemon protocol so multi-phase OAuth tool listings stay alive without replaying a request.

Merge readiness

Blocked by patch quality or review findings - 6 items remain

This PR remains necessary because current main does not contain its OAuth timeout or daemon-liveness work, but the latest head still has the previously reported P1: an idle v2 daemon counts its own status probe as active, so stale-config replacement waits for the drain deadline and fails instead of replacing it. Likely related people: Peter Steinberger (medium confidence, current-main daemon baseline) and Umut Keltek (high confidence, recent adjacent transport/daemon contributor).

Priority: P1
Reviewed head: fb1901d325fa7ae4c84438b1b791e2b8d49b3846

Review scores

Measure Result What it means
Overall readiness 🦪 silver shellfish (2/6) The central OAuth replay proof is strong, but the outstanding P1 breaks idle shared-daemon replacement on the current head.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (logs): The PR body provides an after-fix local OAuth/PKCE, keep-alive daemon, and four-page listing run showing a single authorization and no replay; it directly demonstrates the central user-visible fix.
Patch quality 🦪 silver shellfish (2/6) 1 actionable review finding remain.

Verification

Check Result Evidence
Real behavior Verified Sufficient (logs): The PR body provides an after-fix local OAuth/PKCE, keep-alive daemon, and four-page listing run showing a single authorization and no replay; it directly demonstrates the central user-visible fix.
Evidence reviewed 6 items The current head counts the status request itself: runDaemonHost increments activeDaemonRequests before every parsed request, including status; the status handler then reads that counter while its own request remains in flight, so an otherwise idle daemon reports at least one active request.
Drain loop cannot reach its zero condition: For stale-config replacement, restartDaemon() calls waitForDaemonIdle(), which only returns drained when activeRequests <= 0; repeated status probes therefore keep observing their own count and eventually throw after the drain timeout.
Regression coverage does not exercise the host-side counter: The drain tests emulate daemon status responses in a mock socket, while the host helper defaults readActiveDaemonRequests to zero; neither path executes the production listener’s unconditional increment around a real status request.
Findings 1 actionable finding [P1] Exclude status probes from active request count
Security None None.

How this fits together

MCPorter’s auth command discovers tools through a reusable local daemon, which performs OAuth and MCP requests on the CLI’s behalf. The daemon’s request framing, liveness signals, and replacement logic determine whether slow paginated discovery succeeds without restarting a shared process.

flowchart LR
  CLI[Auth command] --> Client[Daemon client]
  Client --> Host[Keep-alive daemon]
  Host --> Runtime[MCP runtime]
  Runtime --> OAuth[OAuth authorization]
  OAuth --> Listing[Paginated tool listing]
  Host --> Frames[Progress and result frames]
  Frames --> Client
  Client --> Output[CLI result]
Loading

Before merge

  • Exclude status probes from active request count (P1) - runDaemonHost increments activeDaemonRequests before dispatching every request. The status handler then returns that counter before its own finally decrement runs, so even an idle v2 daemon always reports activeRequests: 1 to waitForDaemonIdle(). A stale-config replacement therefore times out and throws instead of replacing the daemon. Count only application work or snapshot the counter before accepting the status request, and cover the real host path.
  • Resolve merge risk (P1) - Until the P1 is fixed, any stale-config replacement of an idle v2 shared daemon can wait up to MCPORTER_DAEMON_DRAIN_TIMEOUT_MS and fail instead of recovering.
  • Resolve merge risk (P1) - The daemon protocol and shared-process replacement behavior remain compatibility-sensitive across running client/daemon versions.
  • Resolve merge risk (P1) - Bun is unavailable in this read-only environment, so no local test execution was possible despite the PR’s supplied successful CI evidence.
  • Improve patch quality - Exclude status probes from active-request accounting.
  • Improve patch quality - Add a real-host socket test that demonstrates idle replacement and preserves the existing busy-drain safety case.

Findings

  • [P1] Exclude status probes from active request count — src/daemon/host.ts:171
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Patch scope 17 files affected; 1,568 additions and 107 deletions The fix changes CLI timeout propagation, runtime behavior, daemon framing, process replacement, and several test suites.
Production regression path 1 unconditional request counter One counter increment before dispatch causes every live status probe to report itself as in flight.

Root-cause cluster

Relationship: canonical
Canonical: #241
Summary: This PR is the active canonical attempt for OAuth tool-list timeout handling and supersedes the earlier closed timeout-only attempt.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge-risk options

Maintainer options:

  1. Correct active-request accounting (recommended)
    Count only application operations, or snapshot the count before accepting a status probe, and prove both idle replacement and busy-daemon preservation through the real host socket path.
  2. Pause the lifecycle expansion
    If the replacement contract cannot be proven without broader protocol redesign, defer the drain/replacement portion rather than merging a path that makes stale daemon recovery fail.
Copy recommended automerge instruction
@clawsweeper automerge

Special instructions:
Adjust daemon active-request accounting so status probes do not keep an idle daemon busy; add a real-host socket regression for stale-config replacement while retaining the busy-drain cases.

Technical review

Best possible solution:

Exclude control-plane probes such as status from the daemon’s active application-request count, then add a real-host regression that proves an idle v2 daemon can be replaced while a genuinely active request still drains safely.

Do we have a high-confidence way to reproduce the issue?

Yes. On the PR head, start an idle v2 daemon with stale configuration metadata and invoke a request: the host increments the counter before dispatching the status probe, reports activeRequests: 1, and the client never observes the required zero before its drain timeout.

Is this the best way to solve the issue?

No. The progress-frame and timeout-forwarding direction is appropriate, but the current active-request accounting makes the replacement safety mechanism unusable for an idle v2 daemon; excluding status probes is the narrower correction.

Full review comments:

  • [P1] Exclude status probes from active request count — src/daemon/host.ts:171
    runDaemonHost increments activeDaemonRequests before dispatching every request. The status handler then returns that counter before its own finally decrement runs, so even an idle v2 daemon always reports activeRequests: 1 to waitForDaemonIdle(). A stale-config replacement therefore times out and throws instead of replacing the daemon. Count only application work or snapshot the counter before accepting the status request, and cover the real host path.
    Confidence: 0.99

Overall correctness: patch is incorrect
Overall confidence: 0.99

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 7259c8c2c9b0.

Labels

Label justifications:

  • P1: A normal stale-config recovery path can fail for users of shared keep-alive daemons rather than replacing an idle daemon.
  • merge-risk: 🚨 compatibility: The PR versions the daemon protocol and changes behavior when clients encounter pre-existing shared daemon processes.
  • merge-risk: 🚨 availability: Incorrect liveness and drain accounting can make CLI operations wait for a timeout or fail to recover a stale daemon.
  • rating: 🦪 silver shellfish: Overall readiness is 🦪 silver shellfish; proof is 🦞 diamond lobster and patch quality is 🦪 silver shellfish.
  • status: ⏳ waiting on author: ClawSweeper has contributor-facing work open and is waiting for author action. Sufficient (logs): The PR body provides an after-fix local OAuth/PKCE, keep-alive daemon, and four-page listing run showing a single authorization and no replay; it directly demonstrates the central user-visible fix.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body provides an after-fix local OAuth/PKCE, keep-alive daemon, and four-page listing run showing a single authorization and no replay; it directly demonstrates the central user-visible fix.

Evidence

Acceptance criteria:

  • [P1] pnpm exec vitest run tests/daemon-client-config-stale.test.ts tests/daemon-host.test.ts tests/daemon-listtools-progress.test.ts.
  • [P1] pnpm check.
  • [P1] pnpm test.

What I checked:

  • The current head counts the status request itself: runDaemonHost increments activeDaemonRequests before every parsed request, including status; the status handler then reads that counter while its own request remains in flight, so an otherwise idle daemon reports at least one active request. (src/daemon/host.ts:171, fb1901d325fa)
  • Drain loop cannot reach its zero condition: For stale-config replacement, restartDaemon() calls waitForDaemonIdle(), which only returns drained when activeRequests <= 0; repeated status probes therefore keep observing their own count and eventually throw after the drain timeout. (src/daemon/client.ts:255, fb1901d325fa)
  • Regression coverage does not exercise the host-side counter: The drain tests emulate daemon status responses in a mock socket, while the host helper defaults readActiveDaemonRequests to zero; neither path executes the production listener’s unconditional increment around a real status request. (tests/daemon-client-config-stale.test.ts:28, fb1901d325fa)
  • Current main does not already implement this branch: The checked-out default branch is 7259c8c2c9b091b4ff6c5ec88b8420c2bd4534cf; comparing the PR base to its head shows 17 affected files, including the daemon protocol, host, client, runtime, and tests. (src/daemon/client.ts:1, 7259c8c2c9b0)
  • Feature-history routing: The current daemon baseline traces to the release commit available in this checkout, while the proposed daemon lifecycle work is carried by the PR’s focused commits; shallow/promisor history prevented deeper local ancestry reads without network access. (src/daemon/client.ts:131, f20febe322b6)
  • Local execution infrastructure was unavailable: The required repository runner could not execute the docs helper because Bun is absent from PATH, so this review did not run tests and relies on source-level reproduction plus the supplied green CI and real-behavior evidence.

Likely related people:

  • Peter Steinberger: The available blame for the current daemon replacement path attributes the baseline to the release commit authored by Peter Steinberger; shallow history limits more specific ancestry attribution. (role: current-main daemon baseline contributor; confidence: medium; commits: f20febe322b6; files: src/daemon/client.ts, src/daemon/host.ts)
  • Umut Keltek: Umut authored the focused timeout/liveness/drain commits in this PR and previously merged related MCP transport work in fix(transport): avoid stall on servers with idle standalone SSE streams #234. (role: recent adjacent transport and daemon contributor; confidence: high; commits: 3c1026d451e8, 0e37f7e383af, 7911b389a8fd; files: src/daemon/client.ts, src/daemon/host.ts, src/daemon/protocol.ts)

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (19 earlier review cycles; latest 8 shown)
  • reviewed 2026-07-31T12:53:50.413Z sha 0e37f7e :: needs changes before merge. :: [P1] Preserve active v1 daemon requests during replacement
  • reviewed 2026-07-31T14:52:08.903Z sha 4e3977c :: needs maintainer review before merge. :: none
  • reviewed 2026-07-31T15:17:57.867Z sha 74b6953 :: needs maintainer review before merge. :: none
  • reviewed 2026-07-31T16:07:36.612Z sha fb1901d :: needs maintainer review before merge. :: none
  • reviewed 2026-07-31T21:16:42.396Z sha fb1901d :: needs changes before merge. :: [P1] Exclude the status probe from active requests
  • reviewed 2026-07-31T22:36:53.817Z sha fb1901d :: needs changes before merge. :: [P1] Exclude the status probe from active requests
  • reviewed 2026-08-01T00:22:52.310Z sha fb1901d :: needs changes before merge. :: [P1] Exclude status probes from active request count
  • reviewed 2026-08-01T01:53:39.364Z sha fb1901d :: needs changes before merge. :: [P1] Exclude status probes from active request count

`mcporter auth` runs the interactive OAuth browser flow inside the SDK's
`tools/list` request, but `listTools` never forwarded a per-request timeout. The
SDK's 60s DEFAULT_REQUEST_TIMEOUT_MSEC killed the request long before the 300s
OAuth code wait could finish, so slow providers could never be authorized --
every `mcporter auth` died with `MCP error -32001: Request timed out`.

Mirror the existing `callTool` timeout forwarding:

- add `timeoutMs` to `ListToolsOptions` and pass `{ timeout,
  resetTimeoutOnProgress, maxTotalTimeout }` to `client.listTools`, with
  `raceWithTimeout` as an outer guard;
- have the `auth` path pass `MCPORTER_OAUTH_TIMEOUT_MS` (default 300s) so the
  request lives at least as long as the OAuth code wait;
- carry `timeoutMs` across the daemon protocol so keep-alive servers get the
  same deadline, and report a phase that blows it as `operation_timeout` so the
  keep-alive wrapper stops treating it as a dead server worth restarting;
- version the daemon protocol so a daemon started before this change is
  replaced instead of silently dropping the new field.
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 9a296ab to 6d72274 Compare July 27, 2026 14:47
@umutkeltek

Copy link
Copy Markdown
Contributor Author

Thanks — both review passes found real things. All three items are addressed and the PR body now carries an inspectable before/after run. Summary:

P1 — liveness frames outliving a wedged request. The stated premise doesn't hold: listResources/readResource do have an operation timeout, because every SDK request carries DEFAULT_REQUEST_TIMEOUT_MSEC (60s, shared/protocol.js) and every OAuth wait carries DEFAULT_OAUTH_CODE_TIMEOUT_MS (300s). But chasing it turned up a stronger case than the one reported: McpRuntime.listTools() walks nextCursor with no guard, so a server that repeats a cursor pages forever — and heartbeats would then keep a caller waiting forever. Both ends are now closed:

  • fixed-phase operations (at most one connect plus one MCP request) carry an absolute ceiling equal to the sum of the deadlines the daemon already applies to those phases — derived rather than guessed, so it can only fire once every real deadline has been blown. A server that accepts and never answers now returns a non-retryable operation_timeout instead of hanging.
  • listTools — the one operation whose phase count is data-dependent — keeps its per-page deadline and gains a repeated-cursor guard.

P2 — short deadlines expiring before the first frame. Fixed, and a step further than suggested: an immediate first frame alone still loses a sub-250ms deadline on the second gap, so the cadence is now derived from the caller's own deadline (sent on the request envelope) and the first frame is written immediately on dispatch.

P3 — release-owned changelog. Correct, and I checked it against this repo's history rather than taking it on trust: my own merged #234 didn't touch CHANGELOG.md — the maintainer added the entry at release with the thanks @… credit. Entry removed.

Real behavior proof is in the PR body: a local OAuth-protected MCP server (dynamic registration, PKCE S256, bearer-gated /mcp) paginating tools/list over 4 pages, driven by a real mcporter auth browser flow through the real keep-alive daemon. On 462865b the listing is replayed — page 1 requested twice, 8 tools/list requests, 34s — with the replay starting 17.3s in, exactly the 2 * timeoutMs + 5s budget. On this branch: one authorization, 4 requests, 21s, no restart, no replay.

Each item has regression coverage; pnpm check and pnpm test are green locally (851 passed) and CI is green on Ubuntu, macOS and Windows.

@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jul 27, 2026
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 6d72274 to 2d6d820 Compare July 27, 2026 15:08
@umutkeltek

Copy link
Copy Markdown
Contributor Author

Fixed the remaining P2 — good catch, the finding is exact.

resolveProgressInterval() clamped the cadence up to a 25ms floor, which meant that for any caller deadline at or below 25ms the interval overshot the very deadline it was meant to refresh: the immediate frame landed, then the socket expired before the next one, and invoke() classified that as a dead transport and replayed. The floor was the one constant left in a mechanism whose whole point is not to use constants, so it is gone rather than lowered — the cadence is now min(250, max(1, floor(deadline / 3))), strictly inside the caller's deadline for every deadline above 1ms.

I did not reject short values at the input boundary instead: timeoutMs: 1 is accepted today and pinned by an existing test (clamps daemon status preflight timeout for tiny per-call timeouts), so tightening the boundary would be a separate behavior change. A 1ms deadline stays unachievable at any cadence — as it was before progress frames existed.

Regression added below the old floor, asserting the invariant directly rather than racing a timer: resolveProgressInterval(n) < n across 2, 3, 5, 12, 24, 25, 74, 75, 300, 30_000, 300_000, plus the 1ms boundary and the default-cadence fallbacks.

pnpm check and pnpm test green (853 passed, 3 skipped).

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Jul 27, 2026
Sizing the daemon socket deadline for a fixed number of phases cannot work.
`McpRuntime.listTools()` issues one `tools/list` request per cursor page and
gives each its own `timeoutMs`, so an OAuth wait followed by two or more slow
pages outlives any constant multiple of the caller deadline. The socket then
expires mid-flight, the client restarts the daemon and resends the listing, and
the user is walked through a second OAuth flow.

Stop predicting the phase count and observe the daemon instead. While a request
is in flight the host emits newline-delimited progress frames, and the client
treats each frame as proof of life and restarts its socket deadline. The deadline
becomes a liveness budget rather than an operation budget: a request survives
however many phases it needs, while a daemon that goes silent is still torn down
and restarted exactly as before.

Liveness alone would be too weak, so every operation stays provably bounded:

- the first frame goes out immediately and the cadence is derived from the
  caller's own deadline, so a deadline shorter than the default interval cannot
  expire before the daemon has proved it is alive;
- operations with a fixed phase count -- at most one connect plus one MCP
  request -- carry an absolute ceiling equal to the sum of the deadlines the
  daemon already applies to those phases, so a server that accepts a request and
  never answers yields a non-retryable `operation_timeout` rather than an
  indefinite wait;
- `listTools` keeps its per-page deadline and gains a repeated-cursor guard, so
  the one operation whose phase count is data-dependent cannot page forever.

Both peers decode frames incrementally, so the daemon's own status probe and stop
handshake stay readable, and a response with no trailing newline still parses.

Regression coverage drives the real client transport and the real host framing
over a socket: an OAuth wait followed by three delayed `tools/list` pages
resolves with one `listTools` request, no replay, and no daemon relaunch. Sibling
cases cover a deadline below the default frame interval, a daemon that goes
silent, and a server that repeats a pagination cursor.
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 2d6d820 to 0e37f7e Compare July 27, 2026 15:17
@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Jul 30, 2026
The previous code's protocol-version check classified any pre-v2 daemon as
stale and called stop() on it from ensureDaemon(). A second client upgrading
mid-request -- OAuth code wait or a delayed cursor page -- would race ahead
of the first client's in-flight call, kill the daemon, and force the very
replay the liveness mechanism was added to prevent.

The fix makes the replacement an idle-drain transition, not a preempt:

* host: the status response now reports the live in-flight count, sampled
  at response time so a long-running request still shows up as in flight.
* protocol: StatusResult.activeRequests is optional so a v1 daemon that
  cannot advertise it does not break old clients.
* client: before restartDaemon() issues stop(), it polls the live daemon
  up to MCPORTER_DAEMON_DRAIN_TIMEOUT_MS (60s default) for activeRequests
  to read zero. Pre-v2 daemons are treated as permanently busy, so the
  drain timeout is the only knob for them too. If the daemon is still
  busy past the timeout, the replacement is abandoned and the caller
  fails loud rather than killing the peer.

Drain timeout is env-var overridable so the test suite can exercise the
refusal branch without waiting the full minute.
Regression coverage for the Codex/ClawSweeper review round:

* daemon-client-config-stale: two new tests pin the drain behavior. The
  first asserts a busy shared daemon is left alone until its in-flight
  count flips to zero, then replaced; the second asserts a daemon that
  never drains is left running rather than killed mid-request.
* daemon-host: three new tests pin the operation ceiling for callTool,
  listResources, and readResource against a wedged runtime, using
  MCPORTER_OAUTH_TIMEOUT_MS=50 and fake timers so the
  OAuth-timeout-plus-60s-DEFAULT_REQUEST_TIMEOUT_MSEC ceiling fires in
  milliseconds rather than the production minute-plus.
* daemon-listtools-progress: one new test exercises a 30ms caller
  deadline -- well under the 250ms default progress cadence -- to prove
  the first progress frame beats the deadline. The existing 120ms test
  already exercised this implicitly; the 30ms case makes the P2 fix
  visible in the test name.
@clawsweeper clawsweeper Bot added status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Jul 31, 2026
The earlier drain landed a contract bug: it only checked whether the
original daemon had gone idle, then immediately fell through to stop().
A second client that won the race to replace the busy daemon during the
drain would see its fresh daemon killed by the upgrading client the
moment the drain returned.

Three changes close the gap:

* client: add `probeLiveStatus` that sends a raw status probe without
  the pid-match filter `readVerifiedStatus` applies. `readVerifiedStatus`
  collapses pid mismatches into null, which would short-circuit the
  drain check and re-introduce the very regression the drain exists to
  prevent.
* client: `waitForDaemonIdle` now returns both `drained` and
  `replacedByPeer`. The pid is tracked across polls so a swap mid-wait
  is visible at the moment the drain resolves.
* client: `restartDaemon` uses `probeLiveStatus` and bails out on
  `replacedByPeer` instead of issuing `stop`.

A third regression test pins the new path: a busy daemon that another
client replaces mid-drain must not receive stop, and the upgrading
client must not relaunch a daemon of its own.
The drain handles the case where a peer swaps in a fresh daemon *while*
the upgrading client is waiting. A subtler race happens *before* the
drain even starts: a peer has already replaced the busy daemon, but the
on-disk metadata still names the old PID, so the previous early-return
check (which also required a fresh config) did not fire and the code
fell through to stop() against the peer's fresh daemon.

Make the pid-mismatch short-circuit in restartDaemon unconditional: if
the live daemon's pid does not match the expected one, do not stop it,
whether or not the metadata still reads stale. The peer owns the
replacement; the upgrading client just uses whatever daemon is live.
This subsumes the pid-mismatch + fresh-config fast path, which is
removed rather than stacked on. The transport-error path is unaffected
because expectedPid is undefined there and the guard is skipped, so the
client still self-heals when no daemon is responding.

A new regression test pins the scenario: a peer wins the swap before
the drain starts, the upgrading client must not call stop, and the
peer's daemon must keep serving.
@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. P1 Urgent regression or broken agent/channel workflow affecting real users now. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant