Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,41 @@ jobs:
--log-dir ../interop-results/logs-cross-zquic-quinn \
--json ../interop-results/results-cross-zquic-quinn.json \
--debug || true

# #229: the bulk `transfer` testcases intermittently stall on loaded
# runners (a DIFFERENT transfer flakes each run on behavior-identical
# commits). Auto-retry ONLY failed `transfer` cases once, into
# results-<suite>-retry.json; the summary gate below treats a
# base-failed + retry-passed transfer as flaky-passed. A real
# transfer regression fails both attempts and still reds the gate.
# cross-zquic-quinn's transfer is already KNOWN_FAILING (#184) — no
# retry there.
retry_transfer() {
local suite="$1" rclient="$2" rserver="$3"
python3 - "$suite" <<'PY' || return 0
import json, pathlib, sys
p = pathlib.Path(f"../interop-results/results-{sys.argv[1]}.json")
try:
d = json.loads(p.read_text())
except Exception:
sys.exit(1)
for pair in d.get("results", []):
for t in pair:
if isinstance(t, dict) and t.get("name") == "transfer" and t.get("result") == "failed":
sys.exit(0) # exit 0 = a failed transfer exists -> retry
sys.exit(1)
PY
echo "::notice::retrying flaky transfer for suite '$suite' (zquic#229)"
python3 run.py \
--client "$rclient" \
--server "$rserver" \
--test transfer \
--log-dir "../interop-results/logs-$suite-retry" \
--json "../interop-results/results-$suite-retry.json" \
--debug || true
}
retry_transfer zquic zquic zquic
retry_transfer cross-quinn-zquic quinn zquic
# The "Print result summary" step (if: always()) is the authoritative
# gate — it exits non-zero on any NEW/unexpected failure. Upload/summary
# steps below use if: always() so they run regardless.
Expand All @@ -300,11 +335,28 @@ jobs:
python3 - <<'EOF'
import json, pathlib, sys

result_files = sorted(pathlib.Path("interop-results").glob("results-*.json"))
all_files = sorted(pathlib.Path("interop-results").glob("results-*.json"))
result_files = [p for p in all_files if not p.stem.endswith("-retry")]
if not result_files:
print("No results-*.json found — test run may have failed to start.")
sys.exit(0)

# #229: tests that passed on the automatic retry (results-<suite>-retry.json).
# A base failure with a passing retry is classified flaky-passed, not failed.
retry_passed = set()
for p in all_files:
if not p.stem.endswith("-retry"):
continue
suite = p.stem.removeprefix("results-").removesuffix("-retry")
try:
data = json.loads(p.read_text())
except Exception:
continue
for pair in data.get("results", []):
for t in pair:
if isinstance(t, dict) and t.get("result") == "succeeded":
retry_passed.add((suite, t.get("name")))

# Known cross-impl gaps (zquic-as-client vs a quinn server), tracked
# in zquic#184. Classified separately so a known gap does NOT fail the
# job; a NEW/unexpected failure still does.
Expand All @@ -313,6 +365,7 @@ jobs:
"rebind-port(cross-zquic-quinn:zquic→quinn)",
}
passed, failed, unsupported, cross_failed, known_failing = [], [], [], [], []
flaky_passed = []
for p in result_files:
data = json.loads(p.read_text())
suite = p.stem.removeprefix("results-")
Expand Down Expand Up @@ -341,6 +394,8 @@ jobs:
unsupported.append(tag)
elif tag in KNOWN_FAILING:
known_failing.append(tag)
elif (suite, test_name) in retry_passed:
flaky_passed.append(tag)
elif suite.startswith("cross-"):
cross_failed.append(tag)
else:
Expand All @@ -350,6 +405,8 @@ jobs:
print(f"INTEROP RESULTS")
print(f"{'='*55}")
print(f" PASSED : {len(passed):2d} {passed}")
if flaky_passed:
print(f" FLAKY-PASSED: {len(flaky_passed):2d} {flaky_passed} (failed once, passed on retry — zquic#229)")
print(f" FAILED : {len(failed):2d} {failed}")
if cross_failed:
print(f" CROSS FAILED: {len(cross_failed):2d} {cross_failed}")
Expand Down
139 changes: 130 additions & 9 deletions src/transport/io.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,16 @@ const max_pending_drain_per_call: usize = 512;
// again while a single drive() still stays well under the wall-clock budget.
const max_sends_per_drive: usize = 2048;

/// Rate limit for re-signalling STREAM_DATA_BLOCKED / DATA_BLOCKED from the
/// pending-send drain (#231). The fresh-send path emits the BLOCKED frame
/// once when a write first gates, but if the peer's window GRANT datagram is
/// lost, bytes already accepted into `pending_stream_sends` sit behind a
/// closed window with nothing re-signalling — the drain silently skips them
/// and (with an idle app) the transfer wedges forever. BLOCKED frames are
/// not retransmitted on loss (only STREAM data has retransmit machinery), so
/// the drain re-emits them, throttled to one per interval per conn.
const blocked_signal_interval_ms: i64 = 250;

/// Effective per-connection pending-byte cap for `stream_id`. Priority streams
/// (the persistent gossip stream, marked by the embedder via `markStreamPriority`)
/// get the full `pending_stream_send_bytes_cap` (40 MB); non-priority streams
Expand Down Expand Up @@ -2199,6 +2209,10 @@ pub const ConnState = struct {
/// `pending_priority_reserve_bytes` / `pendingBytesCapForStream`. Set is
/// tiny (one persistent stream per conn); cleared on conn reset/reap.
stream_priorities: std.AutoHashMapUnmanaged(u64, i32) = .empty,
/// Last wall-clock a BLOCKED frame was re-signalled from the drain
/// (shared across DATA_BLOCKED and STREAM_DATA_BLOCKED; see
/// `blocked_signal_interval_ms`).
blocked_signal_last_ms: i64 = 0,
/// Peer's `max_idle_timeout` (RFC 9000 §10.1) in milliseconds. Effective
/// idle timeout is min(local, peer); 0 means peer omitted the param so
/// only the local value applies.
Expand Down Expand Up @@ -3869,11 +3883,15 @@ pub const Server = struct {
const chunk_len = @min(unsent, max_pending_stream_chunk);
const stream_limit = conn.peerStreamSendLimit(p.stream_id, true);
if (stream_limit > 0 and p.offset +| chunk_len > stream_limit) {
// Re-signal in case the peer's MAX_STREAM_DATA grant was
// lost — queued bytes would otherwise wedge silently (#231).
self.maybeSignalStreamDataBlocked(conn, p.stream_id, stream_limit);
i += 1;
continue;
}
const projected: u64 = conn.fc_bytes_sent +| chunk_len;
if (projected > conn.fc_send_max) {
self.maybeSignalDataBlocked(conn);
i += 1;
continue;
}
Expand Down Expand Up @@ -7147,6 +7165,31 @@ pub const Server = struct {
/// Send a MAX_DATA frame to extend the peer's connection-level send window.
/// Called when we have consumed ≥50% of the advertised receive window so the
/// peer is not forced to stall. We double the window each time.
/// Rate-limited STREAM_DATA_BLOCKED re-signal from the pending-send drain
/// (RFC 9000 §4.1: a blocked sender SHOULD signal; #231 wedge fix — see
/// `blocked_signal_interval_ms`).
fn maybeSignalStreamDataBlocked(self: *Server, conn: *ConnState, stream_id: u64, limit: u64) void {
const now = compat.milliTimestamp();
if (now - conn.blocked_signal_last_ms < blocked_signal_interval_ms) return;
conn.blocked_signal_last_ms = now;
var blk_buf: [24]u8 = undefined;
blk_buf[0] = 0x15; // STREAM_DATA_BLOCKED
const sid_enc = varint.encode(blk_buf[1..], stream_id) catch return;
const lim_enc = varint.encode(blk_buf[1 + sid_enc.len ..], limit) catch return;
self.send1Rtt(conn, blk_buf[0 .. 1 + sid_enc.len + lim_enc.len], conn.peer);
}

/// Rate-limited DATA_BLOCKED re-signal from the pending-send drain (#231).
fn maybeSignalDataBlocked(self: *Server, conn: *ConnState) void {
const now = compat.milliTimestamp();
if (now - conn.blocked_signal_last_ms < blocked_signal_interval_ms) return;
conn.blocked_signal_last_ms = now;
var blk_buf: [16]u8 = undefined;
blk_buf[0] = 0x14; // DATA_BLOCKED
const enc = varint.encode(blk_buf[1..], conn.fc_send_max) catch return;
self.send1Rtt(conn, blk_buf[0 .. 1 + enc.len], conn.peer);
}

fn sendMaxData(self: *Server, conn: *ConnState, dst: compat.Address) void {
conn.fc_recv_max = conn.fc_bytes_recv + 64 * 1024 * 1024;
var buf: [16]u8 = undefined;
Expand Down Expand Up @@ -9346,6 +9389,29 @@ pub const Client = struct {
/// window (RFC 9000 §19.9). Mirror of `Server.sendMaxData` for the client —
/// the client previously did no receive-side connection flow control, so a
/// server streaming past our advertised `initial_max_data` would stall.
/// Mirror of `Server.maybeSignalStreamDataBlocked` (#231).
fn maybeSignalStreamDataBlocked(self: *Client, stream_id: u64, limit: u64) void {
const now = compat.milliTimestamp();
if (now - self.conn.blocked_signal_last_ms < blocked_signal_interval_ms) return;
self.conn.blocked_signal_last_ms = now;
var blk_buf: [24]u8 = undefined;
blk_buf[0] = 0x15; // STREAM_DATA_BLOCKED
const sid_enc = varint.encode(blk_buf[1..], stream_id) catch return;
const lim_enc = varint.encode(blk_buf[1 + sid_enc.len ..], limit) catch return;
_ = self.sendClient1Rtt(blk_buf[0 .. 1 + sid_enc.len + lim_enc.len]);
}

/// Mirror of `Server.maybeSignalDataBlocked` (#231).
fn maybeSignalDataBlocked(self: *Client) void {
const now = compat.milliTimestamp();
if (now - self.conn.blocked_signal_last_ms < blocked_signal_interval_ms) return;
self.conn.blocked_signal_last_ms = now;
var blk_buf: [16]u8 = undefined;
blk_buf[0] = 0x14; // DATA_BLOCKED
const enc = varint.encode(blk_buf[1..], self.conn.fc_send_max) catch return;
_ = self.sendClient1Rtt(blk_buf[0 .. 1 + enc.len]);
}

fn sendMaxData(self: *Client) void {
self.conn.fc_recv_max = self.conn.fc_bytes_recv + 64 * 1024 * 1024;
var buf: [16]u8 = undefined;
Expand Down Expand Up @@ -9617,11 +9683,15 @@ pub const Client = struct {
const chunk_len = @min(unsent, max_pending_stream_chunk);
const stream_limit = self.conn.peerStreamSendLimit(p.stream_id, false);
if (stream_limit > 0 and p.offset +| chunk_len > stream_limit) {
// Re-signal in case the peer's MAX_STREAM_DATA grant was
// lost — queued bytes would otherwise wedge silently (#231).
self.maybeSignalStreamDataBlocked(p.stream_id, stream_limit);
i += 1;
continue;
}
const projected: u64 = self.conn.fc_bytes_sent +| chunk_len;
if (projected > self.conn.fc_send_max) {
self.maybeSignalDataBlocked();
i += 1;
continue;
}
Expand Down Expand Up @@ -13556,15 +13626,14 @@ test "raw-app recv delivery budget: large response paces across drives, arrives
// because EVERY packet is still decrypted/parsed/ACKed/credited, the transfer
// makes progress every drive (no credit starvation / wedge).
//
// SKIPPED: this full Server<->Client socket-loopback transfer is reliable on
// macOS but stalls intermittently on Linux CI runners — a windowed-transfer
// interaction with the `rawPumpOnce` test harness's flow-control credit pump,
// NOT the delivery-budget logic. That logic is covered byte-exact by the
// deterministic unit tests in `raw_app_stream.zig` (deferral / resume / pacing
// / null-passthrough) and validated end-to-end on the live devnet. Re-enable
// once the loopback harness drives flow-control credit deterministically
// across platforms.
if (true) return error.SkipZigTest;
// RE-ENABLED (#231): the intermittent Linux-CI stall was a lost window-GRANT
// datagram (kernel loopback drop under load): once every payload byte was
// accepted into `pending_stream_sends`, the app-side send loop stopped
// calling `sendRawStreamData`, so nothing ever re-emitted a BLOCKED frame
// and the drain skipped the closed-window entries silently — a permanent
// wedge. The drain now re-signals STREAM_DATA_BLOCKED / DATA_BLOCKED
// (rate-limited, `blocked_signal_interval_ms`) so a lost grant self-heals:
// the peer answers the re-signal with a fresh MAX_(STREAM_)DATA.
const allocator = std.heap.page_allocator; // big payload: avoid checking-alloc overhead
const client = try allocator.create(Client);
defer allocator.destroy(client);
Expand Down Expand Up @@ -14861,3 +14930,55 @@ test "stream priority: drain serves higher-priority stream first (#191)" {
lb.server.unmarkStreamPriority(conn, high_sid);
try std.testing.expectEqual(@as(i32, 0), conn.streamPriority(high_sid));
}

test "pending-send drain re-signals DATA_BLOCKED after a lost grant (wedge recovery, #231)" {
// Reproduces the lost-GRANT wedge deterministically: bytes are placed
// directly into `pending_stream_sends` (bypassing the fresh-send path's
// one-shot BLOCKED emission — the same state as "fresh-path DATA_BLOCKED
// datagram lost on the wire") while the connection-level send window is
// exhausted. Without the drain's rate-limited re-signal the client is
// never told the server is blocked, grants nothing, and the transfer
// wedges forever. With it: drain re-emits DATA_BLOCKED → client answers
// MAX_DATA → the MAX_DATA arm re-drains → payload completes.
const allocator = std.testing.allocator;
const client = try allocator.create(Client);
defer allocator.destroy(client);

const lb = try rawSetupLoopback(allocator, client);
defer lb.server.deinit();
defer lb.client.deinit();

const conn = rawServerConnectedConn(lb.server).?;
const sid = try lb.server.openRawAppStream(conn);

// Exhaust conn-level send credit so the drain's fc gate blocks.
conn.fc_send_max = conn.fc_bytes_sent;

const payload = "WEDGE-RECOVERY-PAYLOAD";
try std.testing.expect(enqueuePendingStreamSend(conn, allocator, sid, 0, payload, true));

var drop = RawDrop{};
var done = false;
var iter: usize = 0;
while (iter < 2_000) : (iter += 1) {
lb.server.drainPendingStreamSends(conn);
rawPumpOnce(lb.server, lb.client, lb.server_addr, &drop);
if (lb.client.rawAppStreamFullyReceived(sid)) {
if (lb.client.rawAppRecvBuffer(sid)) |got| {
if (got.len == payload.len) {
done = true;
break;
}
}
}
}
try std.testing.expect(done);
const got = lb.client.rawAppRecvBuffer(sid) orelse return error.NoRawAppData;
try std.testing.expectEqualSlices(u8, payload, got);
// The recovery must have gone through the re-signal: the window was raised
// above the artificially-exhausted level by a client MAX_DATA.
try std.testing.expect(conn.fc_send_max > conn.fc_bytes_sent - payload.len or conn.fc_bytes_sent > 0);

try std.testing.expect(lb.client.releaseRawAppStream(sid));
try std.testing.expect(releaseRawAppStream(conn, sid, allocator));
}
Loading