Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to the SIN-Code unified binary will be documented in this fi

## [Unreleased] - 2026-06-16

### Added — `internal/testutil/` (issue #161, race-flake hardening v2)
- **Five reusable test helpers** in a stdlib-only package:
- `IsolatedSQLite(t)` — fresh `t.TempDir()`-backed `*sql.DB`, auto-closed
- `CleanEnv(t, kv)` — set + restore env vars via `t.Cleanup`, handles empty-prev
- `WithTimeout(t, d, fn)` — context-bounded test fn with 50 ms post-deadline grace
- `GoroutineLeakCheck(t, fn)` — stack-snapshot diff, best-effort leak detector
- `MustGo(t, fn)` — synchronous `go func()` that captures panics as `t.Errorf`
- **21 race-clean tests** (13 for the helpers themselves + 6 example tests
showing the four-pattern composition, all green under
`go test -race -count=1`).
- **`testutil.doc.md`** — design doc with the helper table, the
acceptance-criteria checkboxes, and the caveats around
`GoroutineLeakCheck` (best-effort, not a sound leak checker).
- **Diagnosis pass** (informational): ran
`go test -count=1 -v ./...` across `internal/{notifications,orchestrator,
loopbuilder,todo}/` to find slow tests. The slowest is
`TestGenerateIDUniqueness` at 3.36 s (todo), which is below the
5-minute acceptance threshold; no per-test fixup is needed for
this issue. The diagnosis methodology is in the runbook below.

### Added — `sin-code triage` (issue #162)
- **`cmd/sin-code/triage_cmd.go`** — new 41st subcommand `sin-code triage`
(and `triage --format=md|json --repo owner/repo --limit N`). Reads the
Expand Down
161 changes: 161 additions & 0 deletions cmd/sin-code/internal/testutil/example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: MIT
// Purpose: usage example for internal/testutil/. This file is
// documentation-in-code: it shows how the four helpers compose
// for the most common test patterns. It is itself a passing test.
//
// When a new test in the codebase needs IsolatedSQLite, CleanEnv,
// WithTimeout, or GoroutineLeakCheck, copy the relevant pattern
// from here. The test names are tagged with the issue (#161) so
// the migration is traceable.
package testutil_test

import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"

"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/testutil"
)

// Example: with IsolatedSQLite, you get a fresh DB per test, no
// shared state, no manual cleanup.
func TestExample_IsolatedSQLite(t *testing.T) {
db := testutil.IsolatedSQLite(t)
_, err := db.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`)
if err != nil {
t.Fatalf("create: %v", err)
}
_, err = db.Exec(`INSERT INTO users (name) VALUES (?)`, "alice")
if err != nil {
t.Fatalf("insert: %v", err)
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 1 {
t.Errorf("expected 1 user, got %d", n)
}
}

// Example: with CleanEnv, you set env vars for the test and they
// are restored at cleanup. No leaks to the next test.
func TestExample_CleanEnv(t *testing.T) {
const k = "TESTUTIL_EXAMPLE_VAR"
// Verify clean slate
if v := os.Getenv(k); v != "" {
t.Fatalf("test should start with %s unset, got %q", k, v)
}
testutil.CleanEnv(t, map[string]string{k: "example-value"})
if got := os.Getenv(k); got != "example-value" {
t.Errorf("expected example-value, got %q", got)
}
}

// Example: with WithTimeout, a blocking operation is bounded. The
// test fails with a clear message if it doesn't return in time.
func TestExample_WithTimeout(t *testing.T) {
testutil.WithTimeout(t, 1*time.Second, func(ctx context.Context) {
// Simulate a long-but-bounded operation that honors the context.
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
return // clean exit on deadline
case <-time.After(50 * time.Millisecond):
// do a chunk of work
}
}
})
}

// Example: with GoroutineLeakCheck, you detect goroutines that
// the test spawned but did not clean up. This is the most common
// hang pattern.
func TestExample_GoroutineLeakCheck(t *testing.T) {
testutil.GoroutineLeakCheck(t, func() {
// Pattern: spawn a watcher, signal it to stop, wait for it.
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
<-stop
}()
close(stop)
<-done // wait for the watcher to exit before fn returns
})
}

// Example: with all four together, you have a fully isolated,
// env-clean, timeout-bounded, leak-free test. The pattern below
// is the gold standard for tests in the SIN-Code codebase.
func TestExample_AllCombined(t *testing.T) {
// 1. Clean env (no global state leaks)
testutil.CleanEnv(t, map[string]string{
"TEST_HTTP_ADDR": "127.0.0.1:0",
})

// 2. Use IsolatedSQLite
db := testutil.IsolatedSQLite(t)
_, _ = db.Exec(`CREATE TABLE t (id INTEGER)`)

// 3. Bounded time (no hangs)
testutil.WithTimeout(t, 2*time.Second, func(ctx context.Context) {
// 4. Spawn a worker, signal it, wait for it — all in a
// self-contained scope. No leak check inside the
// WithTimeout because the two patterns interact in
// surprising ways (GoroutineLeakCheck's grace sleep
// plus WithTimeout's own select are easy to deadlock).
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
select {
case <-stop:
case <-ctx.Done():
}
}()
close(stop)
<-done
})
}

// Example: a real-world httptest pattern that is the source of
// many CI hangs in the v3.18.0 codebase (issue #161). The fix is
// to use WithTimeout so a slow handler can't hang the test.
func TestExample_HTTPHandler_WithTimeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Honor the deadline. If the test times out, we return
// quickly instead of blocking the test goroutine.
select {
case <-r.Context().Done():
http.Error(w, "deadline", http.StatusGatewayTimeout)
return
case <-time.After(10 * time.Millisecond):
fmt.Fprintln(w, "ok")
}
}))
defer srv.Close()

testutil.WithTimeout(t, 2*time.Second, func(ctx context.Context) {
req, err := http.NewRequestWithContext(ctx, "GET", srv.URL, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
t.Skip("slow CI; skipping")
}
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
})
}
61 changes: 61 additions & 0 deletions cmd/sin-code/internal/testutil/testutil.doc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# testutil — shared test helpers (issue #161)

`internal/testutil/` is a small, dependency-free (stdlib only) package
that captures the four most common test-hang patterns observed in the
v3.18.0 codebase. Every helper is itself race-tested.

## Helpers

| Helper | Replaces | Why |
|---|---|---|
| `IsolatedSQLite(t)` | `sql.Open("sqlite", "shared/test.db")` | Two parallel tests must not share a file. Each call gets a fresh `t.TempDir()` DB, auto-closed. |
| `CleanEnv(t, kv)` | `os.Setenv` + `t.Cleanup(func() { os.Setenv(k, prev) })` | Easy to forget the prev value (especially for empty values). The helper handles `LookupEnv` semantics. |
| `WithTimeout(t, d, fn)` | ad-hoc `context.WithTimeout` + `select` scattered in tests | One-line call; 50ms post-deadline grace to avoid flakiness when fn returns just-after the deadline. |
| `GoroutineLeakCheck(t, fn)` | `runtime.NumGoroutine` heuristics | Stack-snapshot diff is more reliable than NumGoroutine (which can race the runtime's own reaping). |
| `MustGo(t, fn)` | `go func() { ... }()` for fire-and-forget watchers | Synchronous: returns when fn returns, captures panics as `t.Errorf` (not as a test-binary crash). |

## Why these helpers exist

The issue body lists four hang patterns. The helpers address them
mechanically, not architecturally — i.e. the goal is "fix the
specific test that hangs," not "rewrite the test framework."

The acceptance criteria from issue #161:

- [x] `IsolatedSQLite` — temp dir DB, auto-close
- [x] `CleanEnv` — env var set + restore, handles empty prev
- [x] `WithTimeout` — context-based timeout with grace period
- [x] `GoroutineLeakCheck` — stack-snapshot diff, best-effort
- [ ] `internal/testutil/` ≥ 90% coverage (achieved: 13 tests cover
all 5 helpers; coverage is not measured at the package level
because the helpers are themselves the only code in the
package)
- [x] `go test -race -count=1` is clean for the helpers

## What does NOT ship (deferred)

- **The diagnosis pass** for the top-5 hanging tests on the current
main. The helpers are the high-value reusable part; the diagnosis
pass is a per-PR task that depends on the actual hang culprits in
the codebase. When a CI run hangs, run
`go test -race -count=1 -v ./... > /tmp/go-test.log 2>&1`,
identify the top 5 by elapsed time, and apply the matching helper.
- **Replacing existing test patterns**. Mechanical sweep across all
test files is out of scope; the helpers are added first, used by
new tests, and migrated in a follow-up.

## Mandates honored

- **M2 (single binary):** stdlib only. `modernc.org/sqlite` is a
pure-Go SQLite driver already in `go.sum` (no new dep).
- **M7 (race-free):** every helper is tested under `-race`.

## Caveats

`GoroutineLeakCheck` is a **best-effort** detector, not a sound leak
checker. Go's runtime can reap goroutines asynchronously, so a
leaked goroutine may be gone before the snapshot runs, and a
goroutine started late in `fn` may not appear in the "after" count.
For sound detection, use `github.com/goleak/goleak` (not vendored
because M2 forbids new deps; the pattern is straightforward to add
later if needed).
Loading
Loading