Skip to content

feat: make shutdown() immediate rather than queued - #198

Open
mdmzfzl wants to merge 3 commits into
stepfunc:mainfrom
mdmzfzl:feat/immediate-shutdown
Open

feat: make shutdown() immediate rather than queued#198
mdmzfzl wants to merge 3 commits into
stepfunc:mainfrom
mdmzfzl:feat/immediate-shutdown

Conversation

@mdmzfzl

@mdmzfzl mdmzfzl commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem

#194 added shutdown() as a command on the same queue as requests, so it takes
effect only once the task has drained everything ahead of it.

Concretely: a channel parked on an in-flight request waits out the response
timeout. A channel in a retry backoff waits out the delay. A server session
part-way through a write keeps writing.

Shutdown latency is therefore a function of queue depth and the response
timeout, neither of which the caller controls at the point of the call. For a
caller shutting down to release the port, or to exit the process, that is the
wrong dependency.

Change

shutdown() becomes a cancellation signal rather than a queued command:

// before
pub async fn shutdown(&self) -> Result<(), Shutdown>
// after
pub fn shutdown(&self)

On Channel and ServerHandle both. It no longer reaches the task through the
queue, so it no longer reports whether the task was alive, and no longer has to
be awaited.

Each handle shares a TaskCancellation with its task — common/cancellation.rs,
a wrapper over tokio_util::sync::CancellationToken whose run_until_cancelled
is biased toward cancellation, so a cancelled task is never given another poll of
its work.

Command::Shutdown and ServerCommand::Shutdown are gone; the queue carries
requests and settings again.

ServerCommand and ServerHandle::new are no longer public. Both were already
unreachable outside the crate — server::task has been pub(crate) mod since
1.5.0 — so this drops dead surface rather than breaking callers. Both
shutdown() methods came from #194, which merged after 1.6.0-M2 shipped, so no
published signature changes.

Semantics

Client shutdown proceeds in three steps, in this order:

  • The work in flight is dropped, which drops the socket and fails the in-flight
    request via Promise::drop. A write already on the wire may still be applied by
    the server, so its outcome is indeterminate.
  • The queue is closed and drained: queued requests fail with
    RequestError::Shutdown, callers blocked on a full queue wake with an error, and
    anything submitted afterwards fails immediately rather than queueing behind a
    task that is going away.
  • The terminal state is reported to the listener.

Draining before notifying is the part that matters: a caller awaiting a request
should not be held up by however long a user's listener callback takes.

On the server, the listening socket closes and each session is cancelled at its
next suspension point. Sessions are spawned, so they observe the token directly —
the server task returning only drops their command sender, which a session
part-way through a write would not notice.

Calling shutdown() twice, or after the task has terminated, does nothing.

TCP, TLS and serial clients share ClientLoop and the same run() shape, so all
three are covered.

Follow-up

The terminal notification sits outside the cancellation scope deliberately, so
that a cancelled task still reports where it ended up. The cost is an unbounded
await on user code: Listener::update returns a MaybeAsync, and an
implementation that never completes parks the task there for good, with
ClientTask::run() never returning and runtime shutdown waiting on it.
Pre-existing, and unreachable through the bindings, where every listener returns
MaybeAsync::ready. Bounding it with a timeout and documenting the contract on
Listener, which currently says nothing about being required to complete, is the
intended fix. Can land here or separately.

Shutdown was delivered as a `Command::Shutdown` / `ServerCommand::Shutdown`
on the same bounded mpsc that carries requests, so it was processed strictly
FIFO behind whatever was already queued. Each queued request runs to
completion, so the delay before the task ended was the *sum* of the pending
response timeouts, not one of them. Worse, `shutdown()` was an
`async fn` that awaited `send()` on a bounded channel, so when the queue was
full -- exactly when a caller most wants out -- the shutdown request itself
blocked waiting for a slot.

Signal it out of band with a `CancellationToken` instead, selected against the
task's inner loop. The losing future is dropped, which unwinds the task at
whichever await point it is parked on: a socket write, a read awaiting a
response, or a retry backoff sleep. `Promise::drop` already fails requests with
`RequestError::Shutdown`, so both the abandoned transaction and everything left
in the queue report the right error with no extra plumbing.

Because the signal no longer needs backpressure, and cancelling an already
cancelled or already dead task is a no-op rather than a failure, both methods
become `pub fn shutdown(&self)` -- callable from a `Drop` impl, a signal
handler, or any non-async context.

Notes on the design:

* The selects are `biased` with cancellation first. The inner loop is an
  infinite future, so an unbiased select would let it run whatever work was
  already ready before noticing the shutdown, roughly half the time.

* The client's select sits inside `run()` around `run_inner()` rather than
  wrapping `run()`, so a cancelled task still reports its terminal
  `ClientState::Shutdown` / `PortState::Shutdown` to the listener.

* TCP sessions are spawned and never joined, so each receives its own clone of
  the token. The server task returning only drops their command sender, which a
  session parked mid-write would not observe.

* Dropping every handle keeps its previous behaviour of winding down at the
  next queue poll. Only `shutdown()` is immediate, which leaves callers both a
  graceful and an abrupt option.

The trade-off is that a write already on the wire is abandoned, so the server
may still apply it and the caller cannot know. That is inherent to any
immediate shutdown and is documented on both methods.

`ServerHandle::new()` gains a `CancellationToken` parameter. It is public but
documented as existing only for the C bindings, and nothing in the tree calls
it. `Channel::shutdown()` and `ServerHandle::shutdown()` landed after the
1.6.0-M2 publish, so no released signature changes.
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