Skip to content

Commit 3886b85

Browse files
Peng Chenmeta-codesync[bot]
authored andcommitted
comms/uniflow/tcp: fail loudly on the paths that quietly did the wrong thing (meta-pytorch#3921)
Summary: Pull Request resolved: meta-pytorch#3921 Folded from two adjacent diffs; each half is stated separately below so the two arguments stay reviewable on their own terms. --- outbound: a failed operation must not reach the peer (was D117927060) ---- One invariant, enforced from the two ends it was missing: the peer must not do work for an operation whose caller has already been told it failed. put() has held that line since its pre-flight/commit split; these are the two places that did not. Folded together because they are halves of the same argument and each is small -- the second is the one that made the first insufficient. --- Before the first frame is queued (was D117888775) -------------------------- get() looked up each request's remote handle in the loop that also admits and queues frames, so a multi-request get() whose second request carries no TCP handle failed after the first request's ReadRequests were already queued. The peer serviced reads for an operation the caller had already been told had failed. put() settles everything that can fail before its first frame is queued and says so at the top of its pre-flight loop. get() did not hold that line. This moves the lookup into the loop that already walks requests to count chunks, and carries the resolved segIds forward, so a rejected get() leaves the peer untouched. Not a correctness fix in the load-bearing sense and not a performance one. The caller already saw the same InvalidArgument, and the destination buffer was never at risk: fail() marks the op done, so a late ReadReply fails tryBeginWrite() and skips the copy rather than writing into a buffer the caller may have released. What changed is that the peer no longer does work for a rejected operation, and those inflight_ slots are no longer held for a round trip. No hot-path effect either way: findRemoteHandle() was already called once per request rather than once per chunk, so this reorders the same work instead of removing any. Benchmarked regardless, because the get path is measured rather than argued about. --- After teardown has swept (was D117927060) ---------------------------------- The three admission points disagreed about connBroken_. admitInflight() and recvImpl() re-test it under their container mutex, because the caller's entry check is not enough on its own -- failAllPending() can land in the gap between that check and the insert. The three enqueue paths listed connBroken_ only in their wait predicate, as a wake condition, and after waking checked outClosed alone. failAllPending() sets connBroken_ and clears every lane queue, but never sets outClosed: the only writer is a sender that has died. handleFrame's exception containment sweeps without closing the connection on purpose, so the reachable state is connBroken_ set, reader stopped, connection open, sender alive indefinitely. A frame admitted just before that sweep then lands in the just-cleared queue and the live sender transmits it. For put and get that means a Write reaching the peer's segment for an operation whose caller has already been resolved with ConnectionFailed -- a partial write at offsets nobody is told about, which is the case put()'s pre-flight/commit split exists to prevent. For send it means the promise completes successfully on a transport that has failed everything else and stopped reading. Now all three check connBroken_ alongside outClosed and route it down the path they already had for refusal: enqueueFrame/enqueueFrames report false so the caller fails the op, and enqueueSendFrame takes ownership of the promise and fails it. senderLoop's own outClosed check is deliberately left alone. It has to keep draining whatever is already queued; refusing there would abandon frames rather than admit them. --- inbound: a mismatched reply must not complete an operation (was D117932250) --- TcpInflight::isRead records whether a chunk came from get() or put(), and the reply handler only consulted it on the ReadReply branch. The Ack branch did not, so an Ack naming a get chunk fell through to completeOne(): the chunk resolved Ok with entry.dst never written, and the caller read back whatever its destination buffer already held. That is the one peer-supplied dimension on this path that failed silently. segId, offset, len and payload size are all checked and all produce an error; a crossed reply kind produced success with wrong data. Both directions are now settled in one place, immediately after the entry lookup, and the check is exhaustive over the three ops that reach it: an Ack must name a write and a ReadReply must name a read. Error stays exempt because it is kind-agnostic by design. Consolidating also fixes a diagnostic that was wrong before: !entry.isRead used to report "read reply size mismatch", so a skewed peer sending a ReadReply for a put chunk sent the reader chasing a length bug that did not exist. That branch is now purely a size check and its message is accurate. Not reachable from a same-version peer -- Ack answers a Write, ReadReply answers a ReadRequest, and reqIds are unique per chunk -- so this is version skew or a hostile peer, the same bar as the oversized-ReadRequest check. Differential Revision: D117927060
1 parent de94b35 commit 3886b85

2 files changed

Lines changed: 162 additions & 12 deletions

File tree

comms/uniflow/transport/tcp/TcpTransport.cpp

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -988,14 +988,26 @@ std::future<Status> TcpTransport::get(
988988
auto state = std::make_shared<TcpOpState>();
989989
auto future = state->promise.get_future();
990990

991+
// Pre-flight. Same reason as put(): nothing that can fail may run after the
992+
// first frame is queued, because a queued ReadRequest is already on its way
993+
// to the peer and cannot be recalled. Resolving segIds here rather than in
994+
// the emit loop is what makes a rejected get() leave the peer untouched.
991995
size_t totalChunks = 0;
996+
std::vector<uint64_t> segIds;
997+
segIds.reserve(requests.size());
992998
for (const auto& req : requests) {
993999
if (req.local.size() != req.remote.size()) {
9941000
state->fail(
9951001
Err(ErrCode::InvalidArgument,
9961002
"tcp get: local and remote buffer sizes must match"));
9971003
return future;
9981004
}
1005+
auto remoteHandle = findRemoteHandle(req.remote);
1006+
if (!remoteHandle) {
1007+
state->fail(std::move(remoteHandle).error());
1008+
return future;
1009+
}
1010+
segIds.push_back(remoteHandle.value()->segId());
9991011
const size_t len = req.local.size();
10001012
const size_t chunkSize = adaptiveGetChunk(len, lanes_.size());
10011013
totalChunks += (len == 0) ? 1 : (len + chunkSize - 1) / chunkSize;
@@ -1013,13 +1025,9 @@ std::future<Status> TcpTransport::get(
10131025
}
10141026
}
10151027

1016-
for (const auto& req : requests) {
1017-
auto remoteHandle = findRemoteHandle(req.remote);
1018-
if (!remoteHandle) {
1019-
state->fail(std::move(remoteHandle).error());
1020-
return future;
1021-
}
1022-
const uint64_t segId = remoteHandle.value()->segId();
1028+
for (size_t reqIdx = 0; reqIdx < requests.size(); ++reqIdx) {
1029+
const auto& req = requests[reqIdx];
1030+
const uint64_t segId = segIds[reqIdx];
10231031
const uint64_t baseOffset = static_cast<uint64_t>(req.remote.remoteOffset_);
10241032
const size_t len = req.local.size();
10251033
const MemoryType memType = req.local.memType();
@@ -1834,7 +1842,15 @@ bool TcpTransport::enqueueFrame(TcpFrame frame, bool mayBlock) {
18341842
// the connection. It cannot wait either: that stops it draining the socket
18351843
// and reintroduces the mutual-READ deadlock the reader/sender split exists
18361844
// to avoid. What bounds this queue is the drain rate, not a byte cap.
1837-
if (lane.outClosed) {
1845+
// Refused on connBroken_ as well as outClosed, so this admission point
1846+
// gives the same answer as admitInflight() and recvImpl(). failAllPending()
1847+
// sets connBroken_ and clears this queue but never sets outClosed -- only a
1848+
// dead sender does that -- and handleFrame's exception containment sweeps
1849+
// without closing the connection, leaving the sender alive. Checking
1850+
// outClosed alone therefore lets a frame admitted before the sweep land in
1851+
// the cleared queue and go out on the wire for an op whose caller has
1852+
// already been told it failed.
1853+
if (lane.outClosed || connBroken_.load(std::memory_order_acquire)) {
18381854
return false;
18391855
}
18401856
lane.queue.push_back(TcpOutItem{std::move(frame), nullptr});
@@ -1871,7 +1887,7 @@ bool TcpTransport::enqueueFrames(std::vector<TcpFrame> frames, bool mayBlock) {
18711887
lane.queue.empty() || lane.bytes + bytes <= cap;
18721888
});
18731889
}
1874-
if (lane.outClosed) {
1890+
if (lane.outClosed || connBroken_.load(std::memory_order_acquire)) {
18751891
return false;
18761892
}
18771893
for (auto& frame : frames) {
@@ -1913,7 +1929,7 @@ void TcpTransport::enqueueSendFrame(
19131929
return lane.outClosed || connBroken_.load(std::memory_order_acquire) ||
19141930
lane.queue.empty() || lane.bytes + bytes <= cap;
19151931
});
1916-
if (lane.outClosed) {
1932+
if (lane.outClosed || connBroken_.load(std::memory_order_acquire)) {
19171933
closed = true;
19181934
// Taken over here so each path has exactly one owner: the queue takes it
19191935
// when the frame is enqueued, this does when it cannot be.
@@ -2354,13 +2370,29 @@ void TcpTransport::handleFrameImpl(
23542370
std::lock_guard<std::mutex> lk(inflightMu_);
23552371
inflight_.erase(header.reqId);
23562372
};
2373+
// An Ack answers a Write and a ReadReply answers a ReadRequest, so a
2374+
// reply whose kind disagrees with the request it names is version skew or
2375+
// a hostile peer. Rejected here rather than per-branch because the Ack
2376+
// direction is the dangerous one and it used to fall straight through to
2377+
// completeOne(): the get chunk resolved Ok with entry.dst never written,
2378+
// handing the caller back whatever its buffer already held. Every other
2379+
// peer-supplied dimension on this path fails loudly; this one did not
2380+
// fail at all. Error is exempt because it is kind-agnostic by design.
2381+
if (op != TcpOp::Error && (op == TcpOp::ReadReply) != entry.isRead) {
2382+
eraseInflight();
2383+
entry.state->fail(
2384+
Err(ErrCode::TransportError,
2385+
"tcp: reply op does not match the request kind"));
2386+
break;
2387+
}
23572388
if (op == TcpOp::Error) {
23582389
eraseInflight();
23592390
entry.state->fail(
23602391
Err(ErrCode::TransportError, "tcp: peer reported an error"));
23612392
} else if (op == TcpOp::ReadReply) {
2362-
if (!entry.isRead || payload.size() != header.len ||
2363-
header.len != entry.len) {
2393+
// Kind is settled above, so this is purely a size check and its message
2394+
// says so.
2395+
if (payload.size() != header.len || header.len != entry.len) {
23642396
eraseInflight();
23652397
entry.state->fail(Err(
23662398
ErrCode::TransportError, "tcp get: read reply size mismatch"));

comms/uniflow/transport/tcp/tests/unit/TcpTransportFrameTest.cpp

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ class TcpTransportFrameTest : public ::testing::Test {
206206
protected:
207207
static constexpr uint64_t kSegId = 42;
208208
static constexpr size_t kSegLen = 256;
209+
/// Destination length for the reply-kind-mismatch tests.
210+
static constexpr size_t kAckDstLen = 16;
209211

210212
void SetUp() override {
211213
evbThread_ = std::make_unique<ScopedEventBaseThread>("tcp-frame-test");
@@ -1179,6 +1181,122 @@ TEST_F(TcpTransportFrameTest, AdmissionIsRefusedOnceTeardownHasSwept) {
11791181
<< "admitInflight must not resolve the promise itself";
11801182
}
11811183

1184+
// The three admission points must agree about connBroken_. admitInflight() and
1185+
// recvImpl() re-test it under their container mutex; the enqueue paths listed
1186+
// it only in the wait predicate, as a wake condition, and then checked
1187+
// outClosed alone. failAllPending() does not set outClosed -- the only writer
1188+
// is the dead sender -- so after a sweep that leaves the connection open
1189+
// (handleFrame's exception containment does exactly that: it stops the reader
1190+
// without closing) a frame admitted before the sweep still lands in the
1191+
// just-cleared queue and the live sender transmits it.
1192+
//
1193+
// For put that means a Write applied to the peer's segment for an operation the
1194+
// caller was already told had failed, which is the partial-write-nobody-knows
1195+
// -about case put()'s pre-flight exists to prevent.
1196+
TEST_F(TcpTransportFrameTest, EnqueueIsRefusedOnceTeardownHasSwept) {
1197+
setConnected();
1198+
failAllPending();
1199+
1200+
EXPECT_FALSE(enqueueFrame(makeFrame(
1201+
TcpOp::Write, kSegId, /*offset=*/0, /*len=*/8, /*payloadBytes=*/8)))
1202+
<< "a frame queued after the sweep is transmitted for a failed op";
1203+
EXPECT_EQ(outQueueDepth(), 0u) << "no frame may remain queued after a sweep";
1204+
}
1205+
1206+
TEST_F(TcpTransportFrameTest, EnqueueFramesIsRefusedOnceTeardownHasSwept) {
1207+
setConnected();
1208+
failAllPending();
1209+
1210+
std::vector<TcpFrame> group;
1211+
group.emplace_back(makeFrame(
1212+
TcpOp::Write, kSegId, /*offset=*/0, /*len=*/8, /*payloadBytes=*/8));
1213+
1214+
EXPECT_FALSE(enqueueFrames(std::move(group), /*mayBlock=*/false))
1215+
<< "a staged wave queued after the sweep is transmitted for a failed op";
1216+
EXPECT_EQ(outQueueDepth(), 0u);
1217+
}
1218+
1219+
// send() owns a promise, so refusal has to fail it rather than drop the frame.
1220+
// Reporting success here says a send completed on a transport that has failed
1221+
// everything else and stopped reading.
1222+
TEST_F(TcpTransportFrameTest, SendFrameIsRefusedOnceTeardownHasSwept) {
1223+
setConnected();
1224+
failAllPending();
1225+
1226+
auto state = std::make_shared<TcpOpState>();
1227+
state->remaining = 1;
1228+
auto future = state->promise.get_future();
1229+
1230+
enqueueSendFrame(
1231+
makeFrame(TcpOp::Send, kSegId, /*offset=*/0, /*len=*/8, 8), state);
1232+
1233+
EXPECT_EQ(outQueueDepth(), 0u) << "the send frame must not be queued";
1234+
ASSERT_EQ(future.wait_for(std::chrono::seconds{5}), std::future_status::ready)
1235+
<< "a refused send must fail its promise, not leave the caller waiting";
1236+
EXPECT_TRUE(future.get().hasError())
1237+
<< "send must not report success on a failed connection";
1238+
}
1239+
1240+
// Ack answers a Write, ReadReply answers a ReadRequest, and reqIds are unique
1241+
// per chunk, so a same-version peer never crosses them. The ReadReply direction
1242+
// was already rejected; the Ack direction was not, and it failed silently
1243+
// rather than loudly: the get chunk resolved Ok with its destination never
1244+
// written, so the caller read back whatever the buffer held before. Every other
1245+
// peer-supplied dimension on this path -- segId, offset, len, payload size --
1246+
// is checked, and all of those fail with an error.
1247+
//
1248+
// Version-skew or a hostile peer only, which is the same bar as
1249+
// OversizedReadRequestIsRefusedPerRequest.
1250+
TEST_F(TcpTransportFrameTest, AckOnAReadRequestIsRejected) {
1251+
setConnected();
1252+
1253+
// A destination pre-filled with a known pattern, so "never written" is
1254+
// detectable rather than merely assumed.
1255+
std::vector<uint8_t> dst(kAckDstLen, uint8_t{0x11});
1256+
const std::vector<uint8_t> pristineDst = dst;
1257+
1258+
auto state = std::make_shared<TcpOpState>();
1259+
state->remaining = 1;
1260+
auto future = state->promise.get_future();
1261+
ASSERT_FALSE(admitInflight(
1262+
/*reqId=*/1,
1263+
TcpInflight{state, dst.data(), kAckDstLen, /*isRead=*/true})
1264+
.hasError());
1265+
1266+
feed(makeFrame(
1267+
TcpOp::Ack, kSegId, /*offset=*/0, /*len=*/0, /*payloadBytes=*/0));
1268+
1269+
ASSERT_EQ(future.wait_for(std::chrono::seconds{5}), std::future_status::ready)
1270+
<< "the op must be resolved rather than left outstanding";
1271+
EXPECT_TRUE(future.get().hasError())
1272+
<< "an Ack cannot complete a get: the destination was never written";
1273+
EXPECT_EQ(dst, pristineDst) << "the destination buffer must be untouched";
1274+
EXPECT_EQ(inflightCount(), 0u)
1275+
<< "a rejected reply must not leave its admission slot held";
1276+
}
1277+
1278+
// The mirror direction, already guarded before this change. Kept as a pair so a
1279+
// future edit cannot close one direction and reopen the other.
1280+
TEST_F(TcpTransportFrameTest, ReadReplyOnAWriteIsRejected) {
1281+
setConnected();
1282+
1283+
auto state = std::make_shared<TcpOpState>();
1284+
state->remaining = 1;
1285+
auto future = state->promise.get_future();
1286+
ASSERT_FALSE(admitInflight(
1287+
/*reqId=*/1,
1288+
TcpInflight{state, nullptr, kAckDstLen, /*isRead=*/false})
1289+
.hasError());
1290+
1291+
feed(makeFrame(
1292+
TcpOp::ReadReply, kSegId, /*offset=*/0, kAckDstLen, kAckDstLen));
1293+
1294+
ASSERT_EQ(
1295+
future.wait_for(std::chrono::seconds{5}), std::future_status::ready);
1296+
EXPECT_TRUE(future.get().hasError()) << "a ReadReply cannot complete a put";
1297+
EXPECT_EQ(inflightCount(), 0u);
1298+
}
1299+
11821300
TEST_F(TcpTransportFrameTest, AdmissionSucceedsOnAHealthyTransport) {
11831301
setConnected();
11841302

0 commit comments

Comments
 (0)