0018. Per-socket delivery delay is an adapter scheduling hook, keyed by sid, preserving FIFO
Status: Accepted · 2026-08-05 · #78 Governed by: 0008, 0009, 0010 Narrowed by: 0031
TL;DR Per-socket delivery delay is a mock-only test affordance. It rides the existing adapter registration API as an optional
scheduleDelivery(sid, deliver)hook, keyed by sid rather than a socket method, and the shippedDelayingAdapterdelays a whole socket's stream through an injectable timer while preserving its send order.
Decision
Race-condition tests need to interleave events across sockets deterministically, which
means holding back what one socket's client receives. The delay is on the client-inbound
stream (server -> client) only; a socket's server side still receives its client's emits on
the next tick, so a delay never couples the two directions and a delivery keyed by a sid is
one stream, not two. This has no socket.io counterpart, so it is a mock-only affordance like
server.nextConnection, recorded in differences.md §B, not a conformance behaviour.
It is delivered through the adapter, not a new seam and not a socket method:
- Registration is the adapter API (0008). The delay lives in a registered adapter, so
io.adapter(() => new DelayingAdapter(timer))is the whole entry point. There is no second registration surface to learn, and the routing adapter and the delaying adapter are the same object when a test wants both. - Keyed by sid. The knob is
adapter.setDelay(sid, ms), notsocket.delay(ms). The adapter already speaks in sids (its whole job issid -> rooms), and a test drives a socket by its id. Keeping it off the socket instance also keeps the socket surface a subset of socket.io's, so theEnsure<>guards are untouched. - The core routes client-inbound event delivery through the hook. A client's event
deliveries funnel through one
send, which asks the client to schedule its own receipt; a socket with no delaying adapter keeps the next-tickdeferunchanged, so the conformance suite is byte-for-byte unaffected. Only when the adapter implements the optionalscheduleDelivery(sid, deliver)does such a delivery take the delayed path. The server-inbound stream, acknowledgement answers, and the connect / disconnect lifecycle are not routed here: they stay on the next tick, because the delay is for the event stream a test interleaves, not a request-response reply or a lifecycle signal.
Order within a socket's stream is preserved, which is what keeps this compatible with
0010. The shipped DelayingAdapter holds a
per-sid high-water fire time: a delivery is scheduled no earlier than the one queued ahead
of it, so lowering a delay never lets a new event overtake one already waiting, and a
uniformly delayed stream stays in send order. Delay changes apply only to deliveries
scheduled after the change.
0010's guarantee shifts in kind but not in effect. It was structural: delivery lived in the core, out of the adapter's reach. Now a scheduling adapter could reorder a socket's stream, so per-socket FIFO becomes an obligation on any adapter that schedules, met by the only one that ships. The default no-hook path is still structurally FIFO, and the routing adapters from 0008 do not schedule at all.
Scheduling goes through an injected DeliveryTimer (default: setTimeout / Date.now),
so a test drives delay with Vitest's fake timers and never waits on the wall clock. Tests
that use it run against the mock target only, built on smocket's Server directly rather
than the dual-run fixture, since real socket.io has nothing to compare against.
Decision 0031 narrows teardown behavior. A whole-socket removal drains every pending delivery in FIFO order before the socket leaves its namespace roster. This keeps callback and Promise acknowledgements available to queued events and prevents a scheduled head from delivering twice. Scheduling remains server-to-client only.
Alternatives rejected
- A separate scheduling seam (
server.scheduler(factory)). A second registration API parallel to the adapter, for the one capability. It is more general, but the delay is a routing-adjacent concern the adapter is already the home for, and a test wanting both routing and delay would juggle two registrations of the same socket set. - A socket method,
socket.delay(ms). Puts a mock-only member on the socket surface, which the socket.io-compatible contract and itsEnsure<>guards are meant to keep clean, and scatters the knob across instances instead of the one sid-keyed place. - Real timers with wall-clock waits. Non-deterministic and slow; a race-condition test that sleeps is the flake it is meant to prevent. The injectable timer exists to rule this out.