Skip to content

fix(rpc): bound every request with a wall-clock budget so slots always come back - #1208

Merged
vastsa merged 6 commits into
vastsa:mainfrom
muzimu217:fix/host-rpc-request-budget
Sep 29, 2026
Merged

vastsa merged 6 commits into
vastsa:mainfrom
muzimu217:fix/host-rpc-request-budget

Conversation

@muzimu217

Copy link
Copy Markdown
Contributor

Root cause (issue #1071)

The JS client rejects a host RPC locally after ~130s without ever telling the host, and an in-flight slot is only released when the handler task actually finishes. When one handler wedges the global state lock, every other request queues behind it, times out client-side, and keeps its slot anyway. With MAX_IN_FLIGHT_RPC = 32, enough queued requests burn through all slots in minutes — and every session then fails with HOST_OVERLOADED / "API key auth failed" until the app restarts.

What changed

serve() now wraps each request task in a wall-clock budget (with_request_budget):

Request Budget Rationale
any non-tools.execute method 135s just past the client's ~130s budget, so the slot comes back within seconds of the client giving up
tools.execute tool's own effective_timeout_ms + 90s grace Bash may legitimately run up to 6h — the fixed budget must never clip it; a tool with no effective timeout keeps the old unbounded behavior

A budget expiry answers -32030 HOST_RPC_TIMEOUT, logs warn!(budget_ms), and the slot is released with the task.

Known boundary, stated up front: tokio timeouts only fire at await points, so a handler stuck inside one long synchronous call is not interrupted — that class needs the sync work moved off the async workers (follow-up). What this guarantees is that the dominant failure chain in #1071 — requests queueing on the global lock — is cut loose independently per request: a wedged handler can hold at most its own slot instead of starving all 32.

Validation

  • New deep-dive tests (all passing):
    • concurrent lock waiters (×10) each time out independently with HOST_RPC_TIMEOUT, and the lock is acquirable immediately after the holder releases;
    • a fresh request issued after a wedged holder lets go succeeds on the normal path (no lingering damage);
    • budget matrix: non-tool methods get 135s; tools.execute Bash 60s → 60s+90s grace; Bash 6h ceiling → 6h+90s (never clipped to the fixed budget); a tool without an effective timeout stays unbounded;
  • Full cargo test -p host-core: 663 passed, 0 failed (658 prior + 5 new/extended).
  • cargo fmt --check: clean.

Relationship to #1071

This is the layer-A stopgap from the layered plan proposed in #1071: it removes the "must restart the app" outcome on its own. The deeper items (sync tools onto spawn_blocking, lock discipline for DB access, slow-RPC observability) remain follow-ups and can land independently.

…s come back

A client-side RPC timeout rejects locally without ever telling the host,
so a request stuck waiting on the global state lock kept its in-flight
slot after the caller had moved on. With MAX_IN_FLIGHT_RPC=32 and a
~130s client budget, enough queued requests burn through all slots in
minutes and every session then fails with HOST_OVERLOADED until the app
restarts (issue vastsa#1071).

Give each request a wall-clock budget enforced inside serve():

- non-tool requests: 135s — just past the client budget, so the slot is
  guaranteed to come back within seconds of the client giving up;
- tools.execute: the tool's own effective timeout plus a 90s grace
  window (Bash may legitimately run for hours; a tool with no effective
  timeout keeps the old unbounded behavior);
- a budget expiry answers -32030 HOST_RPC_TIMEOUT and releases the slot.

Known boundary: tokio timeouts only fire at await points, so a handler
stuck inside one long synchronous call is not interrupted — that class
needs the sync work moved off the async workers. What this guarantees is
that the dominant queueing case (waiting on the global lock) is cut
loose independently per request, degrading a full 32-slot outage to at
most one slot held by the wedged handler itself.

Deep-dive tests: concurrent lock waiters time out independently and
leave the lock acquirable; a fresh request succeeds right after the
holder releases; the tools.execute budget matrix covers Bash's 6h
ceiling and unbounded tools.
@vastsa

vastsa commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Review result: do not merge yet.

Issue #1071 is real, but the budget is not applied to the actual tools.execute wire shape. ToolsExecuteParams uses #[serde(rename_all = "camelCase")], and the runtime sends toolName / timeoutMs. However request_budget_ms() reads tool_name / timeout_ms directly from the raw JSON. For a real tools.execute request this produces an empty tool name and no timeout, so effective_timeout_ms("", None) returns None and the tool request remains unbounded. The reported incident specifically includes a stalled Glob request, so the dominant failure path is still open.

The new matrix test passes because its fixtures use the wrong snake_case keys and does not cover the wire shape. The non-tool 135s budget is useful, but this is an incomplete root fix for #1071. Please read the camelCase fields (or deserialize the request before calculating the budget) and add a regression test using toolName / timeoutMs.

The PR is also behind the current origin/main (latest is 78d0e884, after #1200), so the base gate must be refreshed after the correction.

@muzimu217

Copy link
Copy Markdown
Contributor Author

Business-logic validation (real host-core process, added post-open)

Drove the actual patched pi-desktop-host-core binary over stdio NDJSON (the e2e-smoke transport shape), covering the business promises rather than just unit-level assertions:

Check Result
handshake (protocol 11) + app.health + session.create PASS — normal RPC unaffected by the budget
non-tool request (providers.list) PASS — 0ms, rides the 135s budget without impact
shell negotiation (commandShells.list → effective) PASS — bash / posix
CORE: legitimate 137s Bash task (sleep 137 && echo LONGDONE, tool timeout 142s) PASS — completed in 137.2s with exitCode 0 and stdout: LONGDONE

The core case is the business guarantee itself: a 137s task runs past the 135s mark where a uniform budget would have killed it with HOST_RPC_TIMEOUT — tools.execute rides its own 142s tool timeout + 90s grace (budget 232s) and returns the tool-level result. A uniform budget would have turned this legitimate long task into an RPC-level failure.

Desktop smoke with PI_DESKTOP_HOST_BIN pointed at the patched binary: app boots, plugins load (plugin.load.success), main surface renders normally (screenshot verified) — the budget on the RPC main path does not regress normal session flow.

Script: drives handshake → workspace.set → session.create → providers.list → commandShells.list → tools.execute (Bash with shell negotiation + auto permissions.resolve allow-once), ~4 minutes end to end. Happy to commit it under scripts/ if a maintainer wants it in-repo.

…budgets

ToolsExecuteParams is serde-renamed to camelCase, so the real wire
carries toolName / timeoutMs. request_budget_ms read the snake_case
spellings and saw an empty tool name with no timeout on every real
tools.execute request, leaving them unbounded — the stalled-Glob path
from vastsa#1071 stayed open. Read toolName / timeoutMs first with the
snake_case spelling as a fallback, and pin the wire shape with a
dedicated regression test (review on vastsa#1208).
@muzimu217

Copy link
Copy Markdown
Contributor Author

Corrected — thank you for catching this. The review is exactly right, and it also exposed why my own validation missed it twice:

  1. The bug: request_budget_ms() read tool_name / timeout_ms, so every real tools.execute request (camelCase on the wire) resolved to effective_timeout_ms("", None) → None → unbounded. The stalled-Glob path from [Bug] host-core 卡死后 RPC 槽位不释放,32 个槽位被占满,所有会话报「API key auth failed … host RPC capacity is exhausted」,重启前一直无法恢复 #1071 was indeed still open.
  2. Why my matrix test passed: its fixtures used the same wrong snake_case keys — the test validated the function against itself rather than against the wire. Fixed: the matrix and the Bash-ceiling cases now use toolName / timeoutMs, plus a dedicated regression (request_budget_reads_the_camel_case_wire_shape_regression_1208) asserting a 6h Bash resolves to 6h+90s and the snake_case fallback still resolves.
  3. Why my business check passed: the 137s Bash survived because the budget was never applied (unbounded), not because the grace logic worked. Re-ran it on the corrected binary with the real wire payload (toolName: "Bash", timeoutMs: 142000 → budget 232s): the 137s task completes in 137.3s under a genuinely applied budget — same observable outcome, now for the right reason.

Also refreshed the base onto current main (1f8a0df). Full cargo test -p host-core: 680 passed / 0 failed; cargo fmt --check clean. The business validation table in the thread stands, with the corrected interpretation above.

The tools.execute handler may wait on the user's answer to an ask
prompt before the tool runs, and that wait has no timeout of its own.
Without headroom, a slow approval plus a full-length default Bash
(~120s wait + 60s run) exceeded the tool-timeout-plus-grace budget
(150s) and the request would die mid-execution with HOST_RPC_TIMEOUT.

Add a 120s permission-wait cap to the tools.execute budget (matching
the unattended ask budget order), so the budget is permission cap +
tool timeout + grace. Verified against the review matrix and the
business check on the real binary; long-tool semantics unchanged.
@muzimu217

Copy link
Copy Markdown
Contributor Author

One more hardening pass on my own diff while awaiting re-review — a scenario the current budget would have gotten wrong:

Slow approval + full-length tool. The tools.execute handler waits on the user's answer to an ask prompt before the tool runs, and that wait has no internal timeout (tokio::select! on the decision only). With the budget at tool-timeout + 90s grace, a user who takes ~2 minutes to approve plus a default 60s Bash (~180s total) would exceed the 150s budget and be killed mid-execution — a legitimate interactive flow.

Head 5550260b0 adds a 120s permission-prompt cap to the tools.execute budget (matching the unattended ask budget order): budget = permission cap + tool timeout + grace. Examples now pinned in the matrix: Bash 60s → 270s; Bash 6h → 6h + 210s (never clipped). Full suite 680/0, fmt clean, and the real-binary business check re-run passes (137s task completes under a genuinely applied budget).

@vastsa
vastsa merged commit 50d9045 into vastsa:main Sep 29, 2026
3 checks passed
GalaxyXieyu added a commit to GalaxyXieyu/PI-Desktop that referenced this pull request Oct 6, 2026
execute_tool_with_path_access called the synchronous Read, Glob, Grep,
Write, and Edit bodies inline on the async Tokio worker that handled the
request. The host runs the default multi-thread runtime (one worker per
core), the ignore walker never yields, and the rg fast path blocks until
its child exits. A few large Globs therefore occupied every worker, and
cheap RPCs such as session.list waited seconds behind them; the
tools.execute budget from vastsa#1208 cannot help because a timer only fires
at an await point.

Run those five bodies through tokio::task::spawn_blocking with owned
inputs. HashlineStore is an Arc<Mutex<_>> handle, so moving a clone onto
the blocking thread is cheap. Bash keeps its existing async path. Tool
results, error codes, and the wire contract are unchanged; the existing
read and mutation class limits also bound the blocking threads. A
panicking body now returns INTERNAL instead of dropping the response.

The test-only rg override is thread-local, so it is carried onto the
blocking thread for the duration of the body.

Refs vastsa#1071
vastsa pushed a commit that referenced this pull request Oct 6, 2026
execute_tool_with_path_access called the synchronous Read, Glob, Grep,
Write, and Edit bodies inline on the async Tokio worker that handled the
request. The host runs the default multi-thread runtime (one worker per
core), the ignore walker never yields, and the rg fast path blocks until
its child exits. A few large Globs therefore occupied every worker, and
cheap RPCs such as session.list waited seconds behind them; the
tools.execute budget from #1208 cannot help because a timer only fires
at an await point.

Run those five bodies through tokio::task::spawn_blocking with owned
inputs. HashlineStore is an Arc<Mutex<_>> handle, so moving a clone onto
the blocking thread is cheap. Bash keeps its existing async path. Tool
results, error codes, and the wire contract are unchanged; the existing
read and mutation class limits also bound the blocking threads. A
panicking body now returns INTERNAL instead of dropping the response.

The test-only rg override is thread-local, so it is carried onto the
blocking thread for the duration of the body.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants