diff --git a/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go b/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go index 82399c2..17635d8 100644 --- a/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go +++ b/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go @@ -329,6 +329,32 @@ func TestFabricSessionPacketTransportSplitsMixedBatchByStream(t *testing.T) { } } +func TestFabricSessionPacketTransportFanoutBoundedByConfiguredStreams(t *testing.T) { + sender := &captureFabricSessionSender{} + transport := &FabricSessionPacketTransport{ + Sender: sender, + VPNConnectionID: "vpn-1", + SendDirection: FabricDirectionClientToGateway, + StreamIDsByTrafficClass: map[string][]uint64{ + FabricTrafficClassBulk: []uint64{901, 902, 903, 904}, + }, + } + var packets [][]byte + for port := uint16(10000); port < 10100; port++ { + packets = append(packets, testIPv4TCPPacket([4]byte{10, 77, 0, 2}, [4]byte{192, 168, 200, 95}, port, 443)) + } + if err := transport.SendGatewayPacketBatch(context.Background(), packets); err != nil { + t.Fatalf("send large flow batch: %v", err) + } + if len(sender.frames) > 4 { + t.Fatalf("fanout = %d, want bounded by 4 streams", len(sender.frames)) + } + snapshot := transport.Snapshot() + if snapshot["max_batch_frame_count"].(uint64) > 4 { + t.Fatalf("max fanout not bounded: %+v", snapshot) + } +} + func TestFabricSessionPacketTransportClosesAllStreamShards(t *testing.T) { sender := &captureFabricSessionSender{} transport := &FabricSessionPacketTransport{ diff --git a/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md b/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md index f24d7fd..15d4798 100644 --- a/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md +++ b/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md @@ -406,6 +406,8 @@ not collapse onto the first packet's logical stream. fabric-session batch and requires them to remain sharded. The smoke report also exposes the mixed-batch frame fanout so regressions show up as a concrete fanout drop, not just a failed boolean. +Batch fanout is bounded by configured stream shards, so a large batch with many +flows cannot explode into unbounded fabric frames. Fabric-session packet transport snapshots now report packets per stream plus last/max batch fanout, making real multi-site load distribution measurable from gateway status.