Skip to content

Stop the ARP read blocking the runtime, and the release check refiring - #196

Merged
fstubner merged 1 commit into
mainfrom
fix/mcp-blocking-and-release-poll
Aug 17, 2026
Merged

fstubner merged 1 commit into
mainfrom
fix/mcp-blocking-and-release-poll

Conversation

@fstubner

Copy link
Copy Markdown
Owner

Two defects the independent reviews found. Both degrade the product in normal use, and neither is visible from a passing test suite.

1. Reading the ARP table blocked tokio workers

On Windows and macOS it shells out to arp and waits on the child process.

Ops::get_arp_table was the one sync method on an otherwise async facade, which invited exactly the bug it produced — three separate callers all invoked it straight from an async context:

caller effect
MCP tool dispatcher blocks a runtime worker per call
Tauri command blocks a runtime worker per call
DiscoverEngine blocks a worker on every discover and sweep

The MCP case is the sharp one. Handlers are capped at 16 concurrent and the stdin read loop is itself a task on the same pool, so sixteen get_arp_table calls could park every worker. No further request would be parsed — including a cancellation. That's the same class of defect the concurrency rewrite fixed, at a call site it missed.

Fixed at the source rather than per caller: Ops::get_arp_table is now async and moves the read to spawn_blocking, so every consumer benefits and the next one can't repeat it. DiscoverEngine calls NetworkManager::get_arp_table directly, so that one is wrapped in place.

Breaking change, deliberately. Ops::get_arp_table becoming async breaks netscli-core consumers. It lands in the unreleased 0.3.0, and Ops is the async facade — the sync signature was the anomaly.

2. The desktop app rate-limited itself out of update checks

useReleaseNotifications listed showUpdateToast as an effect dependency. It's a plain function from useWorkspaceToast, so every render gives it a new identity and the effect re-ran on every render: abort the in-flight request, issue another.

During a scan that's roughly one request to api.github.com per progress event — per probed port — against an unauthenticated limit of 60 an hour. Idle, it still refired every 30s, because the interface poll sets state with freshly deserialized values.

The callbacks now live in a ref updated by a no-dependency effect — the same shape as the handler ref in useKeyboardShortcuts — so the check runs once per (appVersion, enabled) change.

Tests

Both were confirmed to fail against the previous behaviour before being kept:

× checks once across many re-renders with new callback identities
  expected 13 to be 1

Verification

cargo fmt, clippy with and without pcap, 147 Rust tests, 100 GUI tests, lint 0 errors, GUI build, file-size guard — all pass.

Not verifiable here: the blocking fix is structural (the call moves to a blocking pool) and is covered by the type system and review, not by a test that measures runtime stall. Reproducing the original wedge needs a live MCP session with 16 concurrent ARP calls on a Windows or macOS host.

Two defects the independent reviews found. Both degrade the product in
normal use and neither is visible from a passing test suite.

1. Reading the ARP table blocked tokio workers.

   On Windows and macOS it shells out to `arp` and waits on the child
   process. `Ops::get_arp_table` was the one sync method on an otherwise
   async facade, which invited exactly the bug it produced: three separate
   callers -- the MCP tool dispatcher, the Tauri command, and
   `DiscoverEngine` -- all invoked it straight from an async context.

   The MCP case was the sharp one. Handlers are capped at 16 concurrent and
   the stdin read loop is itself a task on the same pool, so sixteen
   `get_arp_table` calls could park every worker. No further request would
   be parsed -- including a cancellation. That is the same class of defect
   the concurrency rewrite fixed, at a call site it missed.

   Fixed at the source rather than per caller: `Ops::get_arp_table` is now
   async and moves the read to `spawn_blocking`, so every consumer benefits
   and the next one cannot repeat it. `DiscoverEngine` calls
   `NetworkManager::get_arp_table` directly, so that one is wrapped in
   place -- it was blocking a worker on every discover and sweep, not just
   on the ARP tool.

   This makes `Ops::get_arp_table` async, which is a breaking change for
   netscli-core consumers. It lands in the unreleased 0.3.0, and `Ops` is
   the async facade -- the sync signature was the anomaly.

2. The desktop app rate-limited itself out of update checks.

   `useReleaseNotifications` listed `showUpdateToast` as an effect
   dependency. It is a plain function from `useWorkspaceToast`, so every
   render gives it a new identity and the effect re-ran on every render:
   abort the in-flight request, issue another. During a scan that is
   roughly one request to api.github.com per progress event -- per probed
   port -- against an unauthenticated limit of 60 an hour. Idle, it still
   refired every 30s because the interface poll sets state with freshly
   deserialized values.

   The callbacks now live in a ref updated by a no-dependency effect, the
   same shape as the handler ref in useKeyboardShortcuts, so the check runs
   once per (appVersion, enabled) change.

Both new tests were confirmed to fail against the previous behaviour before
being kept: the release test reports 13 calls where it expects 1.

cargo fmt, clippy with and without pcap, 147 Rust tests, 100 GUI tests,
lint 0 errors, GUI build, file-size guard -- all pass.
@fstubner
fstubner merged commit ed6d121 into main Aug 17, 2026
15 checks passed
@fstubner
fstubner deleted the fix/mcp-blocking-and-release-poll branch August 17, 2026 23:41
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.

1 participant