mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 17:49:59 +00:00
`go test -race ./...` on `cmd/ingestor` reports **six data races** on master. None is in production logic. All six come from test helpers that outlive the test that started them, which is why the detector blames whichever test happens to be running: two different tests failed on two consecutive runs of the same code. ## What was racing **`StartStatsFileWriter` had no way to stop.** Two tests start it at a 50ms interval, and its goroutine then runs for the rest of the process. It reads the package-level `readProcSelfIOFn` hook, which a later test replaces to inject a fake, so the write and the read race. The same leak explains the stray log lines about writing stats into temp directories that were already cleaned up. It now returns a stop function that closes the goroutine and waits for it to exit. Production ignores the return value and runs for the process lifetime exactly as before; the two tests call it through `t.Cleanup`. **The migration test read a log buffer while a goroutine wrote to it.** `log.Logger` serialises its own writes, but `logContains` read `buf.String()` outside that lock while `RunAsyncMigration` kept logging after the call that started it had returned. The capture helper now uses a mutex-protected buffer. That one is worth calling a real race rather than a test artefact: a concurrent read during a buffer grow can panic outright with "concurrent map read and map write"-class behaviour, not merely trip `-race`. ## Measured `go test -race ./...` on linux/arm64 under go1.27.1: **exit 0, zero races, 704s**. ## The CI job, and why it is shaped this way The server has had `-race` since #1208. This closes the same gap for the ingestor, which carries an `atomic.Pointer` snapshot (the region key set from #1989) whose safety has been an argument rather than a measurement. Two deliberate choices, because a check that costs too much gets switched off: - **Its own job, not a step inside "Go Build & Test".** Appending `-race` there puts its ten-odd minutes on the critical path, taking the pipeline from roughly 20 minutes to roughly 32. As a separate job it runs beside the E2E job (15-17 minutes) and hides inside that window. - **Only when `cmd/ingestor/**.go` changed**, decided by the existing change-scope job, which already gates the heavy jobs on documentation-only PRs. A frontend or docs PR cannot introduce a data race in the ingestor. Pushes to master always run it, as they already do for `code`. Nothing `needs:` the new job. Adding it to `build-and-publish` would mean a skipped race job skips everything downstream, which is the opposite of what a conditional check should do. It reports as its own check; whether that blocks a merge is a repository setting rather than workflow logic. ## Scope Test helpers, one production signature (`StartStatsFileWriter` now returns a stop function), and the workflow. No change to what the ingestor does at runtime.
170 lines
5.8 KiB
Go
170 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestProcIORate_ZeroValuePrevSuppressesRate guards against the phantom-delta
|
|
// regression from #1169: when os.Open("/proc/self/io") fails, readProcSelfIO
|
|
// now returns a zero-value procIOSnapshot (ok=false, zero time.Time). This
|
|
// asserts procIORate returns nil so no inflated rate spike appears for the
|
|
// next successful read.
|
|
func TestProcIORate_ZeroValuePrevSuppressesRate(t *testing.T) {
|
|
prev := procIOSnapshot{} // zero-value: ok=false, at=zero
|
|
cur := procIOSnapshot{
|
|
at: time.Now(),
|
|
readBytes: 1024 * 1024 * 100,
|
|
ok: true,
|
|
}
|
|
if got := procIORate(prev, cur, "2026-01-01T00:00:00Z"); got != nil {
|
|
t.Fatalf("expected nil rate when prev is zero-value (os.Open failed), got %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestProcIORate_NormalPath asserts two valid snapshots produce a non-nil rate.
|
|
func TestProcIORate_NormalPath(t *testing.T) {
|
|
base := time.Now()
|
|
prev := procIOSnapshot{at: base, readBytes: 0, ok: true}
|
|
cur := procIOSnapshot{at: base.Add(time.Second), readBytes: 1024, ok: true}
|
|
got := procIORate(prev, cur, "2026-01-01T00:00:01Z")
|
|
if got == nil {
|
|
t.Fatal("expected non-nil rate for valid prev/cur pair")
|
|
}
|
|
if got.ReadBytesPerSec != 1024.0 {
|
|
t.Errorf("ReadBytesPerSec: want 1024.0, got %v", got.ReadBytesPerSec)
|
|
}
|
|
}
|
|
|
|
// TestStatsFileWriter_PublishesProcIO asserts the ingestor's published
|
|
// stats snapshot includes a `procIO` block with the per-process I/O rate
|
|
// fields required by issue #1120 ("Both ingestor and server").
|
|
func TestStatsFileWriter_PublishesProcIO(t *testing.T) {
|
|
if _, err := os.Stat("/proc/self/io"); err != nil {
|
|
t.Skip("skip: /proc/self/io unavailable on this host")
|
|
}
|
|
dir := t.TempDir()
|
|
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
|
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
|
|
|
store, err := OpenStore(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("OpenStore: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
t.Cleanup(StartStatsFileWriter(store, 50*time.Millisecond))
|
|
|
|
// Wait for at least 2 ticks so the writer has had a chance to populate
|
|
// procIO rates from a delta.
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
var snap map[string]interface{}
|
|
for time.Now().Before(deadline) {
|
|
time.Sleep(75 * time.Millisecond)
|
|
b, err := os.ReadFile(statsPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if err := json.Unmarshal(b, &snap); err != nil {
|
|
continue
|
|
}
|
|
if _, ok := snap["procIO"]; ok {
|
|
break
|
|
}
|
|
}
|
|
|
|
pio, ok := snap["procIO"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected procIO block in stats snapshot, got: %v", snap)
|
|
}
|
|
for _, field := range []string{"readBytesPerSec", "writeBytesPerSec", "cancelledWriteBytesPerSec", "syscallsRead", "syscallsWrite"} {
|
|
v, present := pio[field]
|
|
if !present {
|
|
t.Errorf("procIO missing field %q", field)
|
|
continue
|
|
}
|
|
// #1167 must-fix #5: assert the field actually decodes as a JSON
|
|
// number, not just that the key exists. An empty PerfIOSample{}
|
|
// substruct would still serialise the keys since the inner numeric
|
|
// fields lack omitempty — without this Kind check the test would
|
|
// silently pass on an empty struct regression.
|
|
if _, isFloat := v.(float64); !isFloat {
|
|
t.Errorf("procIO[%q] expected JSON number (float64), got %T (%v)", field, v, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWriteStatsAtomic_SymlinkAtDestIsReplaced is a regression guardrail for
|
|
// #1170. The tmp side of writeStatsAtomic uses O_NOFOLLOW so a pre-planted
|
|
// symlink at path+".tmp" cannot redirect the write — but the rename target
|
|
// (`path` itself) is not protected by O_NOFOLLOW. Instead, os.Rename's
|
|
// semantics are relied upon: rename atomically replaces any existing entry
|
|
// at the destination, including a symlink, with the new regular file. The
|
|
// original symlink's target is never written through (because the write
|
|
// happened to the unrelated tmp file).
|
|
//
|
|
// This test pre-plants a symlink at `path` pointing to an unrelated target
|
|
// file and asserts:
|
|
//
|
|
// (a) post-write, path is a regular file (not a symlink), and
|
|
// (b) the original target's contents are unchanged.
|
|
//
|
|
// If a future refactor swaps os.Rename for something that follows the
|
|
// destination symlink (e.g. ioutil.WriteFile, or an open(path, O_WRONLY)
|
|
// without O_NOFOLLOW), this test will fail loudly.
|
|
func TestWriteStatsAtomic_SymlinkAtDestIsReplaced(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Unrelated target file with sentinel bytes. If writeStatsAtomic ever
|
|
// followed the symlink at `path`, it would overwrite this file.
|
|
target := filepath.Join(dir, "unrelated-target.bin")
|
|
sentinel := []byte("DO-NOT-OVERWRITE-ME-#1170")
|
|
if err := os.WriteFile(target, sentinel, 0o600); err != nil {
|
|
t.Fatalf("seed target: %v", err)
|
|
}
|
|
|
|
// Pre-plant a symlink at the destination path.
|
|
path := filepath.Join(dir, "stats.json")
|
|
if err := os.Symlink(target, path); err != nil {
|
|
t.Fatalf("symlink: %v", err)
|
|
}
|
|
|
|
payload := []byte(`{"sampledAt":"2026-01-01T00:00:00Z"}`)
|
|
if err := writeStatsAtomic(path, payload); err != nil {
|
|
t.Fatalf("writeStatsAtomic: %v", err)
|
|
}
|
|
|
|
// (a) post-write, path must NOT be a symlink.
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
t.Fatalf("lstat path: %v", err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
t.Errorf("post-write path is still a symlink (mode=%v); os.Rename should have atomically replaced it with a regular file", info.Mode())
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
t.Errorf("post-write path is not a regular file (mode=%v)", info.Mode())
|
|
}
|
|
|
|
// Path now contains the new payload.
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read path: %v", err)
|
|
}
|
|
if string(got) != string(payload) {
|
|
t.Errorf("path contents: want %q, got %q", payload, got)
|
|
}
|
|
|
|
// (b) the original symlink target must be unchanged.
|
|
gotTarget, err := os.ReadFile(target)
|
|
if err != nil {
|
|
t.Fatalf("read target: %v", err)
|
|
}
|
|
if string(gotTarget) != string(sentinel) {
|
|
t.Errorf("symlink target was clobbered: want %q, got %q", sentinel, gotTarget)
|
|
}
|
|
}
|