2 Commits
Author SHA1 Message Date
justme 557b169dae web: make loss and retransmission charts obvious, draw sparse series as points and keep percent axes within 100% [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
ci/woodpecker/push/dev-backend-image Pipeline was successful
2026-10-22 10:14:38 +08:00
justme 4a9608ead4 fleet: count only admitted peers against the session limit so silent connections cannot lock the roster out 2026-10-22 10:14:38 +08:00
5 changed files with 147 additions and 15 deletions

No files matched your search

+31 -4
View File
@@ -27,7 +27,15 @@ public sealed class PeerListenerOptions
public int MaxFrameBytes { get; init; } = 8 << 20;
public int MaxSessions { get; init; } = 8;
/// <summary>
/// 同时在线的会话数上限,只数已经通过敲门的对端。每个拨号方每个地址族一个会话,所以要容得下「名册里的节点数 × 2」。
/// 之前这个上限连还没敲门的连接一起数:节点多起来之后,已建立的会话把名额占满,后到的拨号方(哪怕在名册里)一连上就被断开,
/// 而且一直连不上,重试只会得到同样的结果。
/// </summary>
public int MaxSessions { get; init; } = 64;
/// <summary>还没通过敲门的连接数上限(陌生人、扫描器、还在握手的):敲门有读超时,占着名额的时间有限,但它们不能挤占已通过敲门的会话。</summary>
public int MaxPending { get; init; } = 64;
/// <summary>敲门、TLS、挑战应答整个握手的上限。线路差的时候(RTT 好几秒、还丢包)握手本来就要很久,10 秒做不完就会一直连不上;敲门已经挡住了不认识的连接,放宽不会放大被占连接的风险。</summary>
public TimeSpan HandshakeTimeout { get; init; } = PeerTimings.Handshake;
@@ -44,6 +52,7 @@ public sealed class PeerListener
private readonly INodeLog _log;
private readonly TimeProvider _time;
private int _sessions;
private int _pending;
public PeerListener(PeerListenerOptions options, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task> onSession, INodeLog log, TimeProvider time)
{
@@ -74,9 +83,9 @@ public sealed class PeerListener
while (!ct.IsCancellationRequested)
{
TcpClient client = await listener.AcceptTcpClientAsync(ct);
if (Interlocked.Increment(ref _sessions) > _options.MaxSessions)
if (Interlocked.Increment(ref _pending) > _options.MaxPending)
{
Interlocked.Decrement(ref _sessions);
Interlocked.Decrement(ref _pending);
client.Dispose();
continue;
}
@@ -97,6 +106,7 @@ public sealed class PeerListener
private async Task HandleAsync(TcpClient client, CancellationToken ct)
{
bool admitted = false;
bool counted = false;
string phase = "knock";
long started = _time.GetTimestamp();
EndPoint? remote = client.Client.RemoteEndPoint;
@@ -113,6 +123,15 @@ public sealed class PeerListener
}
admitted = true;
Interlocked.Decrement(ref _pending);
if (Interlocked.Increment(ref _sessions) > _options.MaxSessions)
{
Interlocked.Decrement(ref _sessions);
_log.Warn("peer session limit reached", ("remote", remote), ("limit", _options.MaxSessions));
return;
}
counted = true;
phase = "tls";
_log.Info("peer knock accepted", ("remote", remote), ("phase", phase));
var tls = new SslStream(stream, leaveInnerStreamOpen: false);
@@ -152,7 +171,15 @@ public sealed class PeerListener
finally
{
client.Dispose();
Interlocked.Decrement(ref _sessions);
if (!admitted)
{
Interlocked.Decrement(ref _pending);
}
if (counted)
{
Interlocked.Decrement(ref _sessions);
}
}
}
+61 -1
View File
@@ -303,7 +303,7 @@ public sealed class LinkTests
private readonly CancellationTokenSource _stop = new();
private readonly NodeIdentity _identity;
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null, int maxSessions = 64)
{
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
_identity = NodeIdentity.FromPkcs8(Certificate.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey());
@@ -314,6 +314,7 @@ public sealed class LinkTests
Certificate = Certificate,
LookupPeerKey = lookup,
Knock = new KnockGate(_identity, () => KnockPeers, TimeProvider.System),
MaxSessions = maxSessions,
},
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
NullNodeLog.Instance, TimeProvider.System);
@@ -462,6 +463,65 @@ public sealed class LinkTests
await ok.DisposeAsync();
}
/// <summary>名额只数通过敲门的会话:一堆还没敲门的连接占着,名册里的拨号方照样连得上(之前它们共用一个上限,节点多了之后后到的拨号方被永远拒绝)。</summary>
[Fact]
public async Task PeerListener_SessionLimitCountsOnlyAdmittedPeers_SoSilentConnectionsCannotLockOutTheRoster()
{
using NodeIdentity first = NodeIdentity.Generate();
using NodeIdentity second = NodeIdentity.Generate();
using NodeIdentity third = NodeIdentity.Generate();
await using var agent = new PeerFixture(id => id == "dlr_1" ? first.PublicKeySpki : id == "dlr_2" ? second.PublicKeySpki : third.PublicKeySpki, maxSessions: 2);
agent.KnockPeers.AddRange([first.PublicKeySpki, second.PublicKeySpki, third.PublicKeySpki]);
await agent.Listener.Started.Task;
// 20 个什么都不说的连接:占着待敲门的名额,但不占会话名额
var silent = new List<TcpClient>();
try
{
for (int i = 0; i < 20; i++)
{
var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
silent.Add(client);
}
(_, TlsFrameTransport a) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, first, "dlr_1", null, 1 << 20, TimeProvider.System, default);
(_, TlsFrameTransport b) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, second, "dlr_2", null, 1 << 20, TimeProvider.System, default);
// 会话名额(2)满了:第三个在名册里的拨号方被拒绝,一个会话结束之后又能连上
await Assert.ThrowsAnyAsync<Exception>(async () =>
{
(_, TlsFrameTransport refused) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, third, "dlr_3", null, 1 << 20, TimeProvider.System, default);
await refused.DisposeAsync();
});
await a.DisposeAsync();
bool reconnected = false;
for (int attempt = 0; attempt < 50 && !reconnected; attempt++)
{
try
{
(_, TlsFrameTransport again) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, third, "dlr_3", null, 1 << 20, TimeProvider.System, default);
await again.DisposeAsync();
reconnected = true;
}
catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException or InvalidOperationException)
{
await Task.Delay(100);
}
}
Assert.True(reconnected);
await b.DisposeAsync();
}
finally
{
foreach (TcpClient client in silent)
{
client.Dispose();
}
}
}
[Fact]
public async Task PeerListener_WritesNothingToStrangers_NotEvenATlsServerHello()
{
+6 -4
View File
@@ -55,6 +55,8 @@ interface ChartDef {
group: MetricGroup
/** 占满一行。 */
wide?: boolean
/** 没有才是好的指标:整段为 0 时图上显示的说明 */
quiet?: string
}
const GROUPS = [
@@ -66,10 +68,10 @@ const GROUPS = [
const CHARTS: ChartDef[] = [
{ id: 'rtt', title: '延迟', group: 'quic', unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
{ id: 'jitter', title: '抖动(p95)', group: 'quic', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '去程', color: '--chart-2' }, { key: 'jitter.down.p95', label: '回程', color: '--chart-1' }] },
{ id: 'loss', title: '丢包', group: 'quic', unit: '%', series: [{ key: 'loss.up', label: '去程', color: '--chart-2' }, { key: 'loss.down', label: '回程', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
{ id: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', series: [{ key: 'loss.run.up_max', label: '去程', color: '--chart-2' }, { key: 'loss.run.down_max', label: '回程', color: '--chart-3' }] },
{ id: 'loss', title: '丢包', group: 'quic', unit: '%', fill: true, quiet: '✓ 这段时间没有丢包', series: [{ key: 'loss.up', label: '去程', color: '--chart-2' }, { key: 'loss.down', label: '回程', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
{ id: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', fill: true, quiet: '✓ 这段时间没有连续丢包', series: [{ key: 'loss.run.up_max', label: '去程', color: '--chart-2' }, { key: 'loss.run.down_max', label: '回程', color: '--chart-3' }] },
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
{ id: 'retrans', title: '重传率', group: 'tcp-connect', unit: '%', series: [{ key: 'tcp.retrans.up', label: '去程', color: '--chart-2' }, { key: 'tcp.retrans.down', label: '回程', color: '--chart-3' }, { key: 'tcp.retrans', label: '合计', color: '--chart-1', dash: [4, 3] }] },
{ id: 'retrans', title: '重传率', group: 'tcp-connect', unit: '%', fill: true, quiet: '✓ 这段时间没有重传', series: [{ key: 'tcp.retrans.up', label: '去程', color: '--chart-2' }, { key: 'tcp.retrans.down', label: '回程', color: '--chart-3' }, { key: 'tcp.retrans', label: '合计', color: '--chart-1', dash: [4, 3] }] },
{ id: 'connect-time', title: '建连耗时(p95)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] },
{ id: 'connect-ok', title: '建连成功率', group: 'tcp-connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] },
{ id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] },
@@ -177,7 +179,7 @@ export function Line() {
]
: raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill }))
if (!list.some((x) => x.values.some((v) => v !== null))) return null
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} gaps={gaps} zero={def.zero !== false} height={metricGroup === 'quic' ? 220 : def.wide ? 230 : 190} /> }
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} quiet={def.quiet} gaps={gaps} zero={def.zero !== false} height={metricGroup === 'quic' ? 220 : def.wide ? 230 : 190} /> }
})
return { group, cards: cards.filter((c): c is NonNullable<typeof c> => c !== null) }
}).filter((g) => g.cards.length > 0)
+32 -6
View File
@@ -26,6 +26,8 @@ interface Props {
gaps?: { from: number; to: number }[]
/** 为真时 `t` 是「从起点算起的毫秒数」而不是 Unix 毫秒:横轴显示 0:00、0:15…,用来把两段时间叠在一起比。 */
offset?: boolean
/** 丢包、重传这类「没有才是好」的图:整段都是 0(或没有值)时,在图上盖一句说明,而不是留一条贴着底边的平线。 */
quiet?: string
}
/** 偏移量(毫秒)→ h:mm:ss / m:ss。 */
@@ -38,6 +40,24 @@ export function fmtOffset(ms: number): string {
return h > 0 ? `${h}:${two(m)}${sec ? ':' + two(sec) : ''}` : `${m}:${two(sec)}`
}
/** 有值的格子不到三分之一(而且至少有一个)算稀疏。 */
function isSparse(values: (number | null)[]): boolean {
const filled = values.filter((v) => v !== null).length
return filled > 0 && filled < values.length / 3
}
/**
* Y 轴范围:默认从 0 开始,上面留 8%。百分比的轴不会超过 100%(成功率贴着 100% 时不再出现 100.75% 这种刻度),
* 一条平线(最大等于最小)也给出可读的范围。
*/
function yRange(min: number, max: number, zero: boolean, unit: string): [number, number] {
const percent = unit === '%'
const lo = zero ? Math.min(0, min) : percent ? Math.max(0, min - Math.max(1, (max - min) * 0.2)) : min
let hi = max <= min ? min + (percent ? Math.max(1, Math.abs(min) * 0.01) : 1) : max + (max - min) * 0.08
if (percent && max <= 100) hi = Math.min(100, hi)
return [lo, hi > lo ? hi : lo + 1]
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
@@ -61,7 +81,7 @@ function formatValue(v: number | null | undefined, unit: string): string {
* - 在图上拖动选一段放大预览,双击或点「还原」恢复;数据刷新时保持放大的区间;
* - 鼠标悬停显示这个时刻每条线的值。
*/
export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [], offset = false }: Props) {
export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [], offset = false, quiet }: Props) {
const host = useRef<HTMLDivElement>(null)
const tip = useRef<HTMLDivElement>(null)
const plot = useRef<uPlot | null>(null)
@@ -70,7 +90,10 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
// 数据变了(轮询、实时)只更新数据,不重建图:重建会闪一下,还会丢掉鼠标位置
const latest = useRef({ t, series, gaps })
latest.current = { t, series, gaps }
const structure = JSON.stringify([series.map((x) => [x.label, x.color, x.dash, x.fill, x.faint]), unit, zero, offset, height])
// 稀疏的序列(例如每分钟只有几次的建连):大部分格子是空的,单独的一个点画成线就看不见,所以画出点并把点连起来
const sparse = series.map((x) => isSparse(x.values))
const allQuiet = quiet !== undefined && series.filter((x) => !x.faint).every((x) => x.values.every((v) => v === null || v <= 0))
const structure = JSON.stringify([series.map((x, i) => [x.label, x.color, x.dash, x.fill, x.faint, sparse[i]]), unit, zero, offset, height])
const dataOf = (): AlignedData => [latest.current.t.map((v) => v / 1000), ...latest.current.series.map((x) => x.values.map((v) => (v === null ? null : v)))]
const reset = useCallback(() => {
@@ -97,7 +120,7 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
select: { show: true, left: 0, top: 0, width: 0, height: 0 },
scales: {
x: { time: !offset, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) },
y: { range: (_u, min, max) => [zero ? Math.min(0, min) : min, max <= min ? min + 1 : max + (max - min) * 0.08] },
y: { range: (_u, min, max) => yRange(min, max, zero, unit) },
},
axes: [
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font, ...(offset ? { values: (_u: uPlot, ticks: number[]) => ticks.map((v) => fmtOffset(v * 1000)) } : {}) },
@@ -113,8 +136,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
width: s.faint ? 1 : 1.8,
dash: s.dash,
fill: s.fill ? color + '22' : undefined,
spanGaps: false,
points: { show: false },
spanGaps: sparse[series.indexOf(s)],
points: { show: sparse[series.indexOf(s)], size: 5 },
}
}),
],
@@ -218,7 +241,10 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
)}
</span>
</div>
<div ref={host} className="chart" />
<div className="chart-body">
<div ref={host} className="chart" />
{allQuiet ? <div className="chart-quiet">{quiet}</div> : null}
</div>
<div ref={tip} className="chart-tip" />
</div>
)
+17
View File
@@ -1088,3 +1088,20 @@ pre.code {
align-items: center;
gap: 4px;
}
/* 「没有才是好」的图整段为 0 时:盖一句说明,图的坐标轴留着,说明在绘图区中间 */
.chart-body {
position: relative;
}
.chart-quiet {
position: absolute;
inset: 0 0 28px 56px;
display: flex;
align-items: center;
justify-content: center;
color: var(--app-ok);
font-size: 14px;
font-weight: 600;
pointer-events: none;
}