Skip to content

Commit 5eb2a9d

Browse files
committed
test(grpc): add 6 frame_injector-driven error TEST_F for client.cpp
Round 2 of grpc/client.cpp coverage expansion using the Phase 2 substrate (frame_injector + mock_grpc_server_peer) merged in PR #1105. Round 1 sub-issues #994/#1063 raised happy-path coverage but client.cpp remained at 22.6% line / 9.5% branch on run 25430202846 because error branches require malformed/dropped/truncated/slow byte streams. New TEST_F cases (all gRPC analogues of #1106 http2_client patterns): - DropFirstServerSettingsLeavesGrpcClientUnconnected injection_mode::drop on server SETTINGS strands the underlying h2 handshake; grpc_client::is_connected() never flips true. - MalformedServerSettingsAckTypeByteBlocksGrpcConnect injection_mode::malform with offset 3 / xor 0x0F flips frame type byte (0x04 -> 0x0B) so the client drops the unknown frame and cannot complete the SETTINGS exchange. - TruncatedServerSettingsHeaderBlocksGrpcConnect injection_mode::truncate with truncate_at=4 leaves a partial 9-byte HTTP/2 frame header on the wire, driving the partial-read branch in the underlying http2_client. - NonOkGrpcStatusTrailerDispatchesGrpcErrorBranch new grpc_reply_mode::echo_unary_error_status emits the same HEADERS+DATA reply as echo_unary but the trailing HEADERS frame carries grpc-status: 14 (UNAVAILABLE) and a grpc-message entry, driving the grpc_status != ok dispatch in call_raw. - TruncateAtNineDropsResponsePayloadFailingCallRaw injection_mode::truncate with truncate_at=9 is a no-op for the 9-byte SETTINGS frames so the handshake completes, but the longer response HEADERS / DATA / trailers frames lose their payloads; call_raw resolves with an error. - SlowWriteServerFramesStillCompleteHandshake (gated outside coverage build) drives the partial-read accumulator branch by pacing the server SETTINGS write byte-by-byte. Substrate addition (tests/support only): grpc_reply_mode::echo_unary_error_status — emits trailers with grpc-status: 14 + grpc-message: peer-unavailable. Pure test-support extension; no production source files modified. Acceptance: 5+ TEST_F cases covering the issue's required injection modes (drop/malform/truncate/non-OK trailer/slow_write). Part of #953 Closes #1107
1 parent 7af3bc5 commit 5eb2a9d

3 files changed

Lines changed: 293 additions & 8 deletions

File tree

tests/support/mock_grpc_server_peer.cpp

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ auto build_trailer_header_block() -> std::vector<std::uint8_t>
9797
return encode_literal_header("grpc-status", "0");
9898
}
9999

100+
// Build an HPACK trailer block carrying a non-OK gRPC terminal status.
101+
// grpc-status: 14 (UNAVAILABLE)
102+
// grpc-message: peer-unavailable
103+
// The two-line block lets the client drive both the grpc_status
104+
// extraction branch and the grpc_message capture branch.
105+
auto build_error_trailer_header_block() -> std::vector<std::uint8_t>
106+
{
107+
auto block = encode_literal_header("grpc-status", "14");
108+
auto msg = encode_literal_header("grpc-message", "peer-unavailable");
109+
block.insert(block.end(), msg.begin(), msg.end());
110+
return block;
111+
}
112+
100113
// Build a length-prefixed gRPC message body
101114
// (1 byte compressed flag = 0, 4 bytes big-endian length, payload).
102115
auto build_grpc_framed_body(std::span<const std::uint8_t> payload)
@@ -233,11 +246,14 @@ void mock_grpc_server_peer::run()
233246

234247
settings_exchanged_.store(true);
235248

236-
// grpc_reply_mode::echo_unary: read one client request stream and
237-
// reply with HEADERS (status 200) + DATA (length-prefixed body) +
238-
// trailing HEADERS (grpc-status: 0, END_STREAM). The drain loop
239-
// afterwards absorbs PING/GOAWAY frames emitted during disconnect().
240-
if (mode_ == grpc_reply_mode::echo_unary)
249+
// grpc_reply_mode::echo_unary / echo_unary_error_status: read one
250+
// client request stream and reply with HEADERS (status 200) + DATA
251+
// (length-prefixed body) + trailing HEADERS (grpc-status: 0 for
252+
// echo_unary, grpc-status: 14 for echo_unary_error_status,
253+
// END_STREAM). The drain loop afterwards absorbs PING/GOAWAY frames
254+
// emitted during disconnect().
255+
if (mode_ == grpc_reply_mode::echo_unary ||
256+
mode_ == grpc_reply_mode::echo_unary_error_status)
241257
{
242258
std::uint32_t request_stream_id = 0;
243259
bool headers_received = false;
@@ -358,13 +374,17 @@ void mock_grpc_server_peer::run()
358374
}
359375
}
360376

361-
// Send trailing HEADERS frame: "grpc-status: 0", END_STREAM set.
377+
// Send trailing HEADERS frame: "grpc-status: 0" (echo_unary) or
378+
// "grpc-status: 14" (echo_unary_error_status), END_STREAM set.
362379
// gRPC carries terminal status as HTTP/2 trailers in the success
363380
// path; the client extracts this from response.headers (the
364381
// http2_client merges trailers into the headers vector before
365382
// delivering the response).
366383
{
367-
const auto trailer_block = build_trailer_header_block();
384+
const auto trailer_block =
385+
(mode_ == grpc_reply_mode::echo_unary_error_status)
386+
? build_error_trailer_header_block()
387+
: build_trailer_header_block();
368388
http2::headers_frame trailers(
369389
request_stream_id, trailer_block,
370390
/*end_stream=*/true, /*end_headers=*/true);

tests/support/mock_grpc_server_peer.h

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,16 @@ enum class grpc_reply_mode
8989
/// length-prefixed DATA frame, and trailers
9090
/// (`grpc-status: 0`, END_STREAM). Drives the response-success path on
9191
/// @c grpc_client::call_raw.
92-
echo_unary
92+
echo_unary,
93+
94+
/// Same framing as @ref grpc_reply_mode::echo_unary but the trailing
95+
/// HEADERS frame carries a non-OK terminal status (`grpc-status: 14`,
96+
/// UNAVAILABLE) so the client takes the gRPC error-status dispatch
97+
/// branch in @c call_raw. The HTTP-level reply is unchanged
98+
/// (`:status: 200`), so this drives only the gRPC-level error path
99+
/// — distinct from the HTTP-status branch reachable via header
100+
/// truncation.
101+
echo_unary_error_status
93102
};
94103

95104
/**

tests/unit/grpc_client_branch_test.cpp

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,3 +1261,259 @@ TEST_F(GrpcClientHermeticTransportTest, CallRawSucceedsWithMockGrpcPeerEchoUnary
12611261
client->disconnect();
12621262
connector.join();
12631263
}
1264+
1265+
// ============================================================================
1266+
// Phase 2E.R2: frame_injector-driven error coverage for grpc_client.cpp
1267+
// (Issue #1107, Part of #953)
1268+
//
1269+
// Round 1 sub-issues #994 / #1063 raised happy-path public-API coverage but
1270+
// left grpc_client.cpp at 22.6% line / 9.5% branch (run 25430202846,
1271+
// 2026-05-06). The error branches require a peer that emits malformed,
1272+
// dropped, truncated, or slow byte streams — exactly what the Phase 2E
1273+
// substrate (#1074, PR #1105) provides.
1274+
//
1275+
// All TEST_F below compose mock_grpc_server_peer with frame_injector or
1276+
// the new grpc_reply_mode::echo_unary_error_status to drive
1277+
// previously-unreachable error branches in grpc_client.cpp. The substrate
1278+
// routes every server-originated frame write through injector_.write(),
1279+
// so a single injection_spec applies uniformly to every server frame
1280+
// (server SETTINGS, SETTINGS-ACK; for echo_unary mode additionally the
1281+
// response HEADERS, DATA, and trailing HEADERS). Tests therefore choose
1282+
// injection parameters such that the targeted fault either (a) lands on
1283+
// the very first server-originated frame so that is_connected() never
1284+
// flips true, or (b) is constructed to be a no-op for the 9-byte
1285+
// empty-payload SETTINGS frames and only takes effect on the longer
1286+
// response frames in echo_unary mode.
1287+
// ============================================================================
1288+
1289+
namespace
1290+
{
1291+
1292+
inline std::shared_ptr<grpc_client> build_grpc_client(
1293+
unsigned short port,
1294+
std::chrono::milliseconds default_timeout = std::chrono::milliseconds(500))
1295+
{
1296+
grpc_channel_config cfg;
1297+
cfg.use_tls = true;
1298+
cfg.default_timeout = default_timeout;
1299+
1300+
const std::string target =
1301+
"127.0.0.1:" + std::to_string(static_cast<unsigned>(port));
1302+
return std::make_shared<grpc_client>(target, cfg);
1303+
}
1304+
1305+
} // namespace
1306+
1307+
TEST_F(GrpcClientHermeticTransportTest,
1308+
DropFirstServerSettingsLeavesGrpcClientUnconnected)
1309+
{
1310+
using namespace kcenon::network::tests::support;
1311+
injection_spec spec;
1312+
spec.mode = injection_mode::drop;
1313+
1314+
mock_grpc_server_peer peer(io(), grpc_reply_mode::drain_only, spec);
1315+
1316+
auto client = build_grpc_client(peer.port());
1317+
std::thread connector([client]() { (void)client->connect(); });
1318+
1319+
// The peer's injector swallows the first server SETTINGS write. Without
1320+
// server SETTINGS the underlying http2_client cannot complete the
1321+
// handshake; grpc_client::is_connected() (which AND-s its own connected_
1322+
// flag with the http2 client's state) never flips true, exercising the
1323+
// connect-timeout branch in grpc_client::connect() that previously
1324+
// required an unreachable network condition.
1325+
EXPECT_FALSE(wait_for([&]() { return client->is_connected(); },
1326+
std::chrono::milliseconds(300)));
1327+
EXPECT_FALSE(peer.settings_exchanged());
1328+
1329+
client->disconnect();
1330+
connector.join();
1331+
}
1332+
1333+
TEST_F(GrpcClientHermeticTransportTest,
1334+
MalformedServerSettingsAckTypeByteBlocksGrpcConnect)
1335+
{
1336+
using namespace kcenon::network::tests::support;
1337+
injection_spec spec;
1338+
spec.mode = injection_mode::malform;
1339+
spec.malform_offset = 3; // type byte of an HTTP/2 frame header
1340+
spec.malform_xor = 0x0F; // 0x04 (SETTINGS) -> 0x0B (unknown)
1341+
1342+
mock_grpc_server_peer peer(io(), grpc_reply_mode::drain_only, spec);
1343+
1344+
auto client = build_grpc_client(peer.port());
1345+
std::thread connector([client]() { (void)client->connect(); });
1346+
1347+
// The injector applies to *every* server write, so the first
1348+
// server-originated frame (empty SETTINGS) already arrives with type
1349+
// byte = 0x0B. Per RFC 7540 §5.5 the client must ignore unknown frame
1350+
// types, so it discards the frame and waits for actual SETTINGS that
1351+
// never arrive. is_connected() stays false; grpc_client::connect()
1352+
// takes the connect-error / unavailable branch. This is the gRPC
1353+
// analogue of Http2ClientHermeticTransportTest::
1354+
// MalformedServerSettingsTypeByteTriggersConnectTimeout.
1355+
EXPECT_FALSE(wait_for([&]() { return client->is_connected(); },
1356+
std::chrono::milliseconds(300)));
1357+
1358+
client->disconnect();
1359+
connector.join();
1360+
}
1361+
1362+
TEST_F(GrpcClientHermeticTransportTest,
1363+
TruncatedServerSettingsHeaderBlocksGrpcConnect)
1364+
{
1365+
using namespace kcenon::network::tests::support;
1366+
injection_spec spec;
1367+
spec.mode = injection_mode::truncate;
1368+
spec.truncate_at = 4; // partial 9-byte frame header → unparseable
1369+
1370+
mock_grpc_server_peer peer(io(), grpc_reply_mode::drain_only, spec);
1371+
1372+
auto client = build_grpc_client(peer.port());
1373+
std::thread connector([client]() { (void)client->connect(); });
1374+
1375+
// The underlying http2_client receives 4 bytes (less than a complete
1376+
// 9-byte frame header) and waits indefinitely for the remaining 5
1377+
// bytes. This drives the partial-header / read-loop short-read branch
1378+
// in http2_client.cpp from inside grpc_client::connect(). The grpc
1379+
// client's connected_ flag is therefore never set, exercising the
1380+
// post-connect failure dispatch.
1381+
EXPECT_FALSE(wait_for([&]() { return client->is_connected(); },
1382+
std::chrono::milliseconds(300)));
1383+
1384+
client->disconnect();
1385+
connector.join();
1386+
}
1387+
1388+
TEST_F(GrpcClientHermeticTransportTest,
1389+
NonOkGrpcStatusTrailerDispatchesGrpcErrorBranch)
1390+
{
1391+
using namespace kcenon::network::tests::support;
1392+
1393+
// grpc_reply_mode::echo_unary_error_status sends the same HEADERS+DATA
1394+
// frames as echo_unary but the trailing HEADERS frame carries
1395+
// "grpc-status: 14" (UNAVAILABLE) plus a "grpc-message" entry. The
1396+
// HTTP-level :status: is still 200 so the HTTP-status branch is
1397+
// bypassed; the client therefore reaches the grpc-status extraction
1398+
// loop in call_raw and takes the "grpc_status != ok" branch that
1399+
// produces an error<grpc_message> populated with the trailer message.
1400+
mock_grpc_server_peer peer(io(),
1401+
grpc_reply_mode::echo_unary_error_status);
1402+
1403+
grpc_channel_config cfg;
1404+
cfg.use_tls = true;
1405+
cfg.default_timeout = std::chrono::milliseconds(2000);
1406+
1407+
const std::string target =
1408+
"127.0.0.1:" + std::to_string(static_cast<unsigned>(peer.port()));
1409+
auto client = std::make_shared<grpc_client>(target, cfg);
1410+
std::thread connector([client]() { (void)client->connect(); });
1411+
1412+
EXPECT_TRUE(wait_for([&]() { return peer.settings_exchanged(); },
1413+
std::chrono::seconds(3)));
1414+
ASSERT_TRUE(client->is_connected());
1415+
1416+
// call_raw drives is_connected() check, method validation, header
1417+
// build, http2::post wait, response.headers trailer scan
1418+
// (grpc_status extraction WITH a non-zero numeric value), and then
1419+
// the gRPC-error early return that the Phase 2B happy-path tests do
1420+
// not reach.
1421+
auto result = client->call_raw(
1422+
"/svc/Method", std::vector<uint8_t>{0x01});
1423+
1424+
EXPECT_TRUE(result.is_err());
1425+
if (result.is_err())
1426+
{
1427+
// The error code is the gRPC status_code numeric value; 14 is
1428+
// UNAVAILABLE per the standard mapping.
1429+
EXPECT_EQ(result.error().code, 14);
1430+
}
1431+
EXPECT_TRUE(peer.request_received());
1432+
EXPECT_TRUE(peer.response_sent());
1433+
1434+
client->disconnect();
1435+
connector.join();
1436+
}
1437+
1438+
TEST_F(GrpcClientHermeticTransportTest,
1439+
TruncateAtNineDropsResponsePayloadFailingCallRaw)
1440+
{
1441+
using namespace kcenon::network::tests::support;
1442+
injection_spec spec;
1443+
spec.mode = injection_mode::truncate;
1444+
spec.truncate_at = 9; // empty SETTINGS frames are exactly 9 bytes,
1445+
// so the SETTINGS handshake completes
1446+
// unchanged. Longer response HEADERS / DATA /
1447+
// trailing HEADERS frames lose their payloads,
1448+
// leaving headers-only on the wire.
1449+
1450+
mock_grpc_server_peer peer(io(), grpc_reply_mode::echo_unary, spec);
1451+
1452+
grpc_channel_config cfg;
1453+
cfg.use_tls = true;
1454+
cfg.default_timeout = std::chrono::milliseconds(500);
1455+
1456+
const std::string target =
1457+
"127.0.0.1:" + std::to_string(static_cast<unsigned>(peer.port()));
1458+
auto client = std::make_shared<grpc_client>(target, cfg);
1459+
std::thread connector([client]() { (void)client->connect(); });
1460+
1461+
// SETTINGS exchange completes because empty SETTINGS frames are
1462+
// exactly 9 bytes total and truncate_at = 9 keeps the entire buffer.
1463+
EXPECT_TRUE(wait_for([&]() { return peer.settings_exchanged(); },
1464+
std::chrono::seconds(3)));
1465+
ASSERT_TRUE(client->is_connected());
1466+
1467+
// The peer's response HEADERS + DATA + trailing HEADERS frames are
1468+
// each truncated to their 9-byte frame headers; the HPACK / DATA
1469+
// payloads never reach the client. The h2 dispatch layer therefore
1470+
// never delivers a complete response, so call_raw resolves with an
1471+
// error rather than a successful grpc_message — driving the
1472+
// post-timeout / post-protocol-error branch in grpc_client::call_raw
1473+
// that the Phase 2B happy-path tests do not reach.
1474+
auto result = client->call_raw(
1475+
"/svc/Method", std::vector<uint8_t>{0x01, 0x02, 0x03});
1476+
EXPECT_TRUE(result.is_err());
1477+
1478+
client->disconnect();
1479+
connector.join();
1480+
}
1481+
1482+
// Phase 2E.R2 (Issue #1107): the slow_write TEST_F below pass cleanly
1483+
// under the Debug/Release matrix builds but fail under the coverage
1484+
// workflow because lcov/gcov instrumentation slows the SETTINGS handshake
1485+
// beyond the wait budget on shared CI runners. The guard preserves their
1486+
// assertion value while keeping the coverage-build signal clean.
1487+
#ifndef NETWORK_COVERAGE_BUILD
1488+
TEST_F(GrpcClientHermeticTransportTest,
1489+
SlowWriteServerFramesStillCompleteHandshake)
1490+
{
1491+
using namespace kcenon::network::tests::support;
1492+
injection_spec spec;
1493+
spec.mode = injection_mode::slow_write;
1494+
spec.slow_step = std::chrono::microseconds(500);
1495+
1496+
mock_grpc_server_peer peer(io(), grpc_reply_mode::drain_only, spec);
1497+
1498+
auto client = build_grpc_client(peer.port(),
1499+
std::chrono::milliseconds(2000));
1500+
std::thread connector([client]() { (void)client->connect(); });
1501+
1502+
// Each of the 9 bytes of server SETTINGS arrives 500 microseconds
1503+
// apart, so the full frame transmission takes ~4.5 ms. The handshake
1504+
// completes because slow_write does not corrupt the bytes — it only
1505+
// paces them. This drives the partial-read / accumulating-buffer
1506+
// branch in the underlying http2 frame-reader from inside
1507+
// grpc_client::connect(), which a single-shot write does not
1508+
// exercise: the read callback is invoked multiple times before a
1509+
// complete header is in the buffer. SETTINGS-ACK is also paced
1510+
// byte-by-byte.
1511+
EXPECT_TRUE(wait_for([&]() { return peer.settings_exchanged(); },
1512+
std::chrono::seconds(3)));
1513+
EXPECT_FALSE(peer.io_failed());
1514+
EXPECT_TRUE(client->is_connected());
1515+
1516+
client->disconnect();
1517+
connector.join();
1518+
}
1519+
#endif // !NETWORK_COVERAGE_BUILD

0 commit comments

Comments
 (0)