openvibers/openvibe.link Commits

openvibe-node quit: end the running node cleanly (every actuator stopped, nothing latched), for launchers that start and stop it with the rest of a stream setup (#60)

OP OpenVibers committed 17 days ago · 55dd022d63 · 7 files, +158 −6

Changes

ddiff --git a/README.md b/README.md
iindex 37e3bc0..a700759 100644
---- a/README.md
++++ b/README.md
@@@ -29,6 +29,7 @@ install, Windows and configuration: [docs/install.md](docs/install.md).
openvibe-node status # link, plugins, video, battery, stop latch
openvibe-node stop # kill switch: stop every motor now and keep it stopped
openvibe-node resume
++openvibe-node quit # end the node cleanly (nothing latched), e.g. from a stream launcher
openvibe-node approvals # the computer plugin at level ask: what waits; approve <id> / deny <id>
openvibe-node tray # the same in your notification area, with Approve/Deny notifications (Linux, Windows)
```
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex d4684d4..950be0f 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -9,6 +9,7 @@
// openvibe-node status show the link, the plugins, the stop latch and video
// openvibe-node stop local kill switch: stop every actuator and hold it stopped
// openvibe-node resume release the local kill switch (and a server e-stop)
++// openvibe-node quit end the running node cleanly: every actuator stops, nothing is latched
// openvibe-node plugins list plugins and their capabilities
// openvibe-node version
package main
@@@ -21,6 +22,7 @@ import (
"fmt"
"io"
"log/slog"
++ "net"
"os"
"path/filepath"
"runtime"
@@@ -56,6 +58,8 @@ Usage:
openvibe-node status [--json]
openvibe-node stop stop every actuator now and hold them stopped
openvibe-node resume release the stop
++ openvibe-node quit end the running node cleanly: every actuator stops and nothing is latched, so the next
++ start drives as before (stop is the kill switch, and holds until resume)
openvibe-node plugins [--json]
openvibe-node approvals [--json] what the computer plugin waits to be approved (control level "ask")
openvibe-node approve <ID> let it run
@@@ -183,6 +187,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
err = cmdStop(g, stdout)
case "resume":
err = cmdResume(g, stdout)
++ case "quit":
++ err = cmdQuit(g, stdout)
case "plugins":
err = cmdPlugins(g, *asJSON, stdout)
case "approvals":
@@@ -542,12 +548,18 @@ func cmdRun(g *globals, dryRun bool) error {
}
tokens = ts
}
-- n, err := node.New(node.Options{Config: cfg, Paths: g.paths, Creds: creds, Tokens: tokens, Log: g.log, Version: version})
++ n, err := node.New(node.Options{Config: cfg, Paths: g.paths, Creds: creds, Tokens: tokens, Log: g.log, Version: version, CanQuit: service.Interactive()})
if err != nil {
return err
}
g.log.Info("openvibe-node starting", "version", version, "config", g.paths.ConfigDir, "plugins", len(cfg.Plugins))
-- return service.Run(serviceOptions(g, ""), n.Run)
++ return service.Run(serviceOptions(g, ""), func(ctx context.Context) error {
++ if err := n.Run(ctx); !errors.Is(err, node.ErrQuit) {
++ return err
++ }
++ g.log.Info("openvibe-node exited: every actuator stopped, nothing latched")
++ return service.ErrQuit
++ })
}
func serviceOptions(g *globals, user string) service.Options {
@@@ -601,6 +613,28 @@ func cmdStop(g *globals, stdout io.Writer) error {
return nil
}
++// cmdQuit ends the running Node and waits until it has exited, so a script that runs it next can start it again.
++func cmdQuit(g *globals, stdout io.Writer) error {
++ sock := g.paths.SocketPath()
++ if _, err := localctl.Call(sock, localctl.Request{Cmd: localctl.CmdQuit}, 2*time.Second); err != nil {
++ var op *net.OpError
++ if errors.As(err, &op) && op.Op == "dial" {
++ fmt.Fprintln(stdout, "No node is running here: nothing to quit.")
++ return nil
++ }
++ return err
++ }
++ for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(200 * time.Millisecond) {
++ c, err := net.DialTimeout("unix", sock, 200*time.Millisecond)
++ if err != nil {
++ fmt.Fprintln(stdout, "The node stopped every actuator and exited. Nothing is latched: the next start drives as before.")
++ return nil
++ }
++ c.Close()
++ }
++ return errors.New("the node took the quit but is still running after 20 s; `openvibe-node status` shows it")
++}
++
func cmdResume(g *globals, stdout io.Writer) error {
_, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdResume}, 2*time.Second)
if err != nil {
ddiff --git a/cmd/openvibe-node/main_test.go b/cmd/openvibe-node/main_test.go
iindex 785a461..cc96ece 100644
---- a/cmd/openvibe-node/main_test.go
++++ b/cmd/openvibe-node/main_test.go
@@@ -125,6 +125,66 @@ func TestStopWithoutRunningNodeLatchesFile(t *testing.T) {
}
}
++// TestQuit: `openvibe-node quit` ends a running node cleanly (every actuator stopped, nothing latched) and waits
++// until it has exited; with no node running it says so and succeeds (Maticus, 2026-10-26: `stop` latched, so a
++// script had to kill the process).
++func TestQuit(t *testing.T) {
++ if runtime.GOOS == "windows" {
++ t.Skip("unix only")
++ }
++ if _, err := exec.LookPath("python3"); err != nil {
++ t.Skip("python3 not installed")
++ }
++ home := shortHome(t)
++ t.Setenv("PYTHONPATH", pythonPath(t))
++ if out, _, code := cli(t, "quit", "--home", home); code != 0 || !strings.Contains(out, "No node is running") {
++ t.Fatalf("quit with nothing running: %d %s", code, out)
++ }
++ exe, _ := os.Executable()
++ cmd := exec.Command(exe, "run", "--dry-run", "--home", home)
++ cmd.Env = append(os.Environ(), "OPENVIBE_NODE_MAIN=1")
++ var logs bytes.Buffer
++ cmd.Stdout, cmd.Stderr = &logs, &logs
++ if err := cmd.Start(); err != nil {
++ t.Fatal(err)
++ }
++ exited := make(chan error, 1)
++ go func() { exited <- cmd.Wait() }()
++ defer func() {
++ if cmd.ProcessState == nil {
++ cmd.Process.Kill()
++ }
++ }()
++ deadline := time.Now().Add(20 * time.Second)
++ for {
++ if out, _, code := cli(t, "status", "--home", home); code == 0 && !strings.Contains(out, "not running") {
++ break
++ }
++ if time.Now().After(deadline) {
++ t.Fatalf("the node never answered\n%s", logs.String())
++ }
++ time.Sleep(100 * time.Millisecond)
++ }
++ out, errOut, code := cli(t, "quit", "--home", home)
++ if code != 0 || !strings.Contains(out, "exited") {
++ t.Fatalf("quit: %d %s %s\n%s", code, out, errOut, logs.String())
++ }
++ select {
++ case err := <-exited:
++ if err != nil {
++ t.Fatalf("exit: %v\n%s", err, logs.String())
++ }
++ case <-time.After(15 * time.Second):
++ t.Fatalf("still running after quit\n%s", logs.String())
++ }
++ if !strings.Contains(logs.String(), "stopping every actuator") || !strings.Contains(logs.String(), "nothing latched") {
++ t.Fatalf("no clean shutdown in logs:\n%s", logs.String())
++ }
++ if l, _ := safety.OpenLatch(filepath.Join(home, "latch.json")); l != nil && l.State().Stopped() {
++ t.Fatal("quit latched the stop")
++ }
++}
++
// TestRunDryRun is the acceptance path: `openvibe-node run --dry-run` pairs with the fake server, publishes the test
// pattern, obeys commands, obeys the local kill switch and stops cleanly on SIGTERM.
func TestRunDryRun(t *testing.T) {
ddiff --git a/docs/install.md b/docs/install.md
iindex b0f9d38..9c6340e 100644
---- a/docs/install.md
++++ b/docs/install.md
@@@ -149,11 +149,24 @@ console window that shows its log; closing the window stops the Node and every a
| `openvibe-node status [--json]` | link, stop latch, limits, plugins, video, battery, running jobs |
| `openvibe-node stop` | **kill switch**: stop every actuator now and hold it stopped |
| `openvibe-node resume` | release the local stop (and a server e-stop) |
++| `openvibe-node quit` | end the running Node cleanly: every actuator stops, **nothing is latched**, and it waits until the Node has exited |
| `openvibe-node plugins [--json]` | plugins and their capabilities |
`stop` works even when the Node does not answer: it latches `latch.json` in the state directory, which a running Node
obeys within a quarter second and a starting Node obeys before anything moves. The latch survives restarts.
++### Starting and ending it with the rest of your setup
++
++`stop` is a kill switch, not an exit: it holds every actuator stopped, across restarts, until `resume`. To end the Node
++from a script (a stream launcher that starts and stops it with OBS, say), use `quit`. Every actuator stops, the Node
++exits, and nothing is latched, so the next `openvibe-node run` drives as before. `quit` waits until the process has
++gone and succeeds when no Node is running, so a launcher can call it without checking first.
++
++`quit` ends a Node started from a terminal, by a launcher, or at sign-in (`install --user-session`). A Node installed as
++a system service refuses it, because systemd, launchd or Windows would start it again. Stop the service there instead
++(`sudo systemctl stop openvibe-node`, or Services on Windows), or remove it with `openvibe-node uninstall`. A launcher
++that owns the Node's life should run `openvibe-node run --home DIR` itself, with no service and no sign-in start.
++
## Files
| Linux | macOS / Windows | contents |
ddiff --git a/internal/localctl/localctl.go b/internal/localctl/localctl.go
iindex 0913fb0..be2f072 100644
---- a/internal/localctl/localctl.go
++++ b/internal/localctl/localctl.go
@@@ -1,4 +1,4 @@
--// Package localctl is the running Node's local control socket: `openvibe-node stop`, `resume`, `status`, `plugins`,
++// Package localctl is the running Node's local control socket: `openvibe-node stop`, `resume`, `quit`, `status`, `plugins`,
// `approvals`, `approve`, `deny` and `tray` talk to it. It is a Unix domain socket (Windows 10+ supports them too) in the state directory with mode
// 0600, so only the account running the Node and root can use it. One JSON request per connection, one JSON answer.
package localctl
@@@ -15,9 +15,11 @@ import (
// Commands.
const (
-- CmdStatus = "status"
-- CmdStop = "stop"
-- CmdResume = "resume"
++ CmdStatus = "status"
++ CmdStop = "stop"
++ CmdResume = "resume"
++ // CmdQuit ends the running Node cleanly: every actuator stops and nothing is latched (stop latches).
++ CmdQuit = "quit"
CmdPlugins = "plugins"
// The computer plugin's approvals (control level "ask"): list what waits, approve or deny one by its id.
CmdApprovals = "approvals"
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex 93ce179..7a32203 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -22,6 +22,7 @@ import (
"sort"
"strings"
"sync"
++ "sync/atomic"
"time"
"github.com/OpenVibers/OpenVibe.Node/internal/config"
@@@ -46,6 +47,10 @@ type Options struct {
// Tokens are a Network-paired machine's node tokens; nil builds them from Creds.
Tokens link.Tokens
++ // CanQuit lets `openvibe-node quit` end the process: true when it runs from a terminal or a sign-in start, false
++ // under a service manager, which would only start it again.
++ CanQuit bool
++
// For tests.
ReauthInterval time.Duration
HeartbeatInterval time.Duration
@@@ -70,6 +75,9 @@ type Node struct {
screen screenState
ctx context.Context
++ // quit ends Run on `openvibe-node quit` (control), and quitting tells Run to answer ErrQuit.
++ quit context.CancelFunc
++ quitting atomic.Bool
mu sync.Mutex
limits safety.Limits
@@@ -106,6 +114,9 @@ type Node struct {
trayToldAt time.Time // when the computer plugin was last told the tray shows its approvals
}
++// ErrQuit is what Run returns after `openvibe-node quit`: every actuator stopped, nothing latched, the process may exit.
++var ErrQuit = errors.New("quit by its owner")
++
// InputGuard is how long after an input command `approve` stays refused.
const InputGuard = 2 * time.Second
@@@ -227,6 +238,7 @@ func (n *Node) Run(parent context.Context) error {
defer cancel()
var fatal error
n.ctx = ctx
++ n.quit = cancel
n.started = time.Now()
ctlErr := make(chan error, 1)
go func() { ctlErr <- localctl.Serve(ctx, n.opt.Paths.SocketPath(), n.control) }()
@@@ -271,6 +283,9 @@ func (n *Node) Run(parent context.Context) error {
cancelPlugins()
n.mgr.Wait()
wg.Wait()
++ if fatal == nil && n.quitting.Load() {
++ return ErrQuit
++ }
return fatal
}
@@@ -1211,6 +1226,18 @@ func (n *Node) control(r localctl.Request) localctl.Response {
return localctl.Response{OK: true, Error: "stopped, but the latch could not be saved: " + err.Error()}
}
return localctl.Response{OK: true}
++ case localctl.CmdQuit:
++ // Not a stop: nothing is latched, so the next start drives as before (Maticus, 2026-10-26: a script that
++ // stopped the Node with `stop` left it latched, and had to kill the process to end it).
++ if !n.opt.CanQuit {
++ return localctl.Response{Error: "this node runs as a system service, which would start it again: stop it there " +
++ "(sudo systemctl stop openvibe-node; on Windows, Services), or remove it with `openvibe-node uninstall`"}
++ }
++ n.log.Info("quit by its owner (openvibe-node quit)")
++ n.mgr.StopAll() // at once; Run stops everything again on the way out
++ n.quitting.Store(true)
++ go func() { time.Sleep(100 * time.Millisecond); n.quit() }() // after this answer is written
++ return localctl.Response{OK: true}
case localctl.CmdResume:
if _, err := n.latch.Resume(); err != nil {
return localctl.Response{Error: err.Error()}
ddiff --git a/internal/service/service.go b/internal/service/service.go
iindex 928769e..0c371ee 100644
---- a/internal/service/service.go
++++ b/internal/service/service.go
@@@ -5,6 +5,7 @@ package service
import (
"context"
"errors"
++ "os"
"runtime"
"sync"
"time"
@@@ -21,6 +22,15 @@ const (
// Program is the body of the service: run until ctx ends.
type Program func(ctx context.Context) error
++// ErrQuit is what a Program returns when its owner ended it (`openvibe-node quit`): it has stopped everything, and
++// the process exits 0. Only a Node run from a terminal or a sign-in start takes quit (a service manager would start it
++// again), and there the wait for a signal is not ours to end (kardianos/service on Windows waits for an interrupt),
++// so the process exits here.
++var ErrQuit = errors.New("quit")
++
++// exit is os.Exit; tests replace it.
++var exit = os.Exit
++
type program struct {
run Program
mu sync.Mutex
@@@ -36,6 +46,11 @@ func (p *program) Start(s ks.Service) error {
p.mu.Unlock()
go func() {
err := p.run(ctx)
++ if errors.Is(err, ErrQuit) {
++ close(p.done)
++ exit(0)
++ return
++ }
p.mu.Lock()
p.err = err
p.mu.Unlock()