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
49 changes: 49 additions & 0 deletions .github/workflows/soak.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: soak

# 长期观察:每夜以 600 秒跑浸泡/混沌套件(ubuntu + windows 双平台)。
# 覆盖重连竞速(Soak_ReconnectRacing_LongRun,含状态机转移合法性与会话编号线性化的
# 全程校验)、随机故障混沌与长流完整性。失败会通过 GitHub 通知提醒维护者。
# 手动触发:workflow_dispatch 可指定时长(分钟数)。

on:
schedule:
- cron: '0 19 * * *' # UTC 19:00 = 北京时间 03:00
workflow_dispatch:
inputs:
seconds:
description: '浸泡时长(秒)'
required: false
default: '600'

permissions:
contents: read

jobs:
soak:
name: soak (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]

steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
10.0.x

- name: Build (Release)
run: dotnet build StreamFrame.slnx -c Release

- name: Soak tests
env:
STREAMFRAME_SOAK_SECONDS: ${{ inputs.seconds || '600' }}
run: >-
dotnet test test/StreamFrame.Tests/StreamFrame.Tests.csproj -c Release --no-build
--filter "FullyQualifiedName~Soak"
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- **大报文优化与归因**:发送编码缓冲自适应初始尺寸(按连接记忆上一帧高水位、封顶 1MB——稳态尺寸相近的协议不再从 1KB 起爬几何增长梯子)。新增 64KB 三 codec 归因基准(byte[] 透传 / string+span / string+分配式),结论:框架纯成本距裸 TCP 仅 ≈20–30%,其余为 codec 写法税(span 重载可消除中间数组)与 string 消息固有税(UTF-16 物化 ≈2× 报文)。README 双语新增"大报文指南",bench/README 附归因表。

### 修复
- **接受循环代次门控(#47 阶段二,竞速泄漏监听器 bug)**:用户重连与自动重连竞速产生多个并发 StartAsync 接受循环时,被取代的"僵尸"循环完成 accept 后可能看到 `_server` 已被新循环替换而跳过单客户端的监听器关闭——**泄漏一个仍在监听但永无人 accept 的 socket**,客户端 SYN 进入无人消费的 backlog 后 `ConnectAsync` 永久挂起(远比"拒绝"严重)。新增 `_acceptLoopId` 代次:每次 StartCore 递增,被取代的循环在取得 `_acceptLock` 后静默退出,监听器的创建/关闭/accept 只归属当代循环。由新增的重连竞速混沌(`Soak_ReconnectRacing_LongRun`,60% 动作占比并发竞速)复现并锁定。
- **被动端监听韧性加固(#47,防御性)**:排查确认原始"用户 `Reconnect()` 竞速楔死监听"**无法复现**(顺序/并发两组确定性复现 + 混沌套件恢复该动作后多轮全绿,此前观察到的楔死系当时测试自身缺陷),但修复排查中识别的两个真实弱点——① 接受循环的重试延迟改为锁外等待(旧实现整个重试循环持有 `_acceptLock`,绑定/接受持续失败期间会堵死其它接受循环);② 监听 socket 设置 `SO_REUSEADDR`(服务端主动关闭后立即重绑不受 2MSL/TIME_WAIT 限制;Windows 实测宽松,Linux 上无此选项会遇 `EADDRINUSE`,一并设置保持跨平台一致)。新增确定性回归测试(8 轮服务端主动重连,端口须秒级可重新接受),混沌套件恢复用户重连动作。

## [2.3.1] - 2026-08-28
Expand Down
32 changes: 27 additions & 5 deletions src/StreamFrame/Connection/StreamConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ public string? RemoteIpAddress
private readonly object _sessionGate = new();
#endif
private Pipe? _pipe;

/// <summary>
/// 接受循环代次(#47):每次 StartCore 递增。用户重连与自动重连竞速会产生多个并发的
/// StartAsync 接受循环——旧的"僵尸"循环完成 accept 后可能看到 _server 已被新循环替换,
/// 跳过单客户端的监听器关闭,泄漏一个"仍在监听但永无人 accept"的 socket(客户端 SYN
/// 进入无人消费的 backlog,ConnectAsync 永久挂起)。代次门控让被取代的循环在取得
/// _acceptLock 后静默退出:监听器的创建/关闭/accept 只归属当代循环。
/// </summary>
private int _acceptLoopId;
private CancellationTokenSource? _sessionCts;
private Task? _receiveTask;
private Task? _decodeTask;
Expand Down Expand Up @@ -209,9 +218,9 @@ public void Start(CancellationToken ct)

/// <summary>内部启动入口(重连流程复用),不做一次性启动校验。</summary>
private void StartCore(CancellationToken ct)
=> _ = StartAsync(ct);
=> _ = StartAsync(ct, Interlocked.Increment(ref _acceptLoopId));

private async Task StartAsync(CancellationToken ct)
private async Task StartAsync(CancellationToken ct, int loopId)
{
var connected = false;
try
Expand All @@ -221,7 +230,13 @@ private async Task StartAsync(CancellationToken ct)
CommunicationStateChanging(ConnectionState.Connecting);
try
{
_socket = IsActive ? await ConnectAsync(ct).ConfigureAwait(false) : await AcceptAsync(ct).ConfigureAwait(false);
if (!IsActive && Volatile.Read(ref _acceptLoopId) != loopId)
return; // 已被更新的接受循环取代:静默退出,不再竞争 accept

_socket = IsActive ? await ConnectAsync(ct).ConfigureAwait(false) : await AcceptAsync(ct, loopId).ConfigureAwait(false);
if (_socket is null)
return; // 排队等到锁时已被取代(AcceptAsync 内部的代次检查)

_retryScheduler.Reset();
CommunicationStateChanging(ConnectionState.Connected);
connected = true;
Expand Down Expand Up @@ -280,7 +295,7 @@ private async Task<Socket> ConnectAsync(CancellationToken ct)
return socket;
}

private async Task<Socket> AcceptAsync(CancellationToken ct)
private async Task<Socket?> AcceptAsync(CancellationToken ct, int loopId)
{
while (true)
{
Expand All @@ -293,6 +308,12 @@ private async Task<Socket> AcceptAsync(CancellationToken ct)
int? retryDelay = null;
try
{
// 代次门控(#47 阶段二):排队等到锁时若已被更新的循环取代,静默退出——
// 监听器的创建/关闭/accept 只归属当代循环,杜绝僵尸循环泄漏监听器
// (泄漏的监听器无人 accept,客户端 SYN 进入 backlog 后 ConnectAsync 永久挂起)
if (Volatile.Read(ref _acceptLoopId) != loopId)
return null;

if (_server == null)
InitServer();

Expand All @@ -318,7 +339,8 @@ private async Task<Socket> AcceptAsync(CancellationToken ct)
ConfigureSocket(accepted);

// 单客户端模式:accept 到第一个客户端后关闭监听 socket,
// 后续连接在 TCP 层被立即拒绝。
// 后续连接在 TCP 层被立即拒绝。代次门控保证此处 _server 属于当代循环,
// 不会误关/漏关其它循环的监听器。
if (_options.AcceptFirstClientOnly && _server != null)
{
_server.Dispose();
Expand Down
8 changes: 4 additions & 4 deletions test/StreamFrame.Tests/ReconnectWedgeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ private static async Task ConnectWithRetryAsync(TcpClient client, int port, int
}
}

private static async Task WaitConnectedAsync(StreamConnection<string> server, int timeoutMs = 5000)
private static async Task WaitConnectedAsync(StreamConnection<string> server, int timeoutMs = 10_000)
{
var deadline = TestClock.TickCount64 + timeoutMs;
while (TestClock.TickCount64 < deadline && server.State != ConnectionState.Connected)
Expand All @@ -93,22 +93,22 @@ public async Task RapidServerInitiatedReconnects_PortStaysAcceptable()
using (var client = new TcpClient())
{
await ConnectWithRetryAsync(client, port);
await WaitConnectedAsync(server);
await WaitConnectedAsync(server, timeoutMs: 10_000);
}

// 等自动重连完成、再次形成会话
using (var client = new TcpClient())
{
await ConnectWithRetryAsync(client, port);
await WaitConnectedAsync(server, 8000);
await WaitConnectedAsync(server, 15_000);

// 两路并发触发(#47 的竞速形态):杀对端(自动重连)与用户显式 Reconnect() 竞争
var kill = Task.Run(client.Dispose);
server.Reconnect();
await kill;
}

var rebindDeadline = TestClock.TickCount64 + 10_000; // 修复目标:秒级可用(10s 为宽松上限)
var rebindDeadline = TestClock.TickCount64 + 20_000; // 修复目标:秒级可用(20s 上限:并行负载下偶发慢收敛,曾以 1/15 概率闪失)
while (TestClock.TickCount64 < rebindDeadline)
{
if (await TryConnectAsync(port))
Expand Down
Loading
Loading