1 Commits

Author SHA1 Message Date
4ac08e5a85 The tray says why the engine is not running, and setup will not run under a live app
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
2026-09-19 15:50:26 +05:30
4 changed files with 155 additions and 21 deletions

View File

@@ -92,6 +92,12 @@ func run() error {
}
}
if running := behavisionRunning(); running != "" {
return fmt.Errorf("%s is running. Quit Behavision from the tray icon first, then run setup again.\n\n"+
"Setting up underneath a running copy starts a second engine on the same port and, in a demo,\n"+
"re-claims the shop while the open app still holds the old credentials.", running)
}
py, ver, err := findPython()
if err != nil {
return err
@@ -210,6 +216,22 @@ func engineSource() (string, error) {
// `py -3` first on Windows: the launcher is what the official installer puts
// on PATH, and `python` there is often the Microsoft Store stub that prints an
// advert and exits 9009 instead of running anything.
// behavisionRunning names a Behavision process if one is up. Windows only -
// that is the platform setup ships on - and by image name via tasklist, which
// needs no extra privilege.
func behavisionRunning() string {
if runtime.GOOS != "windows" {
return ""
}
for _, name := range []string{"Behavision.exe", "behavision-agent.exe"} {
out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq "+name, "/NH").Output()
if err == nil && strings.Contains(strings.ToLower(string(out)), strings.ToLower(name)) {
return name
}
}
return ""
}
func findPython() (string, string, error) {
type cand struct {
exe string

View File

@@ -21,6 +21,7 @@ import (
"net/http"
"os"
"os/exec"
"strings"
"sync"
"time"
)
@@ -218,13 +219,26 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
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() {
fmt.Fprintln(s.opts.LogWriter, sc.Text())
line := sc.Text()
fmt.Fprintln(s.opts.LogWriter, line)
tailMu.Lock()
tail = append(tail, line)
if len(tail) > 12 {
tail = tail[1:]
}
tailMu.Unlock()
}
}()
@@ -238,6 +252,12 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
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")
@@ -373,3 +393,28 @@ func LogFile(path string) (*os.File, error) {
}
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
}

View File

@@ -125,6 +125,7 @@ func (a *App) startup(ctx context.Context) {
})
a.startPipeline(ctx)
go a.watchConfig(ctx)
// Recognition starts with the app. Until this, the engine only ever
// started when somebody pressed Start - which meant a till that rebooted
@@ -467,6 +468,62 @@ func (a *App) Claim(code string) (SessionInfo, error) {
// the current config. Only Claim needs it today; it exists as its own method
// because "stop everything that reads the config, then start it" is the part
// that is easy to get half right.
// watchConfig reloads agent.json when something else writes it.
//
// behavision-setup re-run on a PC with the app open re-claims the shop and
// rotates its API token; the running app kept the old one and every camera
// sync was refused from then on - heartbeats still flowed, so head office
// looked fine while the cameras went stale. A claim from `behavision-agent
// claim` does the same. Rather than ask people to restart the app, the app
// watches the file and picks the new credentials up itself.
func (a *App) watchConfig(ctx context.Context) {
path := agentpaths.AgentConfig()
last := mtime(path)
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
now := mtime(path)
if now.IsZero() || now.Equal(last) {
continue
}
last = now
fresh, err := agentcfg.Load(path)
if err != nil {
continue
}
fresh = fresh.WithEngineCredentials(agentpaths.APICredentials())
a.mu.Lock()
changed := fresh.AgentToken != a.cfg.AgentToken || fresh.SiteID != a.cfg.SiteID ||
fresh.BrokerPassword != a.cfg.BrokerPassword || fresh.CloudBase != a.cfg.CloudBase ||
fresh.Standalone != a.cfg.Standalone
if changed {
// Keep this process's live session; a claim clears it in the file
// deliberately, and that is honoured too.
a.cfg = fresh
if fresh.SessionToken == "" {
a.cloud.Clear()
}
}
a.mu.Unlock()
if changed {
a.restartPipeline()
}
}
}
func mtime(path string) time.Time {
st, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return st.ModTime()
}
func (a *App) restartPipeline() {
if a.stopBridge != nil {
a.stopBridge()

View File

@@ -101,9 +101,11 @@ func (t *tray) onReady(ctx context.Context) {
case <-t.mOpen.ClickedCh:
runtime.Show(ctx)
case <-t.mStart.ClickedCh:
t.app.StartEngine()
// Never on the menu loop itself: a stop waits for the process to
// exit, and a menu that is deaf for the duration looks broken.
go func() { t.app.StartEngine(); t.refresh() }()
case <-t.mStop.ClickedCh:
t.app.StopEngine()
go func() { t.app.StopEngine(); t.refresh() }()
case <-t.mLogs.ClickedCh:
runtime.BrowserOpenURL(ctx, "file://"+logsDir())
case <-t.mQuit.ClickedCh:
@@ -129,23 +131,29 @@ func (t *tray) poll(ctx context.Context) {
case <-ctx.Done():
return
case <-tick.C:
s := t.app.EngineStatus()
state, label := describe(s)
systray.SetIcon(iconFor(state))
systray.SetTooltip("Behavision — " + label)
if t.mStatus != nil {
t.mStatus.SetTitle(label)
}
running := s.State == "running"
if t.mStart != nil && t.mStop != nil {
if running {
t.mStart.Disable()
t.mStop.Enable()
} else {
t.mStart.Enable()
t.mStop.Disable()
}
}
t.refresh()
}
}
}
// refresh redraws the icon and the menu from EngineStatus - the same source
// the window reads, so the two cannot disagree.
func (t *tray) refresh() {
s := t.app.EngineStatus()
state, label := describe(s)
systray.SetIcon(iconFor(state))
systray.SetTooltip("Behavision — " + label)
if t.mStatus != nil {
t.mStatus.SetTitle(label)
}
running := s.State == "running" || s.State == "starting" || s.State == "backoff"
if t.mStart != nil && t.mStop != nil {
if running {
t.mStart.Disable()
t.mStop.Enable()
} else {
t.mStart.Enable()
t.mStop.Disable()
}
}
}
@@ -160,7 +168,9 @@ func describe(s EngineStatus) (state, label string) {
case s.State == "stopped":
return "stopped", "Stopped"
case s.State == "failed":
return "error", "Failed — " + firstLine(s.Error)
// The supervisor's error is already a sentence (port in use, missing
// library); show it whole, because it is the thing to act on.
return "error", "Not running — " + firstLine(s.Error)
case s.State == "backoff":
return "error", fmt.Sprintf("Restarting (%d attempts)", s.Restarts)
case !s.Reachable: