Skip to content

core: in-flight counters leak permanently when a throw crosses the reserve/handoff window (RemoteServer and Bridge) #502

Description

@Yaraslaut

Summary

Two counters that gate documented behaviour are incremented before a non-noexcept region and decremented only by a handler that region may never reach. A single throw leaves each permanently over-counted for the process's life.

Verification status

Structurally verified by reading; reachability not reproduced. I did not construct a throwing serializeAction/encode. Revision: origin/master adfe8e5f plus this branch's doc-only commits.


1. RemoteServer::_inFlightExecutes (include/morph/core/remote.hpp)

The reservation at :1406-1422 carries this comment:

"Reserving here, at the last point before the slot is genuinely taken, needs no unwind on the early-return paths above."

True of returns. But several non-noexcept, allocating, lock-taking calls follow before any handler can decrement it, with no RAII guard:

  • :1423 emitMetric(...)
  • :1433-1434 two std::make_shared
  • :1467 _timeoutScheduler->schedule(...)
  • :1491 ticketGuard.awaitTurn() (takes a mutex)
  • :1492 _strand.post(mid, …)

A throw from any of these unwinds to dispatchMessage's catch at :1264-1274, which replies but never decrements.

Consequences:

  • drainedWithin() (:585-589, predicate == 0) can never return true again — graceful shutdown is permanently broken for that server.
  • health().inFlight is permanently wrong.
  • With maxInFlightExecutes set, a slot is lost for good.
  • If the throw lands after :1467 succeeded, the scheduled timeout later fires complete(...) and sends a second err "timeout" for the same callId — the catch at :1274 bypasses the finished atomic_flag (:1435-1451), breaking handle()'s documented reply-exactly-once contract (:257-258).

The catch's own comment (:1265-1269) reinforces the wrong model by enumerating only pre-reservation throw sites (authorize/authenticate/authorizeInstance/missingRequiredFields), reading as if the throwing region ended before the increment.

2. Bridge::_pendingCalls (include/morph/core/bridge.hpp)

_pendingCalls->fetch_add (:1420) and the deadline arming happen before auto anyCompletion = backend->execute(...) (:1544), which is not wrapped in a try. QtWebSocketBackend::execute (src/qt/qt_websocket_backend.cpp:423-449) calls call.serializeAction() (:440, user toJson/glaze code) and wire::encode(env) (:448) with no try — both can throw.

A throw escapes executeVia/BridgeHandler::execute, permanently inflates pendingCalls() — breaking the documented "gate a feature on quiescence" use at :1194-1207 — and leaves the scheduled timeout entry armed.

The doc's "The synchronous 'handler not bound' early return — the one case executeVia() resolves before ever dispatching" (:1201) is also not exhaustive.

Fix shape

Both are the same one-liner: a scope_exit-style guard on the reservation, released on the path that hands ownership to the completion/complete(...).

What would change the verdict

  • Close it if every call in both windows is shown non-throwing in practice — but serializeAction runs user-supplied toJson, and wire::encode runs glaze, so that argument has to cover both.
  • Raise it with a test whose action toJson throws, asserting pendingCalls() == 0 and drainedWithin(...) afterwards. Neither has such a test today, which is why both windows are open.

Activity

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

    area: coreSubsystem: corebugSomething isn't workingtriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions