del: 将go版本的内容删除,只保留处理日志功能

This commit is contained in:
2026-03-30 15:57:36 +08:00
parent 88ed9e2707
commit 24467c04c0
117 changed files with 142 additions and 13890 deletions

View File

@@ -0,0 +1,166 @@
package latencylog
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"omnisocketgo/cmd/internal/protocol"
)
const (
NodeRolePeer = "peer" //客户端节点
NodeRoleServer = "server" //云端转发节点
)
// 记录的消息事件的类型常量。
const (
EventAAppPrepBegin = "A_APP_PREP_BEGIN" // A 端应用开始准备这条消息
EventATXSched = "A_TX_SCHED" // A 端进入 Linux qdisc 之前
EventATXSoftware = "A_TX_SOFTWARE" // A 端即将交给网卡驱动
EventATXHardware = "A_TX_HARDWARE" // A 端网卡真正发出到物理介质
EventBRXHardware = "B_RX_HARDWARE" // B 端网卡真正从物理介质收到
EventBRXSoftware = "B_RX_SOFTWARE" // B 端驱动把数据交给 Linux 接收栈
EventBAppRecv = "B_APP_RECV" // B 端应用真正读到完整消息
EventBPersistBegin = "B_PERSIST_BEGIN" // B 端开始写盘
EventBPersistEnd = "B_PERSIST_END" // B 端写盘完成
EventSendHandoffBegin = "send_handoff_begin" // 调试事件:应用把消息交给传输层开始
EventSendHandoffEnd = "send_handoff_end" // 调试事件:应用把消息交给传输层结束
)
// Event 是一条时延时间戳日志记录。
type Event struct {
TsUnixNano int64 `json:"ts_unix_nano"`
NodeRole string `json:"node_role"`
NodeID string `json:"node_id"`
Event string `json:"event"`
MessageType protocol.MessageType `json:"message_type"`
MessageID uint64 `json:"message_id"`
From string `json:"from"`
To string `json:"to"`
FileName string `json:"file_name,omitempty"`
BodySize int `json:"body_size"`
}
// Logger 负责接收事件并将其写入外部介质。
type Logger interface {
LogEvent(Event) error
}
// NoopLogger 是默认的空实现。
type NoopLogger struct{}
// LogEvent 对空日志实现始终返回 nil。
func (NoopLogger) LogEvent(Event) error {
return nil
}
// JSONLLogger 以 JSONL 形式追加写日志文件。
type JSONLLogger struct {
mu sync.Mutex
closeOnce sync.Once
closeErr error
file *os.File
}
// NewJSONLLogger 创建一个线程安全的 JSONL 文件日志器。
func NewJSONLLogger(path string) (*JSONLLogger, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil, err
}
return &JSONLLogger{file: file}, nil
}
// LogEvent 以单行 JSON 的形式追加一条事件。
func (l *JSONLLogger) LogEvent(event Event) error {
line, err := json.Marshal(event)
if err != nil {
return err
}
l.mu.Lock()
defer l.mu.Unlock()
if _, err := l.file.Write(append(line, '\n')); err != nil {
return err
}
return nil
}
// Close 关闭底层文件;重复调用是安全的。
func (l *JSONLLogger) Close() error {
l.closeOnce.Do(func() {
l.closeErr = l.file.Close()
})
return l.closeErr
}
// IsBusinessMessage 判断消息是否属于要参与 A-C-B 时延分析的业务消息。
func IsBusinessMessage(msg protocol.Message) bool {
switch msg.Type {
case protocol.MessageTypeText, protocol.MessageTypeFile:
return true
default:
return false
}
}
// NewMessageEvent 用当前 UTC 时间为一条业务消息构造事件。
func NewMessageEvent(nodeRole, nodeID, eventName string, msg protocol.Message) Event {
return NewMessageEventAt(time.Now().UTC().UnixNano(), nodeRole, nodeID, eventName, msg)
}
// NewMessageEventAt 用指定的 UnixNano 时间为一条业务消息构造事件。
func NewMessageEventAt(tsUnixNano int64, nodeRole, nodeID, eventName string, msg protocol.Message) Event {
return Event{
TsUnixNano: tsUnixNano,
NodeRole: nodeRole,
NodeID: nodeID,
Event: eventName,
MessageType: msg.Type,
MessageID: msg.ID,
From: msg.From,
To: msg.To,
FileName: msg.FileName,
BodySize: len(msg.Body),
}
}
// LogBestEffort 写一条事件,失败时静默忽略,避免打断主收发流程。
func LogBestEffort(logger Logger, event Event) {
if logger == nil {
return
}
_ = logger.LogEvent(event)
}
// LogMessageEvent 为业务消息构造并写入一条事件。
func LogMessageEvent(logger Logger, nodeRole, nodeID, eventName string, msg protocol.Message) {
if !IsBusinessMessage(msg) {
return
}
LogBestEffort(logger, NewMessageEvent(nodeRole, nodeID, eventName, msg))
}
// LogMessageEventAt 为业务消息写入一条指定时间戳的事件。
func LogMessageEventAt(logger Logger, nodeRole, nodeID, eventName string, tsUnixNano int64, msg protocol.Message) {
if !IsBusinessMessage(msg) {
return
}
LogBestEffort(logger, NewMessageEventAt(tsUnixNano, nodeRole, nodeID, eventName, msg))
}

View File

@@ -0,0 +1,131 @@
package latencylog
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"omnisocketgo/cmd/internal/protocol"
)
func TestJSONLLoggerWritesOneEventPerLine(t *testing.T) {
path := filepath.Join(t.TempDir(), "latency.jsonl")
logger, err := NewJSONLLogger(path)
if err != nil {
t.Fatalf("NewJSONLLogger() error = %v", err)
}
t.Cleanup(func() {
_ = logger.Close()
})
event := Event{
TsUnixNano: 123,
NodeRole: NodeRolePeer,
NodeID: "peer-a",
Event: EventAAppPrepBegin,
MessageType: protocol.MessageTypeText,
MessageID: 1,
From: "peer-a",
To: "peer-b",
BodySize: 5,
}
if err := logger.LogEvent(event); err != nil {
t.Fatalf("LogEvent() error = %v", err)
}
file, err := os.Open(path)
if err != nil {
t.Fatalf("os.Open() error = %v", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
if !scanner.Scan() {
t.Fatal("expected one JSONL line, got none")
}
var got Event
if err := json.Unmarshal(scanner.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got != event {
t.Fatalf("event mismatch: got %+v want %+v", got, event)
}
if scanner.Scan() {
t.Fatal("expected exactly one JSONL line")
}
if err := scanner.Err(); err != nil {
t.Fatalf("scanner.Err() = %v", err)
}
}
func TestJSONLLoggerHandlesConcurrentWrites(t *testing.T) {
path := filepath.Join(t.TempDir(), "latency.jsonl")
logger, err := NewJSONLLogger(path)
if err != nil {
t.Fatalf("NewJSONLLogger() error = %v", err)
}
t.Cleanup(func() {
_ = logger.Close()
})
const total = 32
var wg sync.WaitGroup
for i := 0; i < total; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
err := logger.LogEvent(Event{
TsUnixNano: int64(i + 1),
NodeRole: NodeRoleServer,
NodeID: protocol.ServerPeerID,
Event: EventBAppRecv,
MessageType: protocol.MessageTypeFile,
MessageID: uint64(i + 1),
From: "peer-a",
To: "peer-b",
FileName: "payload.bin",
BodySize: 3,
})
if err != nil {
t.Errorf("LogEvent() error = %v", err)
}
}()
}
wg.Wait()
file, err := os.Open(path)
if err != nil {
t.Fatalf("os.Open() error = %v", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
var count int
seen := make(map[uint64]bool, total)
for scanner.Scan() {
var event Event
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
count++
seen[event.MessageID] = true
}
if err := scanner.Err(); err != nil {
t.Fatalf("scanner.Err() = %v", err)
}
if count != total {
t.Fatalf("line count = %d, want %d", count, total)
}
if len(seen) != total {
t.Fatalf("unique message count = %d, want %d", len(seen), total)
}
}

View File

@@ -0,0 +1,457 @@
package latencylog
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"omnisocketgo/cmd/internal/protocol"
)
// Summary 是针对单条消息的时延的规则列表。
var requiredTimestampNames = []string{
EventAAppPrepBegin, // A 端应用开始准备这条消息
EventATXSched, // A 端进入 Linux qdisc 之前
EventATXSoftware, // A 端即将交给网卡驱动
EventBRXSoftware, // B 端网卡驱动把数据交给 Linux 接收栈
EventBAppRecv, // B 端应用真正读到完整消息
EventBPersistEnd, // B 端写盘完成
}
// Summary 是针对单条消息的时延整理结果。
type Summary struct {
MessageType protocol.MessageType `json:"message_type"` //消息类型
MessageID uint64 `json:"message_id"` //消息ID
From string `json:"from"` //发送方
To string `json:"to"` //接收方
FileName string `json:"file_name,omitempty"` //文件名(仅文件消息)
BodySize int `json:"body_size"` //消息体大小(字节数)
Timestamps map[string]int64 `json:"timestamps"` //事件时间戳key 是事件名称value 是 UnixNano 时间戳
AProcessingLatencyNS *int64 `json:"a_processing_latency_ns,omitempty"` // A 处理时延A_TX_SCHED - A_APP_PREP_BEGIN
AQueueLatencyNS *int64 `json:"a_queue_latency_ns,omitempty"` // A 排队时延A_TX_SOFTWARE - A_TX_SCHED
ABTransportPropagationNS *int64 `json:"a_b_transport_propagation_ns,omitempty"` // A-B 传输+传播时延近似B_APP_RECV - A_TX_SOFTWARE
BKernelReceivePathLatencyNS *int64 `json:"b_kernel_receive_path_latency_ns,omitempty"` // B 内核接收路径近似B_APP_RECV - B_RX_SOFTWARE
BProcessingLatencyNS *int64 `json:"b_processing_latency_ns,omitempty"` // B 处理时延B_PERSIST_END - B_APP_RECV
EndToEndLatencyNS *int64 `json:"end_to_end_latency_ns,omitempty"` // 端到端时延B_PERSIST_END - A_APP_PREP_BEGIN
AProcessingBitrateBPS *float64 `json:"a_processing_bitrate_bps,omitempty"` // A 处理阶段近似比特率:(BodySize * 8) / A 处理时延(秒)
ABTransportPropagationBitrateBPS *float64 `json:"a_b_transport_propagation_bitrate_bps,omitempty"` // A-B 传输+传播阶段近似比特率:(BodySize * 8) / A-B 传输+传播时延(秒)
EndToEndBitrateBPS *float64 `json:"end_to_end_bitrate_bps,omitempty"` // 端到端近似比特率:(BodySize * 8) / 端到端时延(秒)
ApproxRTTNS *int64 `json:"approx_rtt_ns,omitempty"` // 近似 RTT首条反向应答的 B_APP_RECV - 当前请求的 A_TX_SOFTWARE
MissingTimestamps []string `json:"missing_timestamps,omitempty"` // 缺失的时间戳列表,包含 requiredTimestampNames 中但在原始事件中没有的事件名称
}
// LoadEventsFromFiles 从JSONL 原始日志文件中加载事件。
type messageKey struct {
MessageType protocol.MessageType //消息类型
MessageID uint64 //消息ID
From string //发送方
To string //接收方
}
// LoadEventsFromFiles 从多个 JSONL 原始日志文件中加载事件。
func LoadEventsFromFiles(paths []string) ([]Event, error) {
var events []Event
for _, path := range paths {
fileEvents, err := LoadEventsFromFile(path)
if err != nil {
return nil, err
}
events = append(events, fileEvents...)
}
return events, nil
}
// LoadEventsFromFilesWithSharedMaxOffset 从多个 JSONL 原始日志文件中加载事件,
// 并按每个输入文件的最大 message_id 计算共享截断点。
func LoadEventsFromFilesWithSharedMaxOffset(paths []string, sharedMaxOffset uint64) ([]Event, *uint64, error) {
eventsByFile := make([][]Event, 0, len(paths))
var minMaxMessageID uint64
hasSharedMax := false
for _, path := range paths {
fileEvents, err := LoadEventsFromFile(path)
if err != nil {
return nil, nil, err
}
eventsByFile = append(eventsByFile, fileEvents)
fileMaxMessageID, ok := maxBusinessMessageID(fileEvents)
if !ok {
return nil, nil, nil
}
if !hasSharedMax || fileMaxMessageID < minMaxMessageID {
minMaxMessageID = fileMaxMessageID
hasSharedMax = true
}
}
if !hasSharedMax {
return nil, nil, nil
}
cutoff, ok := subtractUint64(minMaxMessageID, sharedMaxOffset)
if !ok {
return []Event{}, nil, nil
}
var events []Event
for _, fileEvents := range eventsByFile {
events = append(events, filterEventsByMaxMessageID(fileEvents, cutoff)...)
}
return events, &cutoff, nil
}
// LoadEventsFromFile 从单个 JSONL 原始日志文件中加载事件。
func LoadEventsFromFile(path string) ([]Event, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("latencylog: open raw log %s: %w", path, err)
}
defer file.Close()
var events []Event
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if len(scanner.Bytes()) == 0 {
continue
}
var event Event
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { //解析 JSONL 行失败,返回错误
return nil, fmt.Errorf("latencylog: decode event from %s: %w", path, err)
}
events = append(events, event)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("latencylog: scan raw log %s: %w", path, err)
}
return events, nil
}
// SummarizeEvents 将原始事件整理成按消息分组的时延结果。
func SummarizeEvents(events []Event) []Summary {
grouped := make(map[messageKey]*Summary)
for _, event := range events {
if !IsBusinessEvent(event) {
continue
}
key := messageKey{
MessageType: event.MessageType,
MessageID: event.MessageID,
From: event.From,
To: event.To,
}
summary, ok := grouped[key]
if !ok {
summary = &Summary{
MessageType: event.MessageType,
MessageID: event.MessageID,
From: event.From,
To: event.To,
FileName: event.FileName,
BodySize: event.BodySize,
Timestamps: make(map[string]int64),
}
grouped[key] = summary
}
if summary.FileName == "" {
summary.FileName = event.FileName
}
if event.BodySize > 0 {
summary.BodySize = event.BodySize
}
if existing, exists := summary.Timestamps[event.Event]; !exists || event.TsUnixNano < existing {
summary.Timestamps[event.Event] = event.TsUnixNano
}
}
summaryPointers := make([]*Summary, 0, len(grouped))
for _, summary := range grouped {
completeSummary(summary) //补全时延指标和缺失时间戳信息
summaryPointers = append(summaryPointers, summary)
}
assignApproxRTTs(summaryPointers)
summaries := make([]Summary, 0, len(summaryPointers))
for _, summary := range summaryPointers {
summaries = append(summaries, *summary)
}
//对整理结果进行排序,先按发送方、再按接收方、再按消息 ID、最后按消息类型排序保证输出的稳定性和可读性。
sort.Slice(summaries, func(i, j int) bool {
if summaries[i].From != summaries[j].From {
return summaries[i].From < summaries[j].From
}
if summaries[i].To != summaries[j].To {
return summaries[i].To < summaries[j].To
}
if summaries[i].MessageID != summaries[j].MessageID {
return summaries[i].MessageID < summaries[j].MessageID
}
return summaries[i].MessageType < summaries[j].MessageType
})
return summaries
}
// WriteSummariesJSONL 将整理结果写成 JSONL 汇总文件。
func WriteSummariesJSONL(path string, summaries []Summary) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("latencylog: create summary dir for %s: %w", path, err)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("latencylog: open summary file %s: %w", path, err)
}
defer file.Close()
writer := bufio.NewWriter(file)
for _, summary := range summaries { //将每条整理结果编码成 JSONL 行并写入文件
line, err := json.Marshal(summary)
if err != nil {
return fmt.Errorf("latencylog: encode summary for message %d: %w", summary.MessageID, err)
}
if _, err := writer.Write(append(line, '\n')); err != nil {
return fmt.Errorf("latencylog: write summary file %s: %w", path, err)
}
}
if err := writer.Flush(); err != nil { //将缓冲区内容写入文件
return fmt.Errorf("latencylog: flush summary file %s: %w", path, err)
}
return nil
}
// completeSummary 根据事件时间戳计算时延指标,并找出缺失的时间戳。
func completeSummary(summary *Summary) {
summary.MissingTimestamps = missingTimestampNames(summary.Timestamps)
if value := subtractIfPresent(summary.Timestamps, EventATXSched, EventAAppPrepBegin); value != nil {
summary.AProcessingLatencyNS = value
}
if value := subtractIfPresent(summary.Timestamps, EventATXSoftware, EventATXSched); value != nil {
summary.AQueueLatencyNS = value
}
if value := subtractIfPresent(summary.Timestamps, EventBAppRecv, EventATXSoftware); value != nil {
summary.ABTransportPropagationNS = value
}
if value := subtractIfPresent(summary.Timestamps, EventBAppRecv, EventBRXSoftware); value != nil {
summary.BKernelReceivePathLatencyNS = value
}
if value := subtractIfPresent(summary.Timestamps, EventBPersistEnd, EventBAppRecv); value != nil {
summary.BProcessingLatencyNS = value
}
if value := subtractIfPresent(summary.Timestamps, EventBPersistEnd, EventAAppPrepBegin); value != nil {
summary.EndToEndLatencyNS = value
}
summary.AProcessingBitrateBPS = calculateBitrateBPS(summary.BodySize, summary.AProcessingLatencyNS)
summary.ABTransportPropagationBitrateBPS = calculateBitrateBPS(summary.BodySize, summary.ABTransportPropagationNS)
summary.EndToEndBitrateBPS = calculateBitrateBPS(summary.BodySize, summary.EndToEndLatencyNS)
}
type routeKey struct {
From string
To string
}
func assignApproxRTTs(summaries []*Summary) {
grouped := make(map[routeKey][]*Summary)
for _, summary := range summaries {
grouped[routeKey{From: summary.From, To: summary.To}] = append(grouped[routeKey{From: summary.From, To: summary.To}], summary)
}
for key, requests := range grouped {
replies := grouped[routeKey{From: key.To, To: key.From}]
if len(replies) == 0 {
continue
}
assignApproxRTTsForRoute(
sortSummariesByTimestamp(requests, EventBAppRecv),
sortSummariesByTimestamp(replies, EventATXSoftware),
)
}
}
func assignApproxRTTsForRoute(requests, replies []*Summary) {
replyIndex := 0
for _, request := range requests {
requestReceivedAtResponder, ok := request.Timestamps[EventBAppRecv]
if !ok {
continue
}
for replyIndex < len(replies) {
reply := replies[replyIndex]
replySentAtResponder, ok := reply.Timestamps[EventATXSoftware]
if !ok {
replyIndex++
continue
}
if replySentAtResponder < requestReceivedAtResponder {
replyIndex++
continue
}
if value := subtractSummaryTimestamps(reply, EventBAppRecv, request, EventATXSoftware); value != nil {
request.ApproxRTTNS = value
}
replyIndex++
break
}
}
}
func sortSummariesByTimestamp(summaries []*Summary, eventName string) []*Summary {
sorted := append([]*Summary(nil), summaries...)
sort.SliceStable(sorted, func(i, j int) bool {
leftTS, leftOK := sorted[i].Timestamps[eventName]
rightTS, rightOK := sorted[j].Timestamps[eventName]
switch {
case leftOK && rightOK:
if leftTS != rightTS {
return leftTS < rightTS
}
case leftOK:
return true
case rightOK:
return false
}
if sorted[i].MessageID != sorted[j].MessageID {
return sorted[i].MessageID < sorted[j].MessageID
}
if sorted[i].From != sorted[j].From {
return sorted[i].From < sorted[j].From
}
if sorted[i].To != sorted[j].To {
return sorted[i].To < sorted[j].To
}
return sorted[i].MessageType < sorted[j].MessageType
})
return sorted
}
// 返回 requiredTimestampNames 中哪些在给定的 timestamps 中缺失。
func missingTimestampNames(timestamps map[string]int64) []string {
var missing []string
for _, name := range requiredTimestampNames {
if _, ok := timestamps[name]; !ok {
missing = append(missing, name)
}
}
return missing
}
// 如果 timestamps 中同时存在 endName 和 beginName则返回它们的差值否则返回 nil。
func subtractIfPresent(timestamps map[string]int64, endName, beginName string) *int64 {
end, ok := timestamps[endName]
if !ok {
return nil
}
begin, ok := timestamps[beginName]
if !ok {
return nil
}
value := end - begin
return &value
}
func subtractSummaryTimestamps(endSummary *Summary, endName string, beginSummary *Summary, beginName string) *int64 {
end, ok := endSummary.Timestamps[endName]
if !ok {
return nil
}
begin, ok := beginSummary.Timestamps[beginName]
if !ok {
return nil
}
value := end - begin
return &value
}
// 除法函数,如果 bodySize <= 0 或 latencyNS 不存在或 <= 0则返回 nil否则返回 bodySize / latencyNS 的结果。
func calculateBitrateBPS(bodySize int, latencyNS *int64) *float64 {
if bodySize <= 0 || latencyNS == nil || *latencyNS <= 0 {
return nil
}
value := float64(bodySize) * 8 * 1_000_000_000 / float64(*latencyNS)
return &value
}
// 最大 message_id 计算函数
func maxBusinessMessageID(events []Event) (uint64, bool) {
var maxMessageID uint64
hasBusinessMessage := false
for _, event := range events {
if !IsBusinessEvent(event) {
continue
}
if !hasBusinessMessage || event.MessageID > maxMessageID {
maxMessageID = event.MessageID
hasBusinessMessage = true
}
}
return maxMessageID, hasBusinessMessage
}
// 根据 message_id 截断事件列表的函数
func filterEventsByMaxMessageID(events []Event, maxMessageID uint64) []Event {
filtered := make([]Event, 0, len(events))
for _, event := range events {
if event.MessageID > maxMessageID {
continue
}
filtered = append(filtered, event)
}
return filtered
}
func subtractUint64(value, offset uint64) (uint64, bool) {
if offset > value {
return 0, false
}
return value - offset, true
}
// 判断事件是否是业务相关的时延事件(其中一项)
func IsBusinessEvent(event Event) bool {
switch event.Event {
case EventAAppPrepBegin,
EventATXSched,
EventATXSoftware,
EventATXHardware,
EventBRXHardware,
EventBRXSoftware,
EventBAppRecv,
EventBPersistBegin,
EventBPersistEnd:
return true
default:
return false
}
}

View File

@@ -0,0 +1,498 @@
package latencylog
import (
"bufio"
"fmt"
"html/template"
"os"
"path/filepath"
"strings"
"omnisocketgo/cmd/internal/protocol"
)
const summaryChartHTMLTemplate = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Latency Summary Chart</title>
<style>
:root {
color-scheme: light;
--bg: #f6f7fb;
--panel: #ffffff;
--text: #172033;
--muted: #60708a;
--border: #d9dfeb;
--track: #e8edf5;
--a-proc: #3b82f6;
--a-queue: #14b8a6;
--transport: #f59e0b;
--b-proc: #22c55e;
--unknown: #94a3b8;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(180deg, #eef3ff 0%, var(--bg) 220px);
color: var(--text);
}
main {
max-width: 1120px;
margin: 0 auto;
padding: 32px 20px 48px;
}
h1 {
margin: 0 0 8px;
font-size: 32px;
line-height: 1.1;
}
.intro {
color: var(--muted);
margin: 0 0 24px;
font-size: 15px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px 16px;
box-shadow: 0 10px 30px rgba(23, 32, 51, 0.06);
}
.stat-label {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.stat-value {
font-size: 22px;
font-weight: 700;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
color: var(--muted);
font-size: 13px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
}
.swatch {
width: 12px;
height: 12px;
border-radius: 999px;
flex: 0 0 auto;
}
.rows {
display: grid;
gap: 14px;
}
.row {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
box-shadow: 0 12px 30px rgba(23, 32, 51, 0.05);
}
.row-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 8px;
}
.row-title {
font-size: 18px;
font-weight: 700;
margin: 0;
}
.row-e2e {
font-size: 16px;
font-weight: 700;
white-space: nowrap;
}
.row-meta {
color: var(--muted);
font-size: 13px;
margin-bottom: 12px;
}
.ratio-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: -2px 0 12px;
}
.ratio-pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: #f7f9fc;
color: var(--text);
font-size: 12px;
line-height: 1.2;
}
.bar {
height: 24px;
display: flex;
overflow: hidden;
background: var(--track);
border-radius: 999px;
}
.segment {
height: 100%;
}
.segment:first-child {
border-top-left-radius: 999px;
border-bottom-left-radius: 999px;
}
.segment:last-child {
border-top-right-radius: 999px;
border-bottom-right-radius: 999px;
}
.row-legend {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-top: 10px;
color: var(--muted);
font-size: 12px;
}
.missing {
margin-top: 10px;
color: #a16207;
font-size: 12px;
}
.empty {
color: var(--muted);
font-style: italic;
padding: 24px 16px;
background: var(--panel);
border: 1px dashed var(--border);
border-radius: 16px;
text-align: center;
}
</style>
</head>
<body>
<main>
<h1>Latency Summary</h1>
<p class="intro">A simple per-message end-to-end latency chart generated from summarized JSONL records.</p>
<section class="stats">
<div class="stat">
<div class="stat-label">Messages</div>
<div class="stat-value">{{.TotalMessages}}</div>
</div>
<div class="stat">
<div class="stat-label">With End-To-End</div>
<div class="stat-value">{{.MessagesWithEndToEnd}}</div>
</div>
<div class="stat">
<div class="stat-label">Average End-To-End</div>
<div class="stat-value">{{.AverageEndToEnd}}</div>
</div>
<div class="stat">
<div class="stat-label">Max End-To-End</div>
<div class="stat-value">{{.MaxEndToEnd}}</div>
</div>
</section>
<section class="legend">
{{range .Legend}}
<span class="legend-item">
<span class="swatch" style="background: {{.Color}}"></span>
<span>{{.Label}}</span>
</span>
{{end}}
</section>
{{if .Rows}}
<section class="rows">
{{range .Rows}}
<article class="row">
<div class="row-head">
<h2 class="row-title">{{.Title}}</h2>
<div class="row-e2e">{{.EndToEnd}}</div>
</div>
<div class="row-meta">{{.Subtitle}}</div>
<div class="row-meta">{{.ApproxRTT}}</div>
{{if .RatioMetrics}}
<div class="ratio-list">
{{range .RatioMetrics}}
<span class="ratio-pill">{{.Label}} {{.Value}}</span>
{{end}}
</div>
{{end}}
<div class="bar">
{{range .Segments}}
<div class="segment" style="width: {{printf "%.4f" .WidthPercent}}%; background: {{.Color}}" title="{{.Label}}: {{.Value}}"></div>
{{end}}
</div>
{{if .Segments}}
<div class="row-legend">
{{range .Segments}}
<span class="legend-item">
<span class="swatch" style="background: {{.Color}}"></span>
<span>{{.Label}} {{.Value}}</span>
</span>
{{end}}
</div>
{{end}}
{{if .MissingTimestamps}}
<div class="missing">Missing timestamps: {{.MissingTimestamps}}</div>
{{end}}
</article>
{{end}}
</section>
{{else}}
<section class="empty">No summarized messages were available for chart rendering.</section>
{{end}}
</main>
</body>
</html>
`
type summaryChartPage struct {
TotalMessages int
MessagesWithEndToEnd int
AverageEndToEnd string
MaxEndToEnd string
Legend []summaryChartLegendItem
Rows []summaryChartRow
}
type summaryChartLegendItem struct {
Label string
Color string
}
type summaryChartRow struct {
Title string
Subtitle string
EndToEnd string
ApproxRTT string
MissingTimestamps string
RatioMetrics []summaryChartValue
Segments []summaryChartSegment
}
type summaryChartSegment struct {
Label string
Value string
Color string
WidthPercent float64
}
type summaryChartValue struct {
Label string
Value string
}
type summaryChartSegmentMetric struct {
label string
value *int64
color string
}
// WriteSummariesHTMLChart 将整理结果写成一个可直接在浏览器中打开的简单 HTML 图表。
func WriteSummariesHTMLChart(path string, summaries []Summary) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("latencylog: create chart dir for %s: %w", path, err)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("latencylog: open chart file %s: %w", path, err)
}
defer file.Close()
page := buildSummaryChartPage(summaries)
tmpl, err := template.New("summary-chart").Parse(summaryChartHTMLTemplate)
if err != nil {
return fmt.Errorf("latencylog: parse chart template: %w", err)
}
writer := bufio.NewWriter(file)
if err := tmpl.Execute(writer, page); err != nil {
return fmt.Errorf("latencylog: render chart %s: %w", path, err)
}
if err := writer.Flush(); err != nil {
return fmt.Errorf("latencylog: flush chart %s: %w", path, err)
}
return nil
}
func buildSummaryChartPage(summaries []Summary) summaryChartPage {
page := summaryChartPage{
TotalMessages: len(summaries),
Legend: []summaryChartLegendItem{
{Label: "A processing", Color: "var(--a-proc)"},
{Label: "A queue", Color: "var(--a-queue)"},
{Label: "A-B transport + propagation", Color: "var(--transport)"},
{Label: "B processing", Color: "var(--b-proc)"},
{Label: "Unknown / missing", Color: "var(--unknown)"},
},
Rows: make([]summaryChartRow, 0, len(summaries)),
}
var (
endToEndValues []int64
totalEndToEnd int64
maxEndToEnd int64
)
for _, summary := range summaries {
page.Rows = append(page.Rows, buildSummaryChartRow(summary))
if summary.EndToEndLatencyNS == nil {
continue
}
endToEnd := *summary.EndToEndLatencyNS
endToEndValues = append(endToEndValues, endToEnd)
totalEndToEnd += endToEnd
if endToEnd > maxEndToEnd {
maxEndToEnd = endToEnd
}
}
page.MessagesWithEndToEnd = len(endToEndValues)
page.AverageEndToEnd = "n/a"
page.MaxEndToEnd = "n/a"
if len(endToEndValues) > 0 {
page.AverageEndToEnd = formatLatencyNS(totalEndToEnd / int64(len(endToEndValues)))
page.MaxEndToEnd = formatLatencyNS(maxEndToEnd)
}
return page
}
func buildSummaryChartRow(summary Summary) summaryChartRow {
row := summaryChartRow{
Title: buildSummaryChartTitle(summary),
Subtitle: buildSummaryChartSubtitle(summary),
EndToEnd: "End-to-end: n/a",
ApproxRTT: "Approx RTT: n/a",
MissingTimestamps: strings.Join(summary.MissingTimestamps, ", "),
}
if summary.ApproxRTTNS != nil && *summary.ApproxRTTNS > 0 {
row.ApproxRTT = fmt.Sprintf("Approx RTT: %s", formatLatencyNS(*summary.ApproxRTTNS))
}
ratioMetrics := []struct {
label string
value *float64
}{
{label: "A processing bitrate", value: summary.AProcessingBitrateBPS},
{label: "A-B transport + propagation bitrate", value: summary.ABTransportPropagationBitrateBPS},
{label: "End-to-end bitrate", value: summary.EndToEndBitrateBPS},
}
for _, metric := range ratioMetrics {
if metric.value == nil || *metric.value <= 0 {
continue
}
row.RatioMetrics = append(row.RatioMetrics, summaryChartValue{
Label: metric.label,
Value: formatBitrateBPS(*metric.value),
})
}
if summary.EndToEndLatencyNS == nil || *summary.EndToEndLatencyNS <= 0 {
return row
}
total := *summary.EndToEndLatencyNS
row.EndToEnd = fmt.Sprintf("End-to-end: %s", formatLatencyNS(total))
metrics := []summaryChartSegmentMetric{
{label: "A processing", value: summary.AProcessingLatencyNS, color: "var(--a-proc)"},
{label: "A queue", value: summary.AQueueLatencyNS, color: "var(--a-queue)"},
{label: "A-B transport + propagation", value: summary.ABTransportPropagationNS, color: "var(--transport)"},
{label: "B processing", value: summary.BProcessingLatencyNS, color: "var(--b-proc)"},
}
var knownTotal int64
for _, metric := range metrics {
if metric.value == nil || *metric.value <= 0 {
continue
}
knownTotal += *metric.value
}
scaleTotal := total
if knownTotal > scaleTotal {
scaleTotal = knownTotal
}
if scaleTotal <= 0 {
return row
}
for _, metric := range metrics {
if metric.value == nil || *metric.value <= 0 {
continue
}
row.Segments = append(row.Segments, summaryChartSegment{
Label: metric.label,
Value: formatLatencyNS(*metric.value),
Color: metric.color,
WidthPercent: float64(*metric.value) * 100 / float64(scaleTotal),
})
}
if remaining := total - knownTotal; remaining > 0 {
row.Segments = append(row.Segments, summaryChartSegment{
Label: "Unknown / missing",
Value: formatLatencyNS(remaining),
Color: "var(--unknown)",
WidthPercent: float64(remaining) * 100 / float64(scaleTotal),
})
}
return row
}
func buildSummaryChartTitle(summary Summary) string {
if summary.MessageType == protocol.MessageTypeFile && summary.FileName != "" {
return fmt.Sprintf("%s #%d (%s)", summary.MessageType, summary.MessageID, summary.FileName)
}
return fmt.Sprintf("%s #%d", summary.MessageType, summary.MessageID)
}
func buildSummaryChartSubtitle(summary Summary) string {
parts := []string{
fmt.Sprintf("%s -> %s", summary.From, summary.To),
fmt.Sprintf("%d bytes", summary.BodySize),
}
if summary.MessageType == protocol.MessageTypeFile && summary.FileName != "" {
parts = append(parts, fmt.Sprintf("file: %s", summary.FileName))
}
return strings.Join(parts, " | ")
}
func formatLatencyNS(ns int64) string {
return fmt.Sprintf("%.3f ms", float64(ns)/1_000_000)
}
func formatBitrateBPS(bitsPerSecond float64) string {
return fmt.Sprintf("%.3f Mb/s", bitsPerSecond/1_000_000)
}

View File

@@ -0,0 +1,79 @@
package latencylog
import (
"os"
"path/filepath"
"strings"
"testing"
"omnisocketgo/cmd/internal/protocol"
)
func TestWriteSummariesHTMLChart(t *testing.T) {
aProcessing := int64(20_000_000)
aQueue := int64(10_000_000)
transport := int64(40_000_000)
bProcessing := int64(30_000_000)
endToEnd := int64(100_000_000)
aProcessingBitrate := float64(5) * 8 * 1_000_000_000 / float64(aProcessing)
transportBitrate := float64(5) * 8 * 1_000_000_000 / float64(transport)
endToEndBitrate := float64(5) * 8 * 1_000_000_000 / float64(endToEnd)
summaries := []Summary{
{
MessageType: protocol.MessageTypeText,
MessageID: 7,
From: "peer-a",
To: "peer-b",
BodySize: 5,
AProcessingLatencyNS: &aProcessing,
AQueueLatencyNS: &aQueue,
ABTransportPropagationNS: &transport,
BProcessingLatencyNS: &bProcessing,
EndToEndLatencyNS: &endToEnd,
AProcessingBitrateBPS: &aProcessingBitrate,
ABTransportPropagationBitrateBPS: &transportBitrate,
EndToEndBitrateBPS: &endToEndBitrate,
ApproxRTTNS: &endToEnd,
},
{
MessageType: protocol.MessageTypeFile,
MessageID: 8,
From: "peer-b",
To: "peer-a",
FileName: "payload.bin",
BodySize: 128,
MissingTimestamps: []string{EventBRXSoftware},
},
}
path := filepath.Join(t.TempDir(), "charts", "latency-summary.html")
if err := WriteSummariesHTMLChart(path, summaries); err != nil {
t.Fatalf("WriteSummariesHTMLChart() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("os.ReadFile() error = %v", err)
}
content := string(data)
for _, want := range []string{
"Latency Summary",
"text #7",
"peer-a -&gt; peer-b | 5 bytes",
"End-to-end: 100.000 ms",
"Approx RTT: 100.000 ms",
"A processing bitrate 0.002 Mb/s",
"A-B transport &#43; propagation bitrate 0.001 Mb/s",
"End-to-end bitrate 0.000 Mb/s",
"A processing 20.000 ms",
"A-B transport &#43; propagation 40.000 ms",
"file #8 (payload.bin)",
"Missing timestamps: B_RX_SOFTWARE",
} {
if !strings.Contains(content, want) {
t.Fatalf("chart content missing %q\n%s", want, content)
}
}
}

View File

@@ -0,0 +1,399 @@
package latencylog
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"reflect"
"sort"
"testing"
"omnisocketgo/cmd/internal/protocol"
)
func TestSummarizeEventsComputesLatencyMetrics(t *testing.T) {
events := []Event{
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 120, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 180, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 220, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 230, Event: EventBPersistBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 260, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
}
summaries := SummarizeEvents(events)
if len(summaries) != 1 {
t.Fatalf("summary count = %d, want 1", len(summaries))
}
summary := summaries[0]
if got := ptrValue(summary.AProcessingLatencyNS); got != 20 {
t.Fatalf("AProcessingLatencyNS = %d, want 20", got)
}
if got := ptrValue(summary.AQueueLatencyNS); got != 20 {
t.Fatalf("AQueueLatencyNS = %d, want 20", got)
}
if got := ptrValue(summary.ABTransportPropagationNS); got != 80 {
t.Fatalf("ABTransportPropagationNS = %d, want 80", got)
}
if got := ptrValue(summary.BKernelReceivePathLatencyNS); got != 40 {
t.Fatalf("BKernelReceivePathLatencyNS = %d, want 40", got)
}
if got := ptrValue(summary.BProcessingLatencyNS); got != 40 {
t.Fatalf("BProcessingLatencyNS = %d, want 40", got)
}
if got := ptrValue(summary.EndToEndLatencyNS); got != 160 {
t.Fatalf("EndToEndLatencyNS = %d, want 160", got)
}
if got := ptrValueFloat(summary.AProcessingBitrateBPS); got != 128_000_000_000 {
t.Fatalf("AProcessingBitrateBPS = %v, want 128000000000", got)
}
if got := ptrValueFloat(summary.ABTransportPropagationBitrateBPS); got != 32_000_000_000 {
t.Fatalf("ABTransportPropagationBitrateBPS = %v, want 32000000000", got)
}
if got := ptrValueFloat(summary.EndToEndBitrateBPS); got != 16_000_000_000 {
t.Fatalf("EndToEndBitrateBPS = %v, want 16000000000", got)
}
if got := summary.Timestamps[EventBRXSoftware]; got != 180 {
t.Fatalf("timestamps[%q] = %d, want 180", EventBRXSoftware, got)
}
if len(summary.MissingTimestamps) != 0 {
t.Fatalf("MissingTimestamps = %v, want empty", summary.MissingTimestamps)
}
}
func TestSummarizeEventsComputesApproxRTTByPairingReverseMessages(t *testing.T) {
events := []Event{
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b"},
{TsUnixNano: 110, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b"},
{TsUnixNano: 180, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b"},
{TsUnixNano: 120, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
{TsUnixNano: 190, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
{TsUnixNano: 200, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 11, From: "peer-b", To: "peer-a"},
{TsUnixNano: 210, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 11, From: "peer-b", To: "peer-a"},
{TsUnixNano: 260, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 11, From: "peer-b", To: "peer-a"},
{TsUnixNano: 220, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 12, From: "peer-b", To: "peer-a"},
{TsUnixNano: 230, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 12, From: "peer-b", To: "peer-a"},
{TsUnixNano: 310, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 12, From: "peer-b", To: "peer-a"},
}
summaries := SummarizeEvents(events)
if len(summaries) != 4 {
t.Fatalf("summary count = %d, want 4", len(summaries))
}
gotByMessageID := make(map[uint64]Summary, len(summaries))
for _, summary := range summaries {
gotByMessageID[summary.MessageID] = summary
}
if got := ptrValue(gotByMessageID[1].ApproxRTTNS); got != 150 {
t.Fatalf("message 1 ApproxRTTNS = %d, want 150", got)
}
if got := ptrValue(gotByMessageID[2].ApproxRTTNS); got != 170 {
t.Fatalf("message 2 ApproxRTTNS = %d, want 170", got)
}
if gotByMessageID[11].ApproxRTTNS != nil {
t.Fatalf("message 11 ApproxRTTNS = %d, want nil", ptrValue(gotByMessageID[11].ApproxRTTNS))
}
if gotByMessageID[12].ApproxRTTNS != nil {
t.Fatalf("message 12 ApproxRTTNS = %d, want nil", ptrValue(gotByMessageID[12].ApproxRTTNS))
}
}
func TestSummarizeEventsReportsMissingTimestamps(t *testing.T) {
events := []Event{
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
{TsUnixNano: 240, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
}
summaries := SummarizeEvents(events)
if len(summaries) != 1 {
t.Fatalf("summary count = %d, want 1", len(summaries))
}
wantMissing := []string{EventATXSched, EventATXSoftware, EventBRXSoftware, EventBAppRecv}
if !reflect.DeepEqual(summaries[0].MissingTimestamps, wantMissing) {
t.Fatalf("MissingTimestamps = %v, want %v", summaries[0].MissingTimestamps, wantMissing)
}
if summaries[0].AProcessingLatencyNS != nil {
t.Fatalf("AProcessingLatencyNS = %v, want nil", ptrValue(summaries[0].AProcessingLatencyNS))
}
if summaries[0].EndToEndLatencyNS == nil {
t.Fatal("EndToEndLatencyNS = nil, want non-nil because endpoints are present")
}
}
func TestLoadAndWriteSummaryFiles(t *testing.T) {
rawPath := filepath.Join(t.TempDir(), "raw.jsonl")
rawLogger, err := NewJSONLLogger(rawPath)
if err != nil {
t.Fatalf("NewJSONLLogger() error = %v", err)
}
t.Cleanup(func() {
_ = rawLogger.Close()
})
for _, event := range []Event{
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 120, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 180, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 220, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 260, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
} {
if err := rawLogger.LogEvent(event); err != nil {
t.Fatalf("LogEvent() error = %v", err)
}
}
events, err := LoadEventsFromFile(rawPath)
if err != nil {
t.Fatalf("LoadEventsFromFile() error = %v", err)
}
summaryPath := filepath.Join(t.TempDir(), "summary.jsonl")
if err := WriteSummariesJSONL(summaryPath, SummarizeEvents(events)); err != nil {
t.Fatalf("WriteSummariesJSONL() error = %v", err)
}
file, err := os.Open(summaryPath)
if err != nil {
t.Fatalf("os.Open() error = %v", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
if !scanner.Scan() {
t.Fatal("expected one summary line, got none")
}
var summary Summary
if err := json.Unmarshal(scanner.Bytes(), &summary); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if summary.MessageID != 3 {
t.Fatalf("MessageID = %d, want 3", summary.MessageID)
}
if got := ptrValue(summary.BKernelReceivePathLatencyNS); got != 40 {
t.Fatalf("BKernelReceivePathLatencyNS = %d, want 40", got)
}
if got := ptrValue(summary.EndToEndLatencyNS); got != 160 {
t.Fatalf("EndToEndLatencyNS = %d, want 160", got)
}
if got := ptrValueFloat(summary.EndToEndBitrateBPS); got != 16_000_000_000 {
t.Fatalf("EndToEndBitrateBPS = %v, want 16000000000", got)
}
}
func TestLoadEventsFromFilesWithSharedMaxOffsetFiltersToSharedCutoff(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
firstMessageIDs []uint64
secondMessageIDs []uint64
offset uint64
wantCutoff *uint64
wantMessageIDs []uint64
}{
{
name: "same max message id rolls back one",
firstMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7},
secondMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7},
offset: 1,
wantCutoff: uint64Ptr(6),
wantMessageIDs: []uint64{1, 2, 3, 4, 5, 6},
},
{
name: "smaller input max wins before rollback",
firstMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9},
secondMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7},
offset: 1,
wantCutoff: uint64Ptr(6),
wantMessageIDs: []uint64{1, 2, 3, 4, 5, 6},
},
{
name: "not enough shared messages yields empty result",
firstMessageIDs: []uint64{1},
secondMessageIDs: []uint64{1},
offset: 1,
wantCutoff: uint64Ptr(0),
wantMessageIDs: nil,
},
}
for _, tt := range testCases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
firstPath := filepath.Join(tempDir, "first.jsonl")
secondPath := filepath.Join(tempDir, "second.jsonl")
writeEventsJSONL(t, firstPath, testEventsForMessageIDs(tt.firstMessageIDs, "peer-a", "peer-b"))
writeEventsJSONL(t, secondPath, testEventsForMessageIDs(tt.secondMessageIDs, "peer-b", "peer-a"))
events, cutoff, err := LoadEventsFromFilesWithSharedMaxOffset([]string{firstPath, secondPath}, tt.offset)
if err != nil {
t.Fatalf("LoadEventsFromFilesWithSharedMaxOffset() error = %v", err)
}
if !reflect.DeepEqual(cutoff, tt.wantCutoff) {
t.Fatalf("cutoff = %v, want %v", cutoff, tt.wantCutoff)
}
if got := businessMessageIDs(events); !reflect.DeepEqual(got, tt.wantMessageIDs) {
t.Fatalf("message IDs = %v, want %v", got, tt.wantMessageIDs)
}
})
}
}
func TestLoadEventsFromFilesWithSharedMaxOffsetPreservesEarlierSummaries(t *testing.T) {
tempDir := t.TempDir()
firstPath := filepath.Join(tempDir, "first.jsonl")
secondPath := filepath.Join(tempDir, "second.jsonl")
writeEventsJSONL(t, firstPath, []Event{
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 120, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 180, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 220, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 260, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
{TsUnixNano: 300, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
{TsUnixNano: 330, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
{TsUnixNano: 360, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
{TsUnixNano: 390, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
{TsUnixNano: 420, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
{TsUnixNano: 470, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
{TsUnixNano: 500, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
{TsUnixNano: 520, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
{TsUnixNano: 540, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
{TsUnixNano: 560, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
{TsUnixNano: 580, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
{TsUnixNano: 600, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
{TsUnixNano: 700, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 4, From: "peer-a", To: "peer-b", BodySize: 40},
})
writeEventsJSONL(t, secondPath, []Event{
{TsUnixNano: 90, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-b", To: "peer-a", BodySize: 20},
{TsUnixNano: 95, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-b", To: "peer-a", BodySize: 20},
{TsUnixNano: 150, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-b", To: "peer-a", BodySize: 20},
{TsUnixNano: 290, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-b", To: "peer-a", BodySize: 20},
{TsUnixNano: 295, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-b", To: "peer-a", BodySize: 20},
{TsUnixNano: 350, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-b", To: "peer-a", BodySize: 20},
{TsUnixNano: 490, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-b", To: "peer-a", BodySize: 20},
{TsUnixNano: 495, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-b", To: "peer-a", BodySize: 20},
{TsUnixNano: 550, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-b", To: "peer-a", BodySize: 20},
{TsUnixNano: 690, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 4, From: "peer-b", To: "peer-a", BodySize: 20},
})
events, cutoff, err := LoadEventsFromFilesWithSharedMaxOffset([]string{firstPath, secondPath}, 1)
if err != nil {
t.Fatalf("LoadEventsFromFilesWithSharedMaxOffset() error = %v", err)
}
if !reflect.DeepEqual(cutoff, uint64Ptr(3)) {
t.Fatalf("cutoff = %v, want %v", cutoff, uint64Ptr(3))
}
summaries := SummarizeEvents(events)
if got := len(summaries); got != 6 {
t.Fatalf("summary count = %d, want 6", got)
}
for _, summary := range summaries {
if summary.MessageID == 4 {
t.Fatalf("message 4 should have been truncated from summaries: %+v", summary)
}
}
var forwardMessageTwo Summary
found := false
for _, summary := range summaries {
if summary.From == "peer-a" && summary.To == "peer-b" && summary.MessageID == 2 {
forwardMessageTwo = summary
found = true
break
}
}
if !found {
t.Fatal("summary for message 2 peer-a -> peer-b not found")
}
if got := ptrValue(forwardMessageTwo.EndToEndLatencyNS); got != 170 {
t.Fatalf("message 2 EndToEndLatencyNS = %d, want 170", got)
}
if got := ptrValue(forwardMessageTwo.ApproxRTTNS); got != 190 {
t.Fatalf("message 2 ApproxRTTNS = %d, want 190", got)
}
}
func ptrValue(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func ptrValueFloat(value *float64) float64 {
if value == nil {
return 0
}
return *value
}
func uint64Ptr(value uint64) *uint64 {
return &value
}
func businessMessageIDs(events []Event) []uint64 {
seen := make(map[uint64]struct{})
var ids []uint64
for _, event := range events {
if !IsBusinessEvent(event) {
continue
}
if _, ok := seen[event.MessageID]; ok {
continue
}
seen[event.MessageID] = struct{}{}
ids = append(ids, event.MessageID)
}
sort.Slice(ids, func(i, j int) bool {
return ids[i] < ids[j]
})
return ids
}
func testEventsForMessageIDs(messageIDs []uint64, from, to string) []Event {
events := make([]Event, 0, len(messageIDs)*2)
for _, messageID := range messageIDs {
events = append(events,
Event{TsUnixNano: int64(messageID*100 + 10), Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: messageID, From: from, To: to, BodySize: 32},
Event{TsUnixNano: int64(messageID*100 + 20), Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: messageID, From: from, To: to, BodySize: 32},
)
}
return events
}
func writeEventsJSONL(t *testing.T, path string, events []Event) {
t.Helper()
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
t.Fatalf("os.OpenFile(%s) error = %v", path, err)
}
defer file.Close()
encoder := json.NewEncoder(file)
for _, event := range events {
if err := encoder.Encode(event); err != nil {
t.Fatalf("encoder.Encode(%s) error = %v", path, err)
}
}
}

View File

@@ -0,0 +1,279 @@
package protocol
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"unicode/utf8"
)
// MaxFrameSize 用于限制单个帧的最大长度,
// 避免异常对端通过伪造超大长度值导致接收方无上限分配内存。
const MaxFrameSize = 8 * 1024 * 1024 // 先临时设置传输的视频帧不超过8MB
var (
ErrInvalidFrameLength = errors.New("protocol: invalid frame length") // 表示帧长度非法,例如长度为 0。
ErrFrameTooLarge = errors.New("protocol: frame too large") // 表示帧长度超过允许的上限。
ErrInvalidMessageType = errors.New("protocol: invalid message type") // 表示消息类型不是当前协议支持的类型。
ErrMissingFrom = errors.New("protocol: missing from") // 表示消息缺少发送方标识。
ErrMissingTo = errors.New("protocol: missing to") // 表示消息缺少接收方标识。
ErrMissingFileName = errors.New("protocol: missing file name") // 表示 file 消息缺少文件名。
ErrUnexpectedFileName = errors.New("protocol: unexpected file name") // 表示 text 消息错误地携带了文件名。
ErrInvalidTextBody = errors.New("protocol: invalid text body") // 表示 text 消息正文不是合法 UTF-8。
ErrUnexpectedBody = errors.New("protocol: unexpected body") // 表示某些控制消息不允许携带正文。
ErrInvalidRegisterTarget = errors.New("protocol: invalid register target") // 表示 register 消息没有发往 server。
ErrInvalidErrorSource = errors.New("protocol: invalid error source") // 表示 error 消息不是由 server 发出。
ErrInvalidHeaderLength = errors.New("protocol: invalid header length") // 表示 header 长度字段为 0、越界或无法完整切分。
ErrInvalidHeaderJSON = errors.New("protocol: invalid header json") // 表示 header JSON 无法解析,可能是格式错误或缺少必要字段。
ErrInvalidContentLength = errors.New("protocol: invalid content length") // 表示头部记录的正文长度与实际正文不一致。
)
// 应用层消息:[4字节 frameLength][4字节 headerLen][header JSON下面自定义的Message头][body bytes]
// 写了 tagJSON 字段名是你指定的 type不写 tagJSON 字段名默认是 Go 字段名 Type
type messageHeader struct {
Type MessageType `json:"type"`
ID uint64 `json:"id"`
From string `json:"from"`
To string `json:"to"`
FileName string `json:"file_name,omitempty"`
ContentLength int `json:"content_length"`
}
// EncodeMessage 将逻辑消息编码为帧内字节格式:
// 1. 4 字节大端序 header 长度
// 2. header JSON
// 3. 原始 body 字节
func EncodeMessage(msg Message) ([]byte, error) {
if err := validateMessage(msg); err != nil {
return nil, err
}
header := messageHeader{
Type: msg.Type,
ID: msg.ID,
From: msg.From,
To: msg.To,
FileName: msg.FileName,
ContentLength: len(msg.Body),
}
headerPayload, err := json.Marshal(header)
if err != nil {
return nil, fmt.Errorf("protocol: encode header: %w", err)
}
// 创建一个新的字节切片来存储完整的帧内容,避免直接在 headerPayload 上修改导致数据混乱。
payload := make([]byte, 4+len(headerPayload)+len(msg.Body))
// 在 payload 前 4 字节写入 header 长度,后续内容依次是 header JSON第五个字节开始 和 body。
binary.BigEndian.PutUint32(payload[:4], uint32(len(headerPayload)))
copy(payload[4:], headerPayload)
copy(payload[4+len(headerPayload):], msg.Body)
//检查整个帧长度是否合法,避免上层调用者构造的消息过大导致发送失败。
if len(payload) > MaxFrameSize {
return nil, ErrFrameTooLarge
}
return payload, nil
}
// DecodeMessage 将帧内字节格式还原为 Message。
func DecodeMessage(data []byte) (Message, error) {
if len(data) > MaxFrameSize {
return Message{}, ErrFrameTooLarge
}
if len(data) < 4 {
return Message{}, ErrInvalidHeaderLength
}
headerLen := int(binary.BigEndian.Uint32(data[:4]))
if headerLen == 0 || headerLen > len(data)-4 {
return Message{}, ErrInvalidHeaderLength
}
headerPayload := data[4 : 4+headerLen]
body := data[4+headerLen:]
var header messageHeader
if err := json.Unmarshal(headerPayload, &header); err != nil {
return Message{}, fmt.Errorf("protocol: decode header: %w", errors.Join(ErrInvalidHeaderJSON, err))
}
if header.ContentLength < 0 || header.ContentLength != len(body) {
return Message{}, ErrInvalidContentLength
}
bodyCopy := make([]byte, len(body))
copy(bodyCopy, body)
msg := Message{
Type: header.Type,
ID: header.ID,
From: header.From,
To: header.To,
FileName: header.FileName,
Body: bodyCopy,
}
if err := validateMessage(msg); err != nil {
return Message{}, err
}
return msg, nil
}
// WriteFrame 向流中写入一个带长度前缀的帧。
// TCP帧格式如下
// 1. 4 字节大端序长度
// 2. 后续 payload 内容
//
// TCP 是字节流协议,没有天然的消息边界。
// 增加显式长度前缀后,接收方就知道一条完整消息应该读取多少字节,
// 从而解决粘包和拆包问题。
func WriteFrame(w io.Writer, payload []byte) error {
size := len(payload)
//空帧
if size == 0 {
return ErrInvalidFrameLength
}
//帧过大
if size > MaxFrameSize {
return ErrFrameTooLarge
}
var header [4]byte
binary.BigEndian.PutUint32(header[:], uint32(size))
// 先写长度头,接收方才能根据长度一次性读取完整消息体。
if err := writeFull(w, header[:]); err != nil {
return err
}
return writeFull(w, payload)
}
// ReadFrame 从流中读取一个完整的长度前缀帧。
// 它会先读取固定 4 字节长度头,校验长度是否合法,
// 再使用 io.ReadFull 按长度读取完整消息体,
// 这样即使底层 TCP 发生分段读取,也不会把半条消息暴露给上层。
func ReadFrame(r io.Reader) ([]byte, error) {
var header [4]byte
if _, err := io.ReadFull(r, header[:]); err != nil {
return nil, err
}
size := binary.BigEndian.Uint32(header[:])
// 长度为 0 的帧被认为是非法输入,而不是合法的空消息。
if size == 0 {
return nil, ErrInvalidFrameLength
}
// 长度超过上限的帧会被拒绝,避免接收方无上限分配内存。
if size > MaxFrameSize {
return nil, ErrFrameTooLarge
}
payload := make([]byte, int(size))
if _, err := io.ReadFull(r, payload); err != nil {
return nil, err
}
return payload, nil
}
// WriteMessage 是给上层直接使用的完整发送路径:
// 把一条结构化消息完整编码并发送出去”的总入口。
// Message -> header+body -> 长度前缀帧 -> io.Writer。
func WriteMessage(w io.Writer, msg Message) error {
payload, err := EncodeMessage(msg)
if err != nil {
return fmt.Errorf("protocol: encode message: %w", err)
}
if err := WriteFrame(w, payload); err != nil {
return fmt.Errorf("protocol: write frame: %w", err)
}
return nil
}
// ReadMessage 是给上层直接使用的完整接收路径:
// io.Reader -> 长度前缀帧 -> header+body -> Message。
func ReadMessage(r io.Reader) (Message, error) {
payload, err := ReadFrame(r)
if err != nil {
return Message{}, fmt.Errorf("protocol: read frame: %w", err)
}
msg, err := DecodeMessage(payload)
if err != nil {
return Message{}, fmt.Errorf("protocol: decode message: %w", err)
}
return msg, nil
}
// validateMessage 检查 Message 传输的类型(只接受 text 和 file )。
func validateMessage(msg Message) error {
if msg.From == "" {
return ErrMissingFrom
}
if msg.To == "" {
return ErrMissingTo
}
switch msg.Type {
case MessageTypeText:
if msg.FileName != "" {
return ErrUnexpectedFileName
}
if !utf8.Valid(msg.Body) {
return ErrInvalidTextBody
}
case MessageTypeFile:
if msg.FileName == "" {
return ErrMissingFileName
}
case MessageTypeRegister:
if msg.To != ServerPeerID {
return ErrInvalidRegisterTarget
}
if msg.FileName != "" {
return ErrUnexpectedFileName
}
if len(msg.Body) != 0 {
return ErrUnexpectedBody
}
case MessageTypeError:
if msg.From != ServerPeerID {
return ErrInvalidErrorSource
}
if msg.FileName != "" {
return ErrUnexpectedFileName
}
if !utf8.Valid(msg.Body) {
return ErrInvalidTextBody
}
default:
return ErrInvalidMessageType
}
return nil
}
// writeFull 会持续写入,直到所有字节都写完或者底层返回错误。
// 这样可以避免某些 Writer 发生部分写入时破坏帧格式。
func writeFull(w io.Writer, data []byte) error {
for len(data) > 0 {
n, err := w.Write(data)
if err != nil {
return err
}
if n == 0 {
return io.ErrShortWrite
}
data = data[n:]
}
return nil
}

View File

@@ -0,0 +1,507 @@
package protocol
import (
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
)
// TestEncodeDecodeMessageTextASCII 验证 ASCII 文本可以按 text 消息往返编解码。
func TestEncodeDecodeMessageTextASCII(t *testing.T) {
original := Message{
Type: MessageTypeText,
ID: 42,
From: "peer-a",
To: "peer-b",
Body: []byte("hello"),
}
data, err := EncodeMessage(original)
if err != nil {
t.Fatalf("EncodeMessage() error = %v", err)
}
decoded, err := DecodeMessage(data)
if err != nil {
t.Fatalf("DecodeMessage() error = %v", err)
}
if !reflect.DeepEqual(decoded, original) {
t.Fatalf("round trip mismatch: got %+v want %+v", decoded, original)
}
}
// TestEncodeDecodeMessageTextUTF8 验证 text 消息允许合法 UTF-8
// 从而天然兼容 ASCII 之外的普通文本。
func TestEncodeDecodeMessageTextUTF8(t *testing.T) {
original := Message{
Type: MessageTypeText,
ID: 43,
From: "peer-a",
To: "peer-b",
Body: []byte("你好, world"),
}
data, err := EncodeMessage(original)
if err != nil {
t.Fatalf("EncodeMessage() error = %v", err)
}
decoded, err := DecodeMessage(data)
if err != nil {
t.Fatalf("DecodeMessage() error = %v", err)
}
if !reflect.DeepEqual(decoded, original) {
t.Fatalf("round trip mismatch: got %+v want %+v", decoded, original)
}
}
// TestEncodeDecodeMessageFile 验证 file 消息会保留文件名和原始二进制正文。
func TestEncodeDecodeMessageFile(t *testing.T) {
original := Message{
Type: MessageTypeFile,
ID: 44,
From: "peer-a",
To: "peer-b",
FileName: "data.bin",
Body: []byte{0x00, 0xff, 0x10, 0x7f},
}
data, err := EncodeMessage(original)
if err != nil {
t.Fatalf("EncodeMessage() error = %v", err)
}
decoded, err := DecodeMessage(data)
if err != nil {
t.Fatalf("DecodeMessage() error = %v", err)
}
if !reflect.DeepEqual(decoded, original) {
t.Fatalf("round trip mismatch: got %+v want %+v", decoded, original)
}
}
// TestEncodeDecodeMessageRegister 验证 register 控制消息也能正常编解码。
func TestEncodeDecodeMessageRegister(t *testing.T) {
original := Message{
Type: MessageTypeRegister,
ID: 45,
From: "peer-a",
To: ServerPeerID,
Body: []byte{},
}
data, err := EncodeMessage(original)
if err != nil {
t.Fatalf("EncodeMessage() error = %v", err)
}
decoded, err := DecodeMessage(data)
if err != nil {
t.Fatalf("DecodeMessage() error = %v", err)
}
if !reflect.DeepEqual(decoded, original) {
t.Fatalf("round trip mismatch: got %+v want %+v", decoded, original)
}
}
// TestEncodeDecodeMessageError 验证 error 控制消息会保留 UTF-8 错误文本。
func TestEncodeDecodeMessageError(t *testing.T) {
original := Message{
Type: MessageTypeError,
ID: 46,
From: ServerPeerID,
To: "peer-a",
Body: []byte("unknown target"),
}
data, err := EncodeMessage(original)
if err != nil {
t.Fatalf("EncodeMessage() error = %v", err)
}
decoded, err := DecodeMessage(data)
if err != nil {
t.Fatalf("DecodeMessage() error = %v", err)
}
if !reflect.DeepEqual(decoded, original) {
t.Fatalf("round trip mismatch: got %+v want %+v", decoded, original)
}
}
// TestWriteReadFrame 单独验证最底层的长度前缀帧逻辑,
// 不依赖 Message 结构,方便确认 TCP 粘包拆包问题是否被正确处理。
func TestWriteReadFrame(t *testing.T) {
var buf bytes.Buffer
payload := []byte("header+body")
if err := WriteFrame(&buf, payload); err != nil {
t.Fatalf("WriteFrame() error = %v", err)
}
got, err := ReadFrame(&buf)
if err != nil {
t.Fatalf("ReadFrame() error = %v", err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("payload mismatch: got %q want %q", got, payload)
}
}
// TestWriteReadMessageAllowsEmptyBody 验证空文本和空文件都可以正常通过协议层,
// 因为外层帧非空的前提下,空正文是合法业务内容。
func TestWriteReadMessageAllowsEmptyBody(t *testing.T) {
tests := []struct {
name string
message Message
}{
{
name: "empty text",
message: Message{
Type: MessageTypeText,
ID: 1,
From: "peer-a",
To: "peer-b",
Body: []byte(""),
},
},
{
name: "empty file",
message: Message{
Type: MessageTypeFile,
ID: 2,
From: "peer-a",
To: "peer-b",
FileName: "empty.txt",
Body: []byte{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
if err := WriteMessage(&buf, tt.message); err != nil {
t.Fatalf("WriteMessage() error = %v", err)
}
got, err := ReadMessage(&buf)
if err != nil {
t.Fatalf("ReadMessage() error = %v", err)
}
if !reflect.DeepEqual(got, tt.message) {
t.Fatalf("round trip mismatch: got %+v want %+v", got, tt.message)
}
})
}
}
// TestWriteReadMessageRejectsInvalidMessages 验证协议层会在编码前拦住明显非法的消息。
func TestWriteReadMessageRejectsInvalidMessages(t *testing.T) {
tests := []struct {
name string
message Message
wantErr error
}{
{
name: "invalid type",
message: Message{
Type: MessageType("unknown"),
ID: 1,
From: "peer-a",
To: "peer-b",
},
wantErr: ErrInvalidMessageType,
},
{
name: "missing from",
message: Message{
Type: MessageTypeText,
ID: 2,
To: "peer-b",
},
wantErr: ErrMissingFrom,
},
{
name: "missing to",
message: Message{
Type: MessageTypeText,
ID: 3,
From: "peer-a",
},
wantErr: ErrMissingTo,
},
{
name: "text with file name",
message: Message{
Type: MessageTypeText,
ID: 4,
From: "peer-a",
To: "peer-b",
FileName: "bad.txt",
Body: []byte("hello"),
},
wantErr: ErrUnexpectedFileName,
},
{
name: "text with invalid utf8",
message: Message{
Type: MessageTypeText,
ID: 5,
From: "peer-a",
To: "peer-b",
Body: []byte{0xff, 0xfe},
},
wantErr: ErrInvalidTextBody,
},
{
name: "file without file name",
message: Message{
Type: MessageTypeFile,
ID: 6,
From: "peer-a",
To: "peer-b",
Body: []byte{0x01},
},
wantErr: ErrMissingFileName,
},
{
name: "register with wrong target",
message: Message{
Type: MessageTypeRegister,
ID: 7,
From: "peer-a",
To: "peer-b",
},
wantErr: ErrInvalidRegisterTarget,
},
{
name: "register with body",
message: Message{
Type: MessageTypeRegister,
ID: 8,
From: "peer-a",
To: ServerPeerID,
Body: []byte("unexpected"),
},
wantErr: ErrUnexpectedBody,
},
{
name: "error with wrong source",
message: Message{
Type: MessageTypeError,
ID: 9,
From: "peer-a",
To: "peer-b",
Body: []byte("bad"),
},
wantErr: ErrInvalidErrorSource,
},
{
name: "error with file name",
message: Message{
Type: MessageTypeError,
ID: 10,
From: ServerPeerID,
To: "peer-a",
FileName: "bad.txt",
Body: []byte("bad"),
},
wantErr: ErrUnexpectedFileName,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := EncodeMessage(tt.message)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("EncodeMessage() error = %v, want %v", err, tt.wantErr)
}
})
}
}
// TestReadFrameRejectsInvalidLength 验证长度为 0 的帧会被当成非法输入,
// 而不是被当成一条合法的空消息。
func TestReadFrameRejectsInvalidLength(t *testing.T) {
var buf bytes.Buffer
if err := binary.Write(&buf, binary.BigEndian, uint32(0)); err != nil {
t.Fatalf("binary.Write() error = %v", err)
}
_, err := ReadFrame(&buf)
if !errors.Is(err, ErrInvalidFrameLength) {
t.Fatalf("ReadFrame() error = %v, want %v", err, ErrInvalidFrameLength)
}
}
// TestReadFrameRejectsTooLargeFrame 验证超大帧会在分配消息体前被拒绝,
// 从而保证最大长度限制真正生效。
func TestReadFrameRejectsTooLargeFrame(t *testing.T) {
var buf bytes.Buffer
if err := binary.Write(&buf, binary.BigEndian, uint32(MaxFrameSize+1)); err != nil {
t.Fatalf("binary.Write() error = %v", err)
}
_, err := ReadFrame(&buf)
if !errors.Is(err, ErrFrameTooLarge) {
t.Fatalf("ReadFrame() error = %v, want %v", err, ErrFrameTooLarge)
}
}
// TestWriteFrameRejectsEmptyPayload 验证写入端和读取端的约束保持一致:
// 既然读取端不接受 0 长度帧,写入端也不应该产生这种帧。
func TestWriteFrameRejectsEmptyPayload(t *testing.T) {
var buf bytes.Buffer
err := WriteFrame(&buf, nil)
if !errors.Is(err, ErrInvalidFrameLength) {
t.Fatalf("WriteFrame() error = %v, want %v", err, ErrInvalidFrameLength)
}
}
// TestDecodeMessageRejectsInvalidHeaderLength 验证无法切出完整头部时会被立即拒绝。
func TestDecodeMessageRejectsInvalidHeaderLength(t *testing.T) {
tests := []struct {
name string
data []byte
}{
{
name: "too short for header len",
data: []byte{0x00, 0x00, 0x00},
},
{
name: "zero header len",
data: []byte{0x00, 0x00, 0x00, 0x00},
},
{
name: "header len exceeds payload",
data: []byte{0x00, 0x00, 0x00, 0x10, '{', '}'},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := DecodeMessage(tt.data)
if !errors.Is(err, ErrInvalidHeaderLength) {
t.Fatalf("DecodeMessage() error = %v, want %v", err, ErrInvalidHeaderLength)
}
})
}
}
// TestDecodeMessageRejectsInvalidHeaderJSON 验证头部 JSON 非法时能返回明确错误。
func TestDecodeMessageRejectsInvalidHeaderJSON(t *testing.T) {
data := append([]byte{0x00, 0x00, 0x00, 0x09}, []byte("{invalid}")...)
_, err := DecodeMessage(data)
if !errors.Is(err, ErrInvalidHeaderJSON) {
t.Fatalf("DecodeMessage() error = %v, want %v", err, ErrInvalidHeaderJSON)
}
}
// TestDecodeMessageRejectsContentLengthMismatch 验证头部声明长度和实际正文不一致时会失败。
func TestDecodeMessageRejectsContentLengthMismatch(t *testing.T) {
headerPayload, err := json.Marshal(messageHeader{
Type: MessageTypeText,
ID: 7,
From: "peer-a",
To: "peer-b",
ContentLength: 10,
})
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
var data bytes.Buffer
if err := binary.Write(&data, binary.BigEndian, uint32(len(headerPayload))); err != nil {
t.Fatalf("binary.Write() error = %v", err)
}
if _, err := data.Write(headerPayload); err != nil {
t.Fatalf("data.Write(headerPayload) error = %v", err)
}
if _, err := data.Write([]byte("hello")); err != nil {
t.Fatalf("data.Write(body) error = %v", err)
}
_, err = DecodeMessage(data.Bytes())
if !errors.Is(err, ErrInvalidContentLength) {
t.Fatalf("DecodeMessage() error = %v, want %v", err, ErrInvalidContentLength)
}
}
// TestReadMultipleMessages 模拟同一条流中连续写入 text 和 file
// 验证读取端每次都能严格停在当前帧边界,不会串包。
func TestReadMultipleMessages(t *testing.T) {
var buf bytes.Buffer
first := Message{
Type: MessageTypeText,
ID: 1,
From: "peer-a",
To: "peer-b",
Body: []byte("hello"),
}
second := Message{
Type: MessageTypeFile,
ID: 2,
From: "peer-b",
To: "peer-a",
FileName: "payload.bin",
Body: []byte{0x01, 0x02, 0x03},
}
if err := WriteMessage(&buf, first); err != nil {
t.Fatalf("WriteMessage(first) error = %v", err)
}
if err := WriteMessage(&buf, second); err != nil {
t.Fatalf("WriteMessage(second) error = %v", err)
}
gotFirst, err := ReadMessage(&buf)
if err != nil {
t.Fatalf("ReadMessage(first) error = %v", err)
}
gotSecond, err := ReadMessage(&buf)
if err != nil {
t.Fatalf("ReadMessage(second) error = %v", err)
}
if !reflect.DeepEqual(gotFirst, first) {
t.Fatalf("first message mismatch: got %+v want %+v", gotFirst, first)
}
if !reflect.DeepEqual(gotSecond, second) {
t.Fatalf("second message mismatch: got %+v want %+v", gotSecond, second)
}
}
// TestReadMessageWrapsDecodeError 验证 ReadMessage 在返回错误时会保留解码阶段上下文。
func TestReadMessageWrapsDecodeError(t *testing.T) {
var buf bytes.Buffer
if err := WriteFrame(&buf, append([]byte{0x00, 0x00, 0x00, 0x09}, []byte("{invalid}")...)); err != nil {
t.Fatalf("WriteFrame() error = %v", err)
}
_, err := ReadMessage(&buf)
if err == nil {
t.Fatal("ReadMessage() error = nil, want non-nil")
}
if !strings.Contains(err.Error(), "decode message") {
t.Fatalf("ReadMessage() error = %v, want wrapped decode error", err)
}
}

View File

@@ -0,0 +1,33 @@
package protocol
// MessageType 表示一条消息的传输类型。
// v1 只区分普通文本和文件两类负载。
type MessageType string
const (
// MessageTypeText 表示正文按 UTF-8 文本解释,天然兼容 ASCII。
MessageTypeText MessageType = "text"
// MessageTypeFile 表示正文是原始文件字节。
MessageTypeFile MessageType = "file"
// MessageTypeRegister 表示 peer 向 server 显式注册自己的身份。
MessageTypeRegister MessageType = "register"
// MessageTypeError 表示 server 向 peer 返回错误信息。
MessageTypeError MessageType = "error"
)
// ServerPeerID 是协议中约定的 server 端固定标识。
const ServerPeerID = "server"
// Message 是 peer 和 server 共用的传输消息结构。
// 头部元信息会被编码为 JSONBody 则作为原始字节拼接在头部之后。
type Message struct {
Type MessageType `json:"type"` // 消息类型,只允许 text 或 file。
ID uint64 `json:"id"` // 由发送方生成,用于追踪消息。
From string `json:"from"` // 发送方标识。
To string `json:"to"` // 接收方标识。
// FileName 仅在 Type 为 file 时使用。
FileName string `json:"file_name,omitempty"`
// Body 是真正传输的正文内容,不进入头部 JSON。
Body []byte `json:"-"`
}