DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,904 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
pvfversion "pvfine/internal/version"
|
||||
)
|
||||
|
||||
func addDiffText(t *testing.T, a *pvf.Archive, path, text string) int32 {
|
||||
t.Helper()
|
||||
i, err := a.AddFileText(path, text, pvf.TypeScript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return i
|
||||
}
|
||||
func waitDiff(t *testing.T, s *ArchiveDiffService, id string) *ArchiveDiffStatus {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
status, err := s.Status(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.State == "ready" || status.State == "error" || status.State == "cancelled" {
|
||||
return status
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("comparison timed out")
|
||||
return nil
|
||||
}
|
||||
func diffFixture(t *testing.T) (*core, *ArchiveDiffService, string) {
|
||||
t.Helper()
|
||||
other := pvf.New()
|
||||
addDiffText(t, other, "equipment/same.equ", "[name]\n`same`")
|
||||
addDiffText(t, other, "equipment/changed.equ", "[name]\n`old`")
|
||||
addDiffText(t, other, "equipment/deleted.equ", "[name]\n`deleted`")
|
||||
path := filepath.Join(t.TempDir(), "other.pvf")
|
||||
if err := other.SaveAs(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := NewCore()
|
||||
if err := c.setArchive(mustReopen(t, path)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := NewArchiveDiffService(c)
|
||||
t.Cleanup(c.closeArchive)
|
||||
return c, s, path
|
||||
}
|
||||
func TestArchiveDiffWorkspaceSnapshot(t *testing.T) {
|
||||
c, s, path := diffFixture(t)
|
||||
a := c.archive
|
||||
changed, _ := a.Find("equipment/changed.equ")
|
||||
deleted, _ := a.Find("equipment/deleted.equ")
|
||||
if err := a.SetText(changed, "[name]\n`backend edit`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.RemoveFiles([]int32{deleted}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addDiffText(t, a, "equipment/added.equ", "[name]\n`added`")
|
||||
changed, _ = a.Find("equipment/changed.equ")
|
||||
original, err := a.CanonicalText(changed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkpoint := a.MutationCheckpoint()
|
||||
revision := c.batchRevision
|
||||
indexGen := c.indexGen
|
||||
generation := c.archiveGeneration
|
||||
c.editorText[changed] = "retained editor overlay"
|
||||
p, err := s.Start(ArchiveDiffRequest{Path: path, Drafts: []ArchiveDiffDraft{{FileIndex: changed, Path: "equipment/changed.equ", Text: "[name]\n`frontend draft`"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status := waitDiff(t, s, p.ID)
|
||||
if status.State != "ready" || status.Added != 1 || status.Modified != 1 || status.Deleted != 1 {
|
||||
t.Fatalf("status=%+v", status)
|
||||
}
|
||||
file, err := s.FileDiff(p.ID, "EQUIPMENT\\CHANGED.EQU")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !file.TextAvailable || !strings.Contains(file.BeforeText, "old") || !strings.Contains(file.AfterText, "frontend draft") {
|
||||
t.Fatalf("diff=%+v", file)
|
||||
}
|
||||
if now, _ := a.CanonicalText(changed); now != original || c.archive != a || a.MutationCheckpoint() != checkpoint || c.batchRevision != revision || c.indexGen != indexGen || c.archiveGeneration != generation || c.editorText[changed] != "retained editor overlay" {
|
||||
t.Fatal("comparison mutated workspace")
|
||||
}
|
||||
s.mu.Lock()
|
||||
other := s.session.other
|
||||
s.mu.Unlock()
|
||||
if other.StringTableIndexStats().MappingBuilds != 0 || other.StringTableIndexStats().TableBuilds != 0 {
|
||||
t.Fatal("comparison built string tables")
|
||||
}
|
||||
roots, err := s.Children(p.ID, "")
|
||||
if err != nil || len(roots) != 1 || roots[0].Count != 3 {
|
||||
t.Fatalf("roots=%+v error=%v", roots, err)
|
||||
}
|
||||
children, err := s.Children(p.ID, "EQUIPMENT")
|
||||
if err != nil || len(children) != 3 {
|
||||
t.Fatalf("children=%+v error=%v", children, err)
|
||||
}
|
||||
// Existing results are immutable; refresh reads the new backend payload.
|
||||
if err := a.SetText(changed, "[name]\n`refreshed`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, _ = s.FileDiff(p.ID, "equipment/changed.equ")
|
||||
if !strings.Contains(file.AfterText, "frontend draft") {
|
||||
t.Fatal("snapshot changed")
|
||||
}
|
||||
fresh, err := s.Start(ArchiveDiffRequest{Path: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if waitDiff(t, s, fresh.ID).State != "ready" {
|
||||
t.Fatal("refresh failed")
|
||||
}
|
||||
if _, err := s.Status(p.ID); err == nil {
|
||||
t.Fatal("old session retained")
|
||||
}
|
||||
file, _ = s.FileDiff(fresh.ID, "equipment/changed.equ")
|
||||
if !strings.Contains(file.AfterText, "refreshed") {
|
||||
t.Fatal("refresh missed latest content")
|
||||
}
|
||||
}
|
||||
func TestArchiveDiffLogicalComparison(t *testing.T) {
|
||||
old, new := pvf.New(), pvf.New()
|
||||
addDiffText(t, old, "Item.equ", "[name]\n`equal`")
|
||||
addDiffText(t, new, "unrelated.equ", "[name]\n`different pool layout`")
|
||||
i := addDiffText(t, new, "item.equ", "[name]\n`equal`")
|
||||
if _, err := new.RemoveFiles([]int32{0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = i
|
||||
before, err := archiveDiffSnapshot(context.Background(), old, 0, func(int) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := archiveDiffSnapshot(context.Background(), new, 0, func(int) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changes := pvfversion.Diff(before, after); len(changes) != 0 {
|
||||
t.Fatalf("false changes=%+v", changes)
|
||||
}
|
||||
old.AddFile("empty.bin", nil, 99)
|
||||
new.AddFile("empty.bin", nil, 98)
|
||||
before, _ = archiveDiffSnapshot(context.Background(), old, 0, func(int) {})
|
||||
after, _ = archiveDiffSnapshot(context.Background(), new, 0, func(int) {})
|
||||
changes := pvfversion.Diff(before, after)
|
||||
if len(changes) != 1 || changes[0].Operation != pvfversion.OperationModify {
|
||||
t.Fatalf("type change=%+v", changes)
|
||||
}
|
||||
}
|
||||
func TestArchiveDiffErrorsAndRelease(t *testing.T) {
|
||||
c, s, path := diffFixture(t)
|
||||
bad := filepath.Join(t.TempDir(), "bad.pvf")
|
||||
if err := os.WriteFile(bad, []byte("invalid"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, request := range []ArchiveDiffRequest{{Path: bad}, {Path: path, Drafts: []ArchiveDiffDraft{{FileIndex: 999, Path: "missing", Text: "invalid"}}}} {
|
||||
p, err := s.Start(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := waitDiff(t, s, p.ID); status.State != "error" || status.Error == "" {
|
||||
t.Fatalf("status=%+v", status)
|
||||
}
|
||||
}
|
||||
p, err := s.Start(ArchiveDiffRequest{Path: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := waitDiff(t, s, p.ID); status.State != "ready" || status.Added+status.Modified+status.Deleted != 0 {
|
||||
t.Fatalf("equal status=%+v", status)
|
||||
}
|
||||
s.Cancel(p.ID)
|
||||
status, _ := s.Status(p.ID)
|
||||
if status.State != "cancelled" {
|
||||
t.Fatal("not cancelled")
|
||||
}
|
||||
s.mu.Lock()
|
||||
retained := s.session.current != nil || s.session.other != nil || s.session.children != nil
|
||||
s.mu.Unlock()
|
||||
if retained {
|
||||
t.Fatal("cancel retains archives")
|
||||
}
|
||||
fresh, _ := s.Start(ArchiveDiffRequest{Path: path})
|
||||
waitDiff(t, s, fresh.ID)
|
||||
c.closeArchive()
|
||||
if _, err := s.Status(fresh.ID); err == nil {
|
||||
t.Fatal("workspace close retains session")
|
||||
}
|
||||
}
|
||||
func TestArchiveDiffRejectsDuplicatePathsAndCancellation(t *testing.T) {
|
||||
a := pvf.New()
|
||||
a.AddFile("A.bin", []byte{1}, 99)
|
||||
a.AddFile("b.bin", []byte{2}, 99)
|
||||
var packed bytes.Buffer
|
||||
if err := a.SaveTo(&packed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := packed.Bytes()
|
||||
// File-table records are plaintext: make the second entry reference the first path.
|
||||
copy(data[0x30+0x18:0x30+0x18+8], data[0x30:0x30+8])
|
||||
var parseErr error
|
||||
a, parseErr = pvf.Parse(data)
|
||||
if parseErr != nil {
|
||||
t.Fatal(parseErr)
|
||||
}
|
||||
if _, err := archiveDiffSnapshot(context.Background(), a, 0, func(int) {}); err == nil || !strings.Contains(err.Error(), "重复路径") {
|
||||
t.Fatalf("duplicate error=%v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := archiveDiffSnapshot(ctx, a, 0, func(int) {}); err != context.Canceled {
|
||||
t.Fatalf("cancel error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveDiffBinaryEmptyAndLargeDetails(t *testing.T) {
|
||||
c, s, path := diffFixture(t)
|
||||
c.archive.AddFile("custom.bin", []byte{1, 2, 3}, 99)
|
||||
addDiffText(t, c.archive, "empty.equ", "")
|
||||
// Unicode data stays cheap to synthesize and exceeds the display threshold.
|
||||
c.archive.AddFile("large.str", make([]byte, int(c.editableByteLimit())+2), pvf.TypeUnicode)
|
||||
p, err := s.Start(ArchiveDiffRequest{Path: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := waitDiff(t, s, p.ID); status.State != "ready" || status.Added != 3 {
|
||||
t.Fatalf("status=%+v", status)
|
||||
}
|
||||
binary, err := s.FileDiff(p.ID, "custom.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binary.TextAvailable || binary.Message == "" || binary.BeforeExists || !binary.AfterExists || binary.AfterSize != 3 {
|
||||
t.Fatalf("binary=%+v", binary)
|
||||
}
|
||||
empty, err := s.FileDiff(p.ID, "empty.equ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !empty.TextAvailable || empty.AfterText != "" || empty.Operation != "add" {
|
||||
t.Fatalf("empty=%+v", empty)
|
||||
}
|
||||
large, err := s.FileDiff(p.ID, "large.str")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if large.TextAvailable || !strings.Contains(large.Message, "阈值") {
|
||||
t.Fatalf("large=%+v", large)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveDiffConcurrentRestartAndCancel(t *testing.T) {
|
||||
_, s, path := diffFixture(t)
|
||||
var workers sync.WaitGroup
|
||||
errors := make(chan error, 12)
|
||||
for worker := 0; worker < 3; worker++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
for attempt := 0; attempt < 4; attempt++ {
|
||||
p, err := s.Start(ArchiveDiffRequest{Path: path})
|
||||
if err != nil {
|
||||
errors <- err
|
||||
continue
|
||||
}
|
||||
_, _ = s.Status(p.ID)
|
||||
_, _ = s.Children(p.ID, "")
|
||||
_, _ = s.FileDiff(p.ID, "equipment/changed.equ")
|
||||
s.Cancel(p.ID)
|
||||
}
|
||||
}()
|
||||
}
|
||||
workers.Wait()
|
||||
close(errors)
|
||||
for err := range errors {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final, err := s.Start(ArchiveDiffRequest{Path: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := waitDiff(t, s, final.ID); status.State != "ready" {
|
||||
t.Fatalf("final=%+v", status)
|
||||
}
|
||||
s.Release()
|
||||
if _, err := s.Status(final.ID); err == nil {
|
||||
t.Fatal("release retains final session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveDiffHashMatchesVersionSnapshot(t *testing.T) {
|
||||
a := pvf.New()
|
||||
addDiffText(t, a, "item.equ", "[name]\n`logical`")
|
||||
if _, err := a.AddFileText("text.str", "文本\r\n", pvf.TypeUnicode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.AddFile("binary.bin", []byte{0, 1, 2, 255}, 99)
|
||||
expected, err := pvfversion.SnapshotFromArchive(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual, err := archiveDiffSnapshot(context.Background(), a, 0, func(int) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, entry := range expected {
|
||||
if actual[path] != entry {
|
||||
t.Fatalf("incompatible logical hash for %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveDiffParallelSnapshotMatchesSerialAndProgress(t *testing.T) {
|
||||
a := pvf.New()
|
||||
for i := 0; i < 256; i++ {
|
||||
addDiffText(t, a, fmt.Sprintf("scripts/%04d.equ", i), fmt.Sprintf("[name]\n`item %d`\n[price]\n%d", i, i*10))
|
||||
a.AddFile(fmt.Sprintf("binary/%04d.bin", i), bytes.Repeat([]byte{byte(i)}, 1024), 99)
|
||||
}
|
||||
expected, err := pvfversion.SnapshotFromArchive(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, workers := range []int{1, 4, 13, 32} {
|
||||
t.Run(fmt.Sprint(workers), func(t *testing.T) {
|
||||
progress := 37
|
||||
actual, err := archiveDiffSnapshotWorkers(context.Background(), a, 37, func(n int) {
|
||||
if n < progress || n > 37+int(a.FileCount()) {
|
||||
t.Errorf("invalid progress %d after %d", n, progress)
|
||||
}
|
||||
progress = n
|
||||
}, workers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatal("parallel snapshot differs from serial logical snapshot")
|
||||
}
|
||||
if progress != 37+int(a.FileCount()) {
|
||||
t.Fatalf("incomplete progress %d", progress)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := archiveDiffWorkerCount(a.FileCount()); got != min(int(a.FileCount()), max(1, runtime.GOMAXPROCS(0)-1)) {
|
||||
t.Fatalf("worker count %d does not follow available CPUs", got)
|
||||
}
|
||||
if archiveDiffWorkerCount(1) != 1 || archiveDiffWorkerCount(0) != 0 {
|
||||
t.Fatal("worker count exceeds file count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveDiffParallelCancellationDrainsWorkers(t *testing.T) {
|
||||
a := pvf.New()
|
||||
for i := 0; i < 512; i++ {
|
||||
a.AddFile(fmt.Sprintf("%04d.bin", i), bytes.Repeat([]byte{byte(i)}, 4096), 99)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
last := 0
|
||||
_, err := archiveDiffSnapshotWorkers(ctx, a, 0, func(n int) { last = n; cancel() }, 13)
|
||||
if err != context.Canceled {
|
||||
t.Fatalf("cancellation error=%v", err)
|
||||
}
|
||||
if last != 1 {
|
||||
t.Fatalf("progress continued after cancellation: %d", last)
|
||||
}
|
||||
// A fresh scan must remain usable after all cancelled workers exit.
|
||||
result, err := archiveDiffSnapshotWorkers(context.Background(), a, 0, func(int) {}, 13)
|
||||
if err != nil || len(result) != int(a.FileCount()) {
|
||||
t.Fatalf("fresh scan=%d error=%v", len(result), err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkArchiveDiffParallelSnapshot(b *testing.B) {
|
||||
for _, kind := range []string{"script", "binary"} {
|
||||
a := pvf.New()
|
||||
for i := 0; i < 1024; i++ {
|
||||
path := fmt.Sprintf("files/%04d", i)
|
||||
if kind == "script" {
|
||||
if _, err := a.AddFileText(path+".equ", "[values]\n"+strings.Repeat("1 2 3 4 5 6 7 8\n", 128), pvf.TypeScript); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
a.AddFile(path+".bin", bytes.Repeat([]byte{byte(i)}, 64<<10), 99)
|
||||
}
|
||||
}
|
||||
for _, workers := range []int{1, 4, 13} {
|
||||
b.Run(fmt.Sprintf("%s/workers-%d", kind, workers), func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := archiveDiffSnapshotWorkers(context.Background(), a, 0, func(int) {}, workers); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Previous text-hash implementation is kept only as a correctness/performance oracle.
|
||||
func archiveDiffSnapshot(ctx context.Context, a *pvf.Archive, offset int, progress func(int)) (pvfversion.Snapshot, error) {
|
||||
return archiveDiffSnapshotWorkers(ctx, a, offset, progress, archiveDiffWorkerCount(a.FileCount()))
|
||||
}
|
||||
|
||||
type archiveDiffHashResult struct {
|
||||
key string
|
||||
entry pvfversion.Entry
|
||||
err error
|
||||
}
|
||||
|
||||
func archiveDiffSnapshotWorkers(parent context.Context, a *pvf.Archive, offset int, progress func(int), workers int) (pvfversion.Snapshot, error) {
|
||||
if err := parent.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(pvfversion.Snapshot, a.FileCount())
|
||||
if a.FileCount() == 0 {
|
||||
progress(offset)
|
||||
return result, nil
|
||||
}
|
||||
workers = min(int(a.FileCount()), max(1, workers))
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
// Queue indexes rather than decoded payloads. Each worker retains only the
|
||||
// file it is hashing; the bounded result queue contains compact hashes.
|
||||
jobs := make(chan int32)
|
||||
results := make(chan archiveDiffHashResult, workers)
|
||||
largeFile := make(chan struct{}, 1)
|
||||
var done sync.WaitGroup
|
||||
done.Add(workers + 1)
|
||||
go func() {
|
||||
defer done.Done()
|
||||
defer close(jobs)
|
||||
for i := int32(0); i < a.FileCount(); i++ {
|
||||
select {
|
||||
case jobs <- i:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
for worker := 0; worker < workers; worker++ {
|
||||
go func() {
|
||||
defer done.Done()
|
||||
for {
|
||||
var index int32
|
||||
var ok bool
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case index, ok = <-jobs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
file := a.File(index)
|
||||
// Large logical text expansion is the main transient memory risk. Allow
|
||||
// at most one >=8 MiB payload to decode/hash at a time, independently of
|
||||
// the CPU-based worker count used for ordinary files.
|
||||
large := file.DataSize >= 8<<20
|
||||
if large {
|
||||
select {
|
||||
case largeFile <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
value := archiveDiffHashFile(ctx, a, index, file)
|
||||
if large {
|
||||
<-largeFile
|
||||
}
|
||||
select {
|
||||
case results <- value:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if value.err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() { done.Wait(); close(results) }()
|
||||
processed := 0
|
||||
var firstErr error
|
||||
// One collector owns the map and progress updates, keeping progress ordered
|
||||
// even when file computations finish out of order. Drain after cancellation
|
||||
// so no producer or worker survives a released comparison task.
|
||||
for value := range results {
|
||||
if firstErr != nil || ctx.Err() != nil {
|
||||
continue
|
||||
}
|
||||
if value.err != nil {
|
||||
firstErr = value.err
|
||||
} else if _, exists := result[value.key]; exists {
|
||||
firstErr = fmt.Errorf("归档包含重复路径 %q,无法唯一比较", value.entry.Path)
|
||||
}
|
||||
if firstErr != nil {
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
result[value.key] = value.entry
|
||||
processed++
|
||||
if processed == 1 || processed%64 == 0 {
|
||||
progress(offset + processed)
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
if err := parent.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
progress(offset + processed)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func archiveDiffHashFile(ctx context.Context, a *pvf.Archive, index int32, file pvf.File) archiveDiffHashResult {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return archiveDiffHashResult{err: err}
|
||||
}
|
||||
path := pvfversion.DisplayPath(a.Path(index))
|
||||
key := pvfversion.CanonicalPath(path)
|
||||
if key == "" {
|
||||
return archiveDiffHashResult{err: fmt.Errorf("归档中存在空路径")}
|
||||
}
|
||||
var data []byte
|
||||
var err error
|
||||
if file.DataType == pvf.TypeScript || file.DataType == pvf.TypeUnicode {
|
||||
var value string
|
||||
value, err = a.CanonicalText(index)
|
||||
data = []byte(value)
|
||||
} else {
|
||||
data, err = a.RawBytes(index)
|
||||
}
|
||||
if err != nil {
|
||||
return archiveDiffHashResult{err: fmt.Errorf("读取 %s 失败: %w", path, err)}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return archiveDiffHashResult{err: err}
|
||||
}
|
||||
return archiveDiffHashResult{key: key, entry: pvfversion.Entry{Path: path, DataType: file.DataType, Hash: archiveDiffContentHash(file.DataType, data)}}
|
||||
}
|
||||
|
||||
func TestArchiveDiffOptimizedComparisonMatchesCanonicalOracle(t *testing.T) {
|
||||
before, after := pvf.New(), pvf.New()
|
||||
addDiffText(t, after, "temporary.equ", "[name]\n`different pool offsets`")
|
||||
for i := 0; i < 256; i++ {
|
||||
path := fmt.Sprintf("equipment/%03d.equ", i)
|
||||
text := fmt.Sprintf("[name]\n`item %d`\n[price]\n%d", i, i*10)
|
||||
addDiffText(t, before, path, text)
|
||||
addDiffText(t, after, path, text)
|
||||
}
|
||||
if _, err := after.RemoveFiles([]int32{0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index, _ := after.Find("equipment/010.equ")
|
||||
if err := after.SetText(index, "[name]\n`changed`\n[price]\n42"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index, _ = after.Find("equipment/020.equ")
|
||||
if _, err := after.RemoveFiles([]int32{index}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addDiffText(t, after, "equipment/new.equ", "[name]\n`added`")
|
||||
before.AddFile("fallback.equ", []byte{1, 1, 0, 0, 0}, pvf.TypeScript)
|
||||
after.AddFile("fallback.equ", []byte{9, 2, 0, 0, 0}, pvf.TypeScript) // Both ignored by canonical rendering.
|
||||
before.AddFile("trailing.equ", []byte{0, 1, 0, 0, 0}, pvf.TypeScript)
|
||||
after.AddFile("trailing.equ", []byte{0, 1, 0, 0, 0, 255}, pvf.TypeScript)
|
||||
before.AddFile("text.str", []byte{'A', 0}, pvf.TypeUnicode)
|
||||
after.AddFile("text.str", []byte{'A', 0, 255}, pvf.TypeUnicode) // UTF-16 decoder ignores the odd tail.
|
||||
before.AddFile("nan.equ", []byte{2, 1, 0, 192, 127}, pvf.TypeScript)
|
||||
after.AddFile("nan.equ", []byte{2, 2, 0, 192, 127}, pvf.TypeScript)
|
||||
before.AddFile("type.bin", []byte{1}, 99)
|
||||
after.AddFile("type.bin", []byte{1}, 100)
|
||||
expectedBefore, err := pvfversion.SnapshotFromArchive(before)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedAfter, err := pvfversion.SnapshotFromArchive(after)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := pvfversion.Diff(expectedBefore, expectedAfter)
|
||||
previous := 0
|
||||
actual, err := archiveDiffCompare(context.Background(), before, after, func(n int) {
|
||||
if n < previous {
|
||||
t.Errorf("progress regressed: %d -> %d", previous, n)
|
||||
}
|
||||
previous = n
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(actual) != len(expected) {
|
||||
t.Fatalf("optimized changes=%+v expected=%+v", actual, expected)
|
||||
}
|
||||
for i := range actual {
|
||||
if actual[i].Path != expected[i].Path || actual[i].Operation != expected[i].Operation {
|
||||
t.Fatalf("change=%+v expected=%+v", actual[i], expected[i])
|
||||
}
|
||||
if actual[i].BeforeHash != "" && actual[i].BeforeHash != expected[i].BeforeHash {
|
||||
t.Fatal("before hash no longer canonical")
|
||||
}
|
||||
if actual[i].AfterHash != "" && actual[i].AfterHash != expected[i].AfterHash {
|
||||
t.Fatal("after hash no longer canonical")
|
||||
}
|
||||
}
|
||||
if previous != int(before.FileCount()+after.FileCount()) {
|
||||
t.Fatal("progress did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveDiffSkipsUnpairedPayloadAndHashesDetailsOnDemand(t *testing.T) {
|
||||
c, s, path := diffFixture(t)
|
||||
added := addDiffText(t, c.archive, "added.equ", "[price]\n42")
|
||||
p, err := s.Start(ArchiveDiffRequest{Path: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := waitDiff(t, s, p.ID); status.State != "ready" {
|
||||
t.Fatalf("status=%+v", status)
|
||||
}
|
||||
s.mu.Lock()
|
||||
change := s.session.changes["added.equ"]
|
||||
s.mu.Unlock()
|
||||
if change.AfterHash != "" {
|
||||
t.Fatal("added file was unnecessarily hashed during classification")
|
||||
}
|
||||
meta, err := s.FileDiff(p.ID, "added.equ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, err := c.archive.CanonicalText(added)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !meta.TextAvailable || meta.AfterHash != archiveDiffContentHash(pvf.TypeScript, []byte(text)) {
|
||||
t.Fatalf("lazy detail=%+v", meta)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := archiveDiffCompare(ctx, c.archive, c.archive, func(int) {}); err != context.Canceled {
|
||||
t.Fatalf("cancel=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkArchiveDiffOptimizedComparison(b *testing.B) {
|
||||
a := pvf.New()
|
||||
for i := 0; i < 2048; i++ {
|
||||
if _, err := a.AddFileText(fmt.Sprintf("items/%04d.equ", i), "[name]\n`common value`\n[values]\n"+strings.Repeat("1 2 3 4 5 6 7 8\n", 64), pvf.TypeScript); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
var packed bytes.Buffer
|
||||
if err := a.SaveTo(&packed); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
before, err := pvf.Parse(packed.Bytes())
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
after, err := pvf.Parse(packed.Bytes())
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
for _, kind := range []string{"canonical-text", "token-chunks"} {
|
||||
b.Run(kind, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if kind == "canonical-text" {
|
||||
old, err := archiveDiffSnapshot(context.Background(), before, 0, func(int) {})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
next, err := archiveDiffSnapshot(context.Background(), after, 0, func(int) {})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if len(pvfversion.Diff(old, next)) != 0 {
|
||||
b.Fatal("fixture differs")
|
||||
}
|
||||
} else {
|
||||
changes, err := archiveDiffCompare(context.Background(), before, after, func(int) {})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if len(changes) != 0 {
|
||||
b.Fatal("fixture differs")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkArchiveDiffOversizedColdChunk(b *testing.B) {
|
||||
a := pvf.New()
|
||||
a.AddFile("padding.bin", make([]byte, (64<<20)+1), 99)
|
||||
for i := 0; i < 8; i++ {
|
||||
if _, err := a.AddFileText(fmt.Sprintf("items/%02d.equ", i), "[name]\n`shared value`\n[price]\n42", pvf.TypeScript); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
var packed bytes.Buffer
|
||||
if err := a.SaveTo(&packed); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
data := packed.Bytes()
|
||||
for _, kind := range []string{"canonical-text", "token-chunks"} {
|
||||
b.Run(kind, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
before, err := pvf.Parse(data)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
after, err := pvf.Parse(data)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if kind == "canonical-text" {
|
||||
old, err := archiveDiffSnapshot(context.Background(), before, 0, func(int) {})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
next, err := archiveDiffSnapshot(context.Background(), after, 0, func(int) {})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if len(pvfversion.Diff(old, next)) != 0 {
|
||||
b.Fatal("fixture differs")
|
||||
}
|
||||
} else {
|
||||
changes, err := archiveDiffCompare(context.Background(), before, after, func(int) {})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if len(changes) != 0 {
|
||||
b.Fatal("fixture differs")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveDiffDetailCacheIsIsolatedAndReleased(t *testing.T) {
|
||||
c, s, path := diffFixture(t)
|
||||
i, _ := c.archive.Find("equipment/changed.equ")
|
||||
if err := c.archive.SetText(i, "[name]\n`cached`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := s.Start(ArchiveDiffRequest{Path: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := waitDiff(t, s, p.ID); status.State != "ready" {
|
||||
t.Fatalf("status=%+v", status)
|
||||
}
|
||||
first, err := s.FileDiff(p.ID, "equipment/changed.equ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := *first
|
||||
first.AfterText = "caller mutation"
|
||||
again, err := s.FileDiff(p.ID, "EQUIPMENT\\CHANGED.EQU")
|
||||
if err != nil || *again != want {
|
||||
t.Fatalf("cached value was mutated: %v", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
cached := s.session.detail
|
||||
s.mu.Unlock()
|
||||
if cached == nil {
|
||||
t.Fatal("small detail was not cached")
|
||||
}
|
||||
s.Release()
|
||||
if _, err := s.FileDiff(p.ID, "equipment/changed.equ"); err == nil {
|
||||
t.Fatal("released detail remained accessible")
|
||||
}
|
||||
}
|
||||
|
||||
func archiveDiffLegacyChildren(changes []pvfversion.FileChange) map[string][]ArchiveDiffNode {
|
||||
nodes := make(map[string]map[string]ArchiveDiffNode)
|
||||
for _, change := range changes {
|
||||
parts := strings.Split(change.DisplayPath, "/")
|
||||
parent := ""
|
||||
for i, name := range parts {
|
||||
path := strings.Join(parts[:i+1], "/")
|
||||
key := pvfversion.CanonicalPath(path)
|
||||
if nodes[parent] == nil {
|
||||
nodes[parent] = make(map[string]ArchiveDiffNode)
|
||||
}
|
||||
node := nodes[parent][key]
|
||||
node.Path = path
|
||||
node.Name = name
|
||||
node.Count++
|
||||
node.IsDir = i < len(parts)-1
|
||||
if !node.IsDir {
|
||||
node.Operation = string(change.Operation)
|
||||
}
|
||||
nodes[parent][key] = node
|
||||
parent = key
|
||||
}
|
||||
}
|
||||
result := make(map[string][]ArchiveDiffNode, len(nodes))
|
||||
for parent, entries := range nodes {
|
||||
list := make([]ArchiveDiffNode, 0, len(entries))
|
||||
for _, node := range entries {
|
||||
list = append(list, node)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].IsDir != list[j].IsDir {
|
||||
return list[i].IsDir
|
||||
}
|
||||
return strings.ToLower(list[i].Name) < strings.ToLower(list[j].Name)
|
||||
})
|
||||
result[parent] = list
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func diffTreeChanges(n int) []pvfversion.FileChange {
|
||||
changes := make([]pvfversion.FileChange, n)
|
||||
for i := range changes {
|
||||
path := fmt.Sprintf("equipment/group%03d/subdir/item%06d.equ", i%100, i)
|
||||
changes[i] = pvfversion.FileChange{Path: path, DisplayPath: path, Operation: pvfversion.OperationModify}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func TestArchiveDiffTreeMatchesPreviousAndCancels(t *testing.T) {
|
||||
changes := diffTreeChanges(4096)
|
||||
got := archiveDiffChildren(changes)
|
||||
if !reflect.DeepEqual(got, archiveDiffLegacyChildren(changes)) {
|
||||
t.Fatal("tree paths, order or directory counts changed")
|
||||
}
|
||||
if len(archiveDiffChildren(nil)) != 0 {
|
||||
t.Fatal("empty comparison has tree nodes")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
_, err := archiveDiffBuildChildren(ctx, changes, func(state string, n, total int) {
|
||||
if state == "building" && n >= 64 {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
if err != context.Canceled {
|
||||
t.Fatalf("tree cancellation=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveDiffReportsTextVerificationPhase(t *testing.T) {
|
||||
before, after := pvf.New(), pvf.New()
|
||||
addDiffText(t, before, "changed.equ", "[name]\n`old`")
|
||||
addDiffText(t, after, "changed.equ", "[name]\n`new`")
|
||||
var phases []string
|
||||
last := map[string]int{}
|
||||
changes, err := archiveDiffComparePhases(context.Background(), before, after, func(int) {}, func(state string, n, total int) {
|
||||
if n < last[state] || n > total {
|
||||
t.Errorf("invalid phase progress %s: %d/%d", state, n, total)
|
||||
}
|
||||
last[state] = n
|
||||
phases = append(phases, state)
|
||||
})
|
||||
if err != nil || len(changes) != 1 || last["verifying"] != 2 {
|
||||
t.Fatalf("missing text verification progress: %v %v", phases, err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkArchiveDiffTree(b *testing.B) {
|
||||
changes := diffTreeChanges(100000)
|
||||
for _, kind := range []string{"previous", "directory-cache"} {
|
||||
b.Run(kind, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
var nodes map[string][]ArchiveDiffNode
|
||||
if kind == "previous" {
|
||||
nodes = archiveDiffLegacyChildren(changes)
|
||||
} else {
|
||||
nodes = archiveDiffChildren(changes)
|
||||
}
|
||||
if len(nodes["equipment"]) != 100 {
|
||||
b.Fatal("incorrect tree")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user