Seen on the demo PC: Start did nothing and Stop stayed grey. The supervisor's engine had failed because a second engine already held port 8010, and the tray reported that as nothing at all. The supervisor now keeps the engine's last lines and turns the known ones into a sentence - 'port 8010 is already in use - another Behavision or its engine is still running', 'run behavision-setup again' - which the tray and the window show. Tray clicks no longer run on the menu loop, so a stop that waits for the process cannot make the menu look dead. Two ways that second process came to exist are closed: setup refuses to run while Behavision.exe or the agent is up, and the app watches agent.json so a claim made underneath it - which rotates the API token - is picked up instead of leaving camera sync refused until a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
421 lines
12 KiB
Go
421 lines
12 KiB
Go
// Package engine starts, watches and stops the Python recognition engine.
|
|
//
|
|
// Go cannot run ONNX, OpenCV or FAISS, so the engine stays Python and ships
|
|
// frozen. What Go owns is its lifecycle: start it, keep it up, capture its
|
|
// output, and stop it when the user asks — which is what the tray's start/stop
|
|
// buttons actually drive.
|
|
//
|
|
// Deliberately not a Windows service. A service runs in session 0 and cannot
|
|
// draw a tray icon, and spawning a child process needs no elevation while
|
|
// controlling a service does. A service wrapper can be layered on later
|
|
// without touching anything here.
|
|
package engine
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// State is what the tray icon colours itself from.
|
|
type State string
|
|
|
|
const (
|
|
Stopped State = "stopped" // not running, and not meant to be
|
|
Starting State = "starting" // process spawned, not yet answering
|
|
Running State = "running" // answering /api/health
|
|
Backoff State = "backoff" // crashed, waiting to retry
|
|
Failed State = "failed" // gave up
|
|
)
|
|
|
|
const (
|
|
minBackoff = 1 * time.Second
|
|
maxBackoff = 30 * time.Second
|
|
// A run that lasted this long counts as healthy, so the next crash starts
|
|
// its backoff from the bottom again. Without this a process that runs fine
|
|
// for hours and then crashes once waits the full 30s to come back.
|
|
stableRun = 60 * time.Second
|
|
// How long a stopping process gets to exit on its own before it is killed.
|
|
stopGrace = 10 * time.Second
|
|
)
|
|
|
|
// Options configures a Supervisor.
|
|
type Options struct {
|
|
// Command builds the process to run. Injected rather than hardcoded so
|
|
// tests can supervise /bin/sh instead of a 200 MB frozen engine.
|
|
Command func(ctx context.Context) *exec.Cmd
|
|
// LogWriter receives the engine's stdout and stderr. A crashed engine with
|
|
// no captured output is undiagnosable, which on a customer site means a
|
|
// site visit.
|
|
LogWriter io.Writer
|
|
// HealthURL, StatsURL, User, Password address the engine's own API.
|
|
HealthURL string
|
|
StatsURL string
|
|
User string
|
|
Password string
|
|
// MaxRestarts of 0 means never give up. Non-zero is for tests.
|
|
MaxRestarts int
|
|
now func() time.Time
|
|
}
|
|
|
|
// Supervisor keeps one engine process running. Safe for concurrent use.
|
|
type Supervisor struct {
|
|
opts Options
|
|
|
|
mu sync.Mutex
|
|
state State
|
|
lastErr error
|
|
restarts int
|
|
cancel context.CancelFunc
|
|
done chan struct{}
|
|
}
|
|
|
|
func New(opts Options) *Supervisor {
|
|
if opts.LogWriter == nil {
|
|
opts.LogWriter = io.Discard
|
|
}
|
|
if opts.now == nil {
|
|
opts.now = time.Now
|
|
}
|
|
return &Supervisor{opts: opts, state: Stopped}
|
|
}
|
|
|
|
// Start launches the engine and keeps it running until Stop. Calling it while
|
|
// already running is a no-op rather than a second process — two engines on one
|
|
// SQLite WAL and one camera is exactly the failure this package exists to
|
|
// avoid.
|
|
func (s *Supervisor) Start() {
|
|
s.mu.Lock()
|
|
if s.cancel != nil {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.cancel = cancel
|
|
s.done = make(chan struct{})
|
|
s.state = Starting
|
|
s.restarts = 0
|
|
done := s.done
|
|
s.mu.Unlock()
|
|
|
|
go s.supervise(ctx, done)
|
|
}
|
|
|
|
// Stop asks the engine to exit and waits for it.
|
|
func (s *Supervisor) Stop() {
|
|
s.mu.Lock()
|
|
cancel, done := s.cancel, s.done
|
|
s.cancel = nil
|
|
s.mu.Unlock()
|
|
if cancel == nil {
|
|
return
|
|
}
|
|
cancel()
|
|
if done != nil {
|
|
<-done
|
|
}
|
|
s.setState(Stopped, nil)
|
|
}
|
|
|
|
// State reports what the supervisor is doing, plus the last error if any.
|
|
func (s *Supervisor) State() (State, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.state, s.lastErr
|
|
}
|
|
|
|
// Restarts counts crash-restarts since Start.
|
|
func (s *Supervisor) Restarts() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.restarts
|
|
}
|
|
|
|
// -- the loop --------------------------------------------------------------
|
|
|
|
func (s *Supervisor) supervise(ctx context.Context, done chan struct{}) {
|
|
defer close(done)
|
|
backoff := minBackoff
|
|
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
s.setState(Starting, nil)
|
|
started := s.opts.now()
|
|
err := s.runOnce(ctx)
|
|
ran := s.opts.now().Sub(started)
|
|
|
|
// A cancelled context means the user pressed Stop. Exiting then is
|
|
// success, not a crash, and restarting would be the single most
|
|
// annoying bug a tray app can have.
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.restarts++
|
|
restarts := s.restarts
|
|
s.mu.Unlock()
|
|
|
|
if s.opts.MaxRestarts > 0 && restarts >= s.opts.MaxRestarts {
|
|
s.setState(Failed, err)
|
|
return
|
|
}
|
|
if ran >= stableRun {
|
|
backoff = minBackoff
|
|
}
|
|
s.setState(Backoff, err)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(backoff):
|
|
}
|
|
if backoff < maxBackoff {
|
|
backoff *= 2
|
|
if backoff > maxBackoff {
|
|
backoff = maxBackoff
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Supervisor) runOnce(ctx context.Context) error {
|
|
cmd := s.opts.Command(ctx)
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cmd.Stderr = cmd.Stdout
|
|
// Cancel ends the whole process tree, not just the process exec spawned.
|
|
// `kill` is filled in after Start, once the tree is confined; until then
|
|
// it is exec's own behaviour.
|
|
var kill func() error
|
|
cmd.Cancel = func() error {
|
|
if kill == nil {
|
|
return cmd.Process.Kill()
|
|
}
|
|
return kill()
|
|
}
|
|
prepare(cmd)
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("engine failed to start: %w", err)
|
|
}
|
|
k, release, err := confine(cmd)
|
|
if err != nil {
|
|
// Not fatal: the engine runs, and stopping it falls back to killing
|
|
// the one process. Logged because on Windows that fallback is the
|
|
// bug this exists to fix.
|
|
fmt.Fprintf(s.opts.LogWriter, "supervisor: could not confine engine process tree: %v\n", err)
|
|
}
|
|
kill = k
|
|
defer release()
|
|
|
|
// The last few lines the engine printed travel with the failure, because
|
|
// "engine exited: exit status 1" sends somebody to a log file on a shop
|
|
// PC, and the one line that matters - "port 8010 is already in use" - was
|
|
// right there.
|
|
var tailMu sync.Mutex
|
|
var tail []string
|
|
pumped := make(chan struct{})
|
|
go func() {
|
|
defer close(pumped)
|
|
sc := bufio.NewScanner(stdout)
|
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
fmt.Fprintln(s.opts.LogWriter, line)
|
|
tailMu.Lock()
|
|
tail = append(tail, line)
|
|
if len(tail) > 12 {
|
|
tail = tail[1:]
|
|
}
|
|
tailMu.Unlock()
|
|
}
|
|
}()
|
|
|
|
s.setState(Running, nil)
|
|
waitErr := cmd.Wait()
|
|
<-pumped
|
|
|
|
// A context cancel terminates the child through exec's own handling; the
|
|
// resulting error is expected, not a fault.
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
if waitErr != nil {
|
|
tailMu.Lock()
|
|
reason := explain(tail)
|
|
tailMu.Unlock()
|
|
if reason != "" {
|
|
return fmt.Errorf("%s (%v)", reason, waitErr)
|
|
}
|
|
return fmt.Errorf("engine exited: %w", waitErr)
|
|
}
|
|
return errors.New("engine exited unexpectedly with status 0")
|
|
}
|
|
|
|
func (s *Supervisor) setState(st State, err error) {
|
|
s.mu.Lock()
|
|
s.state = st
|
|
if err != nil {
|
|
s.lastErr = err
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// -- health ----------------------------------------------------------------
|
|
|
|
// Health is the subset of /api/health the tray and the server care about.
|
|
type Health struct {
|
|
Status string `json:"status"`
|
|
RecognitionModel string `json:"recognition_model"`
|
|
Cameras map[string]bool `json:"cameras"`
|
|
// Paths is a STRUCT, not map[string]string, because `frozen` is a bool.
|
|
// It was a map of strings, so decoding the engine's real reply failed with
|
|
// "cannot unmarshal bool into Go struct field Health.paths" - and because
|
|
// one bad field fails the whole document, a perfectly healthy engine was
|
|
// reported unreachable: red tray, and a heartbeat carrying neither the
|
|
// model nor the camera list, so head office showed 0 of 0 cameras for a
|
|
// site that was watching one.
|
|
Paths EnginePaths `json:"paths"`
|
|
}
|
|
|
|
// EnginePaths mirrors what `behavision paths` and /api/health report. Unknown
|
|
// fields are ignored by encoding/json, so the engine can add to it freely.
|
|
type EnginePaths struct {
|
|
Frozen bool `json:"frozen"`
|
|
InstallRoot string `json:"install_root"`
|
|
StateRoot string `json:"state_root"`
|
|
Config string `json:"config"`
|
|
DataDir string `json:"data_dir"`
|
|
ModelsDir string `json:"models_dir"`
|
|
}
|
|
|
|
// Health polls the engine's own API. A running process is not the same as a
|
|
// working engine: the model can fail to load and the process stays up.
|
|
func (s *Supervisor) Health(ctx context.Context) (*Health, error) {
|
|
if s.opts.HealthURL == "" {
|
|
return nil, errors.New("no health url configured")
|
|
}
|
|
var h Health
|
|
if err := s.getJSON(ctx, s.opts.HealthURL, &h); err != nil {
|
|
return nil, err
|
|
}
|
|
return &h, nil
|
|
}
|
|
|
|
// Stats is the slice of /api/stats the heartbeat carries.
|
|
//
|
|
// Only fraction_below_gate, because that is the number that decides whether a
|
|
// site's footfall can be believed at all - the share of faces its cameras saw
|
|
// and discarded before they ever became a visit. Everything else in /api/stats
|
|
// is a local diagnostic and belongs on the local dashboard, not on the wire
|
|
// every thirty seconds.
|
|
type Stats struct {
|
|
Cameras []struct {
|
|
CameraID string `json:"camera_id"`
|
|
Pipeline struct {
|
|
BestQuality struct {
|
|
N int `json:"n"`
|
|
FractionBelowGate float64 `json:"fraction_below_gate"`
|
|
} `json:"best_quality"`
|
|
} `json:"pipeline"`
|
|
} `json:"cameras"`
|
|
}
|
|
|
|
// WorstBelowGate returns the worst camera's figure, and whether any camera has
|
|
// measured enough faces to have an opinion.
|
|
//
|
|
// Worst rather than average: one badly placed camera is a hole in the report,
|
|
// and averaging it against three good ones hides the only camera anyone needs
|
|
// to move. The sample floor is there because three faces is an anecdote -
|
|
// reporting 1.00 from a single below-gate track would raise an alarm about a
|
|
// camera nobody has walked past yet.
|
|
func (s *Stats) WorstBelowGate() (float64, bool) {
|
|
const minSamples = 10
|
|
worst, found := 0.0, false
|
|
for _, c := range s.Cameras {
|
|
if c.Pipeline.BestQuality.N < minSamples {
|
|
continue
|
|
}
|
|
if !found || c.Pipeline.BestQuality.FractionBelowGate > worst {
|
|
worst, found = c.Pipeline.BestQuality.FractionBelowGate, true
|
|
}
|
|
}
|
|
return worst, found
|
|
}
|
|
|
|
// Stats polls the engine's pipeline counters.
|
|
func (s *Supervisor) Stats(ctx context.Context) (*Stats, error) {
|
|
if s.opts.StatsURL == "" {
|
|
return nil, errors.New("no stats url configured")
|
|
}
|
|
var out Stats
|
|
if err := s.getJSON(ctx, s.opts.StatsURL, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.opts.User != "" {
|
|
req.SetBasicAuth(s.opts.User, s.opts.Password)
|
|
}
|
|
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("%s returned %s", url, resp.Status)
|
|
}
|
|
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out)
|
|
}
|
|
|
|
// LogFile opens the engine log, rotating aside anything already there so one
|
|
// run's output cannot be mistaken for another's.
|
|
func LogFile(path string) (*os.File, error) {
|
|
if _, err := os.Stat(path); err == nil {
|
|
os.Rename(path, path+".1")
|
|
}
|
|
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
|
}
|
|
|
|
// explain turns the engine's last output into the sentence the tray shows.
|
|
// The cases are the ones seen on real installs; anything else shows the last
|
|
// non-empty line verbatim.
|
|
func explain(tail []string) string {
|
|
last := ""
|
|
for _, l := range tail {
|
|
low := strings.ToLower(l)
|
|
switch {
|
|
case strings.Contains(low, "address already in use") || strings.Contains(low, "only one usage of each socket address"):
|
|
return "port 8010 is already in use - another Behavision or its engine is still running"
|
|
case strings.Contains(low, "no module named behavision"):
|
|
return "the engine is not installed in this Python - run behavision-setup again"
|
|
case strings.Contains(low, "modulenotfounderror") || strings.Contains(low, "importerror"):
|
|
return "the engine is missing a library - run behavision-setup again"
|
|
}
|
|
if strings.TrimSpace(l) != "" {
|
|
last = strings.TrimSpace(l)
|
|
}
|
|
}
|
|
if len(last) > 120 {
|
|
last = last[:120] + "…"
|
|
}
|
|
return last
|
|
}
|