Verified still reproducing on f1fea6c (2026-09-16). Nothing has been
fixed. The server-side loss is now reproduced over a real socket, the client
loop turns out to have the same shape, and the fix originally proposed here
would not have worked — see Fix. References are refreshed after
#248/#250 moved TCP onto core's FramedConnection. Claim-by-claim check in
Verification.
Summary
Both session loops rebuild Connection::recv() on every select_biased!
iteration and drop it whenever another arm wins, so recv must be cancel-safe.
TCP, serial and WebSocket are; UDS is not. A UDS frame that needs more than one
read, arriving while another arm becomes ready, is cut in half and lost without
a trace.
The mechanism
Server — aimdb-core/src/session/server.rs:130-153:
let step = {
let mut recv = conn.recv().fuse();
let mut event = core::pin::pin!(event_rx.recv().fuse());
select_biased! {
r = recv => ...,
ev = event => ...,
sub_id = subs.select_next_some() => ...,
}
};
Client — drive_connection in aimdb-core/src/session/client.rs:627-661 has the
same shape, with keepalive, prune and caller cmd arms beside recv.
select_biased! only prioritizes among futures that are ready. If recv is
Pending and another arm is Ready, the select resolves on that arm and recv — a
local — is dropped at the end of the block. The next iteration builds a fresh
one. The Connection trait (aimdb-core/src/session/mod.rs:376-378) does not
state the requirement.
Transport-by-transport
| Transport |
recv impl |
Cancel-safe? |
| TCP, serial |
core's FramedConnection (aimdb-core/src/session/io.rs:281) — partial bytes live in the connection's Framer; ByteStream::read is cancel-safe on the shipped adapters (io.rs:66-72) |
yes |
WebSocket server (aimdb-websocket-connector/src/transport.rs:73) |
pending queue on self, then self.ws.recv() |
yes |
WebSocket client (transport.rs:144) |
self.ws.next() |
yes |
UDS (aimdb-uds-connector/src/transport.rs:41) |
read_line into a local String |
no |
The UDS impl:
fn recv(&mut self) -> BoxFut<'_, TransportResult<Option<Vec<u8>>>> {
Box::pin(async move {
let mut line = String::new();
match self.reader.read_line(&mut line).await { ... }
})
}
read_line consumes bytes from the BufReader into the future's own buffer
before the line is complete. Dropping the future discards them; the BufReader
has already advanced.
Impact
Server — reproduced. The request's head is lost. Its tail arrives as a frame
of its own, fails to decode (it carries the outer object's unmatched }), and is
skipped by the malformed-frame arm (server.rs:183-186). The session survives;
the request gets no reply and nothing is logged.
Client — by inspection. aimdb-client's AimxConnection dials unix://
endpoints through UdsDialer (aimdb-client/src/endpoint.rs:124), so the CLI
and anything else on that client are exposed the same way. A reply or event
arriving in pieces while the caller issues a command, the keepalive timer fires,
or a prune sweep runs is lost; the client also skips the malformed tail
(client.rs:737), so the call waits for a reply that never comes.
The trigger is a frame larger than one read — large record.set params, a
record.list over a big database, a large event payload — while the other
direction is active on the same connection. Rare, and hard to diagnose when it
does happen.
Fix
Either option stays inside aimdb-uds-connector.
Option A — move UDS onto FramedConnection. Design 052 already names this
route for the crate (docs/design/052-runtime-neutral-connectors.md:258). Add
an NDJSON Framer (split on \n, keep today's trailing-\r tolerance; a bad
line is FrameFault::Recoverable, since the next newline resyncs) and build
connections through it, as TCP does with FramingListener/FramingDialer.
Partial lines then live in the framer, and UDS stops being the one hand-rolled
transport. TokioByteStream<UnixStream> already satisfies ByteStream, but
aimdb-tokio-adapter is only a dev-dependency here — add it, or wrap
UnixStream in a local newtype.
Option B — minimal. Keep the hand-rolled connection and hoist the partial
line into it:
pub struct UdsConnection {
reader: BufReader<OwnedReadHalf>,
writer: OwnedWriteHalf,
pending: Vec<u8>, // survives a dropped recv future
peer: PeerInfo,
}
Read with read_until(b'\n', &mut self.pending) and take the buffer only once
it ends in a newline (or at EOF).
It must be read_until into a Vec<u8>, not read_line into a String.
This issue originally proposed a pending: String resumed with read_line.
That would not fix it: tokio moves the String's contents into the future and,
if the future is dropped, leaves the String empty. Tokio documents read_line
as not cancel-safe for exactly this reason and read_until as resumable.
Also worth doing
State the requirement on Connection::recv (aimdb-core/src/session/mod.rs:376-378):
both loops drop the future whenever another arm wins. ByteStream already
carries equivalent wording (io.rs:66-72) to mirror.
Test
Deterministic, in aimdb-uds-connector/tests/, against the real UdsServer:
- Subscribe to a
SpmcRing record with with_remote_access().
- Write the first bytes of a
record.list request, no newline; pause so the
server reads them.
- Produce one value — the event arm wins the select.
- Read the event, write the rest of the request, and assert the reply arrives.
Keep the control case alongside: the same split request with no event in between
must be answered, which shows it is the event, not the split, that loses it.
A client-side counterpart needs a raw UnixListener peer that writes half a
reply while the caller sends a second command.
Verification
Checked against f1fea6c on 2026-09-16. The test above was written as a scratch
integration test in aimdb-uds-connector/tests/ and run five times, with the
same result each time:
control (split, no event) -> {"t":"reply","id":2,"ok":[…]}
event while head is buffered -> {"t":"event","seq":1,"sub":"1","data":{"n":1}}
split request after event -> (no reply within 2 s)
whole request afterwards -> {"t":"reply","id":4,"ok":[…]}
Claim by claim:
| Claim as filed |
Status |
run_session drops recv when another arm wins |
holds — server.rs:130-153, unchanged |
UDS recv uses read_line into a local String |
holds — transport.rs:41-57, unchanged |
| Truncated frame is skipped, the session survives |
holds — reproduced; arm now at server.rs:183-186 |
| The remainder "can mis-decode too" |
dropped — the tail always fails to decode and is skipped |
TCP is cancel-safe via self.acc in tokio_transport.rs:59 |
outdated — file removed in #248/#250; TCP and serial use FramedConnection, still cancel-safe |
WebSocket recv at transport.rs:140 |
moved — server :73, client :144; both still cancel-safe |
Fix: resume read_line into a pending: String |
would not fix it — see Fix |
No mention of cancel safety anywhere in session/ |
outdated — ByteStream documents it (io.rs:66-72); Connection still does not |
Connection::recv at session/mod.rs:345 |
moved — :376-378 |
| Only the server loop is affected |
incomplete — client drive_connection (client.rs:627-661) has the same shape |
Found on feat/retire-aimdb-ws-protocol |
merged as #201; the bug survived the merge |
Verification section generated by Claude Code
Summary
Both session loops rebuild
Connection::recv()on everyselect_biased!iteration and drop it whenever another arm wins, so
recvmust be cancel-safe.TCP, serial and WebSocket are; UDS is not. A UDS frame that needs more than one
read, arriving while another arm becomes ready, is cut in half and lost without
a trace.
The mechanism
Server —
aimdb-core/src/session/server.rs:130-153:Client —
drive_connectioninaimdb-core/src/session/client.rs:627-661has thesame shape, with
keepalive,pruneand callercmdarms besiderecv.select_biased!only prioritizes among futures that are ready. IfrecvisPending and another arm is Ready, the select resolves on that arm and
recv— alocal — is dropped at the end of the block. The next iteration builds a fresh
one. The
Connectiontrait (aimdb-core/src/session/mod.rs:376-378) does notstate the requirement.
Transport-by-transport
recvimplFramedConnection(aimdb-core/src/session/io.rs:281) — partial bytes live in the connection'sFramer;ByteStream::readis cancel-safe on the shipped adapters (io.rs:66-72)aimdb-websocket-connector/src/transport.rs:73)pendingqueue onself, thenself.ws.recv()transport.rs:144)self.ws.next()aimdb-uds-connector/src/transport.rs:41)read_lineinto a localStringThe UDS impl:
read_lineconsumes bytes from theBufReaderinto the future's own bufferbefore the line is complete. Dropping the future discards them; the
BufReaderhas already advanced.
Impact
Server — reproduced. The request's head is lost. Its tail arrives as a frame
of its own, fails to decode (it carries the outer object's unmatched
}), and isskipped by the malformed-frame arm (
server.rs:183-186). The session survives;the request gets no reply and nothing is logged.
Client — by inspection.
aimdb-client'sAimxConnectiondialsunix://endpoints through
UdsDialer(aimdb-client/src/endpoint.rs:124), so the CLIand anything else on that client are exposed the same way. A reply or event
arriving in pieces while the caller issues a command, the keepalive timer fires,
or a prune sweep runs is lost; the client also skips the malformed tail
(
client.rs:737), so the call waits for a reply that never comes.The trigger is a frame larger than one read — large
record.setparams, arecord.listover a big database, a large event payload — while the otherdirection is active on the same connection. Rare, and hard to diagnose when it
does happen.
Fix
Either option stays inside
aimdb-uds-connector.Option A — move UDS onto
FramedConnection. Design 052 already names thisroute for the crate (
docs/design/052-runtime-neutral-connectors.md:258). Addan NDJSON
Framer(split on\n, keep today's trailing-\rtolerance; a badline is
FrameFault::Recoverable, since the next newline resyncs) and buildconnections through it, as TCP does with
FramingListener/FramingDialer.Partial lines then live in the framer, and UDS stops being the one hand-rolled
transport.
TokioByteStream<UnixStream>already satisfiesByteStream, butaimdb-tokio-adapteris only a dev-dependency here — add it, or wrapUnixStreamin a local newtype.Option B — minimal. Keep the hand-rolled connection and hoist the partial
line into it:
Read with
read_until(b'\n', &mut self.pending)and take the buffer only onceit ends in a newline (or at EOF).
It must be
read_untilinto aVec<u8>, notread_lineinto aString.This issue originally proposed a
pending: Stringresumed withread_line.That would not fix it: tokio moves the
String's contents into the future and,if the future is dropped, leaves the
Stringempty. Tokio documentsread_lineas not cancel-safe for exactly this reason and
read_untilas resumable.Also worth doing
State the requirement on
Connection::recv(aimdb-core/src/session/mod.rs:376-378):both loops drop the future whenever another arm wins.
ByteStreamalreadycarries equivalent wording (
io.rs:66-72) to mirror.Test
Deterministic, in
aimdb-uds-connector/tests/, against the realUdsServer:SpmcRingrecord withwith_remote_access().record.listrequest, no newline; pause so theserver reads them.
Keep the control case alongside: the same split request with no event in between
must be answered, which shows it is the event, not the split, that loses it.
A client-side counterpart needs a raw
UnixListenerpeer that writes half areply while the caller sends a second command.
Verification
Checked against
f1fea6con 2026-09-16. The test above was written as a scratchintegration test in
aimdb-uds-connector/tests/and run five times, with thesame result each time:
Claim by claim:
run_sessiondropsrecvwhen another arm winsserver.rs:130-153, unchangedrecvusesread_lineinto a localStringtransport.rs:41-57, unchangedserver.rs:183-186self.accintokio_transport.rs:59FramedConnection, still cancel-saferecvattransport.rs:140:73, client:144; both still cancel-saferead_lineinto apending: Stringsession/ByteStreamdocuments it (io.rs:66-72);Connectionstill does notConnection::recvatsession/mod.rs:345:376-378drive_connection(client.rs:627-661) has the same shapefeat/retire-aimdb-ws-protocolVerification section generated by Claude Code