Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
241 lines
6.7 KiB
Go
241 lines
6.7 KiB
Go
package engine
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os/exec"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// sh supervises /bin/sh instead of a 200 MB frozen engine. The Command hook
|
|
// exists for exactly this.
|
|
func sh(script string) func(context.Context) *exec.Cmd {
|
|
return func(ctx context.Context) *exec.Cmd {
|
|
return exec.CommandContext(ctx, "/bin/sh", "-c", script)
|
|
}
|
|
}
|
|
|
|
func waitFor(t *testing.T, s *Supervisor, want State, within time.Duration) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(within)
|
|
for time.Now().Before(deadline) {
|
|
if got, _ := s.State(); got == want {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
got, err := s.State()
|
|
t.Fatalf("state %q (err %v), want %q within %s", got, err, want, within)
|
|
}
|
|
|
|
func TestItRunsAndReportsRunning(t *testing.T) {
|
|
s := New(Options{Command: sh("sleep 5")})
|
|
s.Start()
|
|
defer s.Stop()
|
|
waitFor(t, s, Running, 2*time.Second)
|
|
}
|
|
|
|
func TestStopDoesNotTriggerARestart(t *testing.T) {
|
|
// The classic supervisor bug: the user presses Stop, the child exits, the
|
|
// loop reads that as a crash and starts it again.
|
|
s := New(Options{Command: sh("sleep 30")})
|
|
s.Start()
|
|
waitFor(t, s, Running, 2*time.Second)
|
|
s.Stop()
|
|
|
|
if got, _ := s.State(); got != Stopped {
|
|
t.Fatalf("state after Stop is %q", got)
|
|
}
|
|
if n := s.Restarts(); n != 0 {
|
|
t.Fatalf("Stop counted as %d crash-restarts", n)
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
if got, _ := s.State(); got != Stopped {
|
|
t.Fatalf("it restarted itself after Stop: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStopIsSynchronous(t *testing.T) {
|
|
// Stop must not return while the child still holds the SQLite WAL, or the
|
|
// next Start races the previous process.
|
|
s := New(Options{Command: sh("sleep 30")})
|
|
s.Start()
|
|
waitFor(t, s, Running, 2*time.Second)
|
|
done := make(chan struct{})
|
|
go func() { s.Stop(); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Stop did not return")
|
|
}
|
|
}
|
|
|
|
func TestACrashIsRestarted(t *testing.T) {
|
|
s := New(Options{Command: sh("exit 1")})
|
|
s.Start()
|
|
defer s.Stop()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if s.Restarts() >= 2 {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("only %d restarts - is it backing off correctly?", s.Restarts())
|
|
}
|
|
|
|
func TestItDoesNotSpinOnAProcessThatCannotStart(t *testing.T) {
|
|
// A tight restart loop on a broken install pins a core and fills the disk
|
|
// with log lines. Backoff must space the attempts out.
|
|
s := New(Options{Command: sh("exit 1")})
|
|
s.Start()
|
|
defer s.Stop()
|
|
time.Sleep(1500 * time.Millisecond)
|
|
// 1s + 2s backoff means at most ~2 attempts in 1.5s; a spin would be
|
|
// thousands.
|
|
if n := s.Restarts(); n > 4 {
|
|
t.Fatalf("%d restarts in 1.5s - not backing off", n)
|
|
}
|
|
}
|
|
|
|
func TestItGivesUpAfterMaxRestarts(t *testing.T) {
|
|
s := New(Options{Command: sh("exit 1"), MaxRestarts: 2})
|
|
s.Start()
|
|
defer s.Stop()
|
|
waitFor(t, s, Failed, 5*time.Second)
|
|
if _, err := s.State(); err == nil {
|
|
t.Fatal("Failed state carries no reason")
|
|
}
|
|
}
|
|
|
|
func TestEngineOutputIsCaptured(t *testing.T) {
|
|
// A crashed engine with no captured output means a site visit to diagnose.
|
|
var mu sync.Mutex
|
|
buf := &lockedBuf{mu: &mu}
|
|
s := New(Options{Command: sh("echo model-load-failed; exit 1"),
|
|
LogWriter: buf, MaxRestarts: 1})
|
|
s.Start()
|
|
defer s.Stop()
|
|
waitFor(t, s, Failed, 5*time.Second)
|
|
if got := buf.String(); !bytes.Contains([]byte(got), []byte("model-load-failed")) {
|
|
t.Fatalf("engine output not captured, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStartTwiceDoesNotRunTwoEngines(t *testing.T) {
|
|
// Two engines on one SQLite WAL and one camera is the failure this whole
|
|
// package exists to prevent.
|
|
s := New(Options{Command: sh("sleep 5")})
|
|
s.Start()
|
|
s.Start()
|
|
defer s.Stop()
|
|
waitFor(t, s, Running, 2*time.Second)
|
|
if n := s.Restarts(); n != 0 {
|
|
t.Fatalf("second Start disturbed the first: %d restarts", n)
|
|
}
|
|
}
|
|
|
|
func TestHealthReportsTheModelThatActuallyLoaded(t *testing.T) {
|
|
// A running process is not a working engine: on a memory-starved box the
|
|
// big model loses the fallback chain and the process stays up regardless.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
user, pass, ok := r.BasicAuth()
|
|
if !ok || user != "u" || pass != "p" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
w.Write([]byte(`{"status":"ok","recognition_model":"w600k_mbf.onnx",
|
|
"cameras":{"entrance":true}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := New(Options{Command: sh("sleep 1"), HealthURL: srv.URL,
|
|
User: "u", Password: "p"})
|
|
h, err := s.Health(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if h.RecognitionModel != "w600k_mbf.onnx" || !h.Cameras["entrance"] {
|
|
t.Fatalf("bad health: %+v", h)
|
|
}
|
|
}
|
|
|
|
func TestHealthFailsClosedOnBadCredentials(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer srv.Close()
|
|
s := New(Options{Command: sh("true"), HealthURL: srv.URL, User: "u", Password: "wrong"})
|
|
if _, err := s.Health(context.Background()); err == nil {
|
|
t.Fatal("401 reported as healthy")
|
|
}
|
|
}
|
|
|
|
type lockedBuf struct {
|
|
mu *sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (l *lockedBuf) Write(p []byte) (int, error) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.buf.Write(p)
|
|
}
|
|
func (l *lockedBuf) String() string {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.buf.String()
|
|
}
|
|
|
|
// The engine has to be TOLD where to post detections, and the only place that
|
|
// can happen is when the child is launched: the bridge picks a random loopback
|
|
// port after the supervisor is built, and a restarted engine has to be told
|
|
// again. This pins that the Command hook is consulted per launch rather than
|
|
// captured once - the wiring that was missing while the bridge's own doc
|
|
// comment claimed it existed.
|
|
func TestTheChildIsBuiltFreshOnEveryLaunch(t *testing.T) {
|
|
var mu sync.Mutex
|
|
url := "http://127.0.0.1:1111/e"
|
|
var seen []string
|
|
|
|
s := New(Options{Command: func(ctx context.Context) *exec.Cmd {
|
|
mu.Lock()
|
|
seen = append(seen, url)
|
|
mu.Unlock()
|
|
return exec.CommandContext(ctx, "/bin/sh", "-c", "exit 1")
|
|
}})
|
|
s.Start()
|
|
waitFor(t, s, Backoff, 2*time.Second)
|
|
|
|
// The port changes, exactly as it does when the bridge restarts.
|
|
mu.Lock()
|
|
url = "http://127.0.0.1:2222/e"
|
|
mu.Unlock()
|
|
|
|
// One backoff (1s) plus room for the relaunch.
|
|
deadline := time.Now().Add(4 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
mu.Lock()
|
|
n := len(seen)
|
|
mu.Unlock()
|
|
if n >= 2 {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
s.Stop()
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(seen) < 2 {
|
|
t.Fatalf("the command hook ran %d times, so a restart could not be told a new URL", len(seen))
|
|
}
|
|
if seen[len(seen)-1] != "http://127.0.0.1:2222/e" {
|
|
t.Fatalf("the last launch used %q - the hook captured a stale value", seen[len(seen)-1])
|
|
}
|
|
}
|