Skip to content

Collapse the one-shot completion kinds into one generic case; await sends on the connection's IValueTaskSource #229

Description

@MDA2AV

The observation

The completion dispatch has nine kinds, and both loops carry the whole switch:

  • Reactor.Loop.SharedRing.cs and Reactor.Loop.Incremental.cs each case over KindTcpAccept, KindTcpRecv, KindTcpSend, KindWake, KindClient, KindCancel, KindTimer, KindUdpRecv, KindUdpSend.
  • Only three of those are streams the loop has to understand: KindTcpAccept and KindTcpRecv, KindUdpRecv (multishot, one submission delivering many completions into connection-owned state). Every other kind is a one-shot op - one SQE, one CQE, exactly one party waiting for the result - and each one-shot kind carries its own copy of the same logic: find the target, hand over res/flags, continue.
  • KindTcpSend in particular puts the partial-send retry inside the completion handler (OnSendCompletion: advance WriteHead, resubmit the remainder, vectored or plain), which is why the send needs per-connection state (WriteHead, WriteInFlight, FlushVectored, ZcNotifPending) and its own kind at all.

The proposal

Collapse the one-shot kinds into one generic completion kind whose target is the thing waiting, and keep the dispatch switch to the streams plus that one case:

case KindTcpAccept: ...          // multishot: a stream into the listener
case KindTcpRecv:   ...          // multishot: a stream into the connection
case KindUdpRecv:   ...
case KindOp:                     // every one-shot op: send, udp send, client op, timer, cancel ack
    IRingCompletion target = _opTargets[slot]; free slot; target.Complete(res, flags);

This is what KindClient already is (Emit + OnClientCompletion + IRingCompletion.Complete). The difference is who else goes through it. libioxd does exactly this with its TAG_OP: one case in the loop, and the op-specific logic - including the short-send loop - lives in the code that submitted and is waiting.

The send, with IValueTaskSource instead of a coroutine

libioxd's waiter is a parked coroutine with sent/len as locals. ioxide has no coroutine, but it already has the equivalent: RingOpSource : IRingCompletion, IValueTaskSource<int>, with Complete(result) firing a ManualResetValueTaskSourceCore. So a server send becomes an awaitable one-shot op and the retry becomes a loop in the awaiting method:

// per connection, pooled and Reset() per use - no allocation on the hot path
async ValueTask FlushAsync()
{
    int sent = 0;
    while (sent < WriteInFlight)
    {
        int n = await Reactor.SendAsync(this, WriteBuffer + sent, WriteInFlight - sent);   // one SQE, KindOp
        if (n <= 0) { Close(); return; }
        sent += n;                                                                          // short send: go again
    }
    CompleteFlush();
}

SendAsync allocates an op slot, fills the SQE, and returns the connection's RingOpSource as a ValueTask<int>; the loop's KindOp case calls Complete(res), and the continuation runs inline on the loop thread, the same way OnClientCompletion already runs target.Complete inline. The state that lived on TcpConnection for the retry (WriteHead, WriteInFlight's progress, the iovec trimming) moves into the async method's frame, which the state machine keeps for free. The gen check against a stale fd goes away too: the slot is freed on completion, so a stale CQE has nothing to hit.

Two things to keep in mind:

  • Zero-copy sends complete twice (F_MORE data CQE, then F_NOTIF). Either they keep a kind of their own, or IRingCompletion.Complete takes the flags and the target counts the notification itself (the ZcNotifPending bookkeeping moves into the target). The second keeps the switch to one case.
  • Ordering: a connection must not start the next flush while a one-shot send is in flight on it; the awaiting method already serialises that, since the next flush cannot start before FlushAsync returns.

What this buys

  • One dispatch case for every one-shot op, in both loops, instead of five.
  • Send logic next to the send (the retry, the vectored path, later bundled or zero-copy sends) rather than in the completion handler.
  • Less per-connection state, and no gen-guarded lookup on the send path.

The cost is one ValueTask state machine step per flush; RingOpSource already pays it for client ops, and it is a resumed continuation, not a thread switch.

🤖 Generated with Claude Code

https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions