From 4ac08e5a8599a5daeeee49cf624aa27c498be47b Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Sat, 19 Sep 2026 15:50:26 +0530 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- agent/cmd/behavision-setup/main.go | 22 ++++++++++++ agent/pkg/engine/supervisor.go | 47 +++++++++++++++++++++++- desktop/app.go | 57 ++++++++++++++++++++++++++++++ desktop/tray.go | 50 +++++++++++++++----------- 4 files changed, 155 insertions(+), 21 deletions(-) diff --git a/agent/cmd/behavision-setup/main.go b/agent/cmd/behavision-setup/main.go index 5580ad9..003ce44 100644 --- a/agent/cmd/behavision-setup/main.go +++ b/agent/cmd/behavision-setup/main.go @@ -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 diff --git a/agent/pkg/engine/supervisor.go b/agent/pkg/engine/supervisor.go index 144d161..12d6d5a 100644 --- a/agent/pkg/engine/supervisor.go +++ b/agent/pkg/engine/supervisor.go @@ -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 +} diff --git a/desktop/app.go b/desktop/app.go index d38c4c8..383aead 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -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() diff --git a/desktop/tray.go b/desktop/tray.go index 8a8da06..0a086be 100644 --- a/desktop/tray.go +++ b/desktop/tray.go @@ -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: