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)
Changes
@@@ -29,6 +29,7 @@ install, Windows and configuration: [docs/install.md](docs/install.md).openvibe-node status # link, plugins, video, battery, stop latchopenvibe-node stop # kill switch: stop every motor now and keep it stoppedopenvibe-node resume++openvibe-node quit # end the node cleanly (nothing latched), e.g. from a stream launcheropenvibe-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)```@@@ -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 versionpackage 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 stoppedopenvibe-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) interr = 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 {@@@ -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) {@@@ -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 Nodeobeys 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 |@@@ -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"@@@ -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.DurationHeartbeatInterval time.Duration@@@ -70,6 +75,9 @@ type Node struct {screen screenStatectx context.Context++ // quit ends Run on `openvibe-node quit` (control), and quitting tells Run to answer ErrQuit.++ quit context.CancelFunc++ quitting atomic.Boolmu sync.Mutexlimits 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 errorn.ctx = ctx++ n.quit = canceln.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()}@@@ -5,6 +5,7 @@ package serviceimport ("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 Programmu 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 = errp.mu.Unlock()
@@ -29,6 +29,7 @@ install, Windows and configuration: [docs/install.md](docs/install.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 status # link, plugins, video, battery, stop latch
openvibe-node stop # kill switch: stop every motor now and keep it stopped
openvibe-node stop # kill switch: stop every motor now and keep it stopped
openvibe-node resume
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 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)
openvibe-node tray # the same in your notification area, with Approve/Deny notifications (Linux, Windows)
```
```
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
// openvibe-node status show the link, the plugins, the stop latch and video
// 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 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 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 plugins list plugins and their capabilities
// openvibe-node version
// openvibe-node version
package main
package main
@@ -21,6 +22,7 @@ import (
@@ -21,6 +22,7 @@ import (
"fmt"
"fmt"
"io"
"io"
"log/slog"
"log/slog"
+ "net"
"os"
"os"
"path/filepath"
"path/filepath"
"runtime"
"runtime"
@@ -56,6 +58,8 @@ Usage:
@@ -56,6 +58,8 @@ Usage:
openvibe-node status [--json]
openvibe-node status [--json]
openvibe-node stop stop every actuator now and hold them stopped
openvibe-node stop stop every actuator now and hold them stopped
openvibe-node resume release the stop
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 plugins [--json]
openvibe-node approvals [--json] what the computer plugin waits to be approved (control level "ask")
openvibe-node approvals [--json] what the computer plugin waits to be approved (control level "ask")
openvibe-node approve <ID> let it run
openvibe-node approve <ID> let it run
@@ -183,6 +187,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -183,6 +187,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
err = cmdStop(g, stdout)
err = cmdStop(g, stdout)
case "resume":
case "resume":
err = cmdResume(g, stdout)
err = cmdResume(g, stdout)
+ case "quit":
+ err = cmdQuit(g, stdout)
case "plugins":
case "plugins":
err = cmdPlugins(g, *asJSON, stdout)
err = cmdPlugins(g, *asJSON, stdout)
case "approvals":
case "approvals":
@@ -542,12 +548,18 @@ func cmdRun(g *globals, dryRun bool) error {
@@ -542,12 +548,18 @@ func cmdRun(g *globals, dryRun bool) error {
}
}
tokens = ts
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 {
if err != nil {
return err
return err
}
}
g.log.Info("openvibe-node starting", "version", version, "config", g.paths.ConfigDir, "plugins", len(cfg.Plugins))
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 {
func serviceOptions(g *globals, user string) service.Options {
@@ -601,6 +613,28 @@ func cmdStop(g *globals, stdout io.Writer) error {
@@ -601,6 +613,28 @@ func cmdStop(g *globals, stdout io.Writer) error {
return nil
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 {
func cmdResume(g *globals, stdout io.Writer) error {
_, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdResume}, 2*time.Second)
_, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdResume}, 2*time.Second)
if err != nil {
if err != nil {
@@ -125,6 +125,66 @@ func TestStopWithoutRunningNodeLatchesFile(t *testing.T) {
@@ -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
// 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.
// pattern, obeys commands, obeys the local kill switch and stops cleanly on SIGTERM.
func TestRunDryRun(t *testing.T) {
func TestRunDryRun(t *testing.T) {
@@ -149,11 +149,24 @@ console window that shows its log; closing the window stops the Node and every a
@@ -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 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 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 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 |
| `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
`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.
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
## Files
| Linux | macOS / Windows | contents |
| Linux | macOS / Windows | contents |
@@ -1,4 +1,4 @@
@@ -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
// `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.
// 0600, so only the account running the Node and root can use it. One JSON request per connection, one JSON answer.
package localctl
package localctl
@@ -15,9 +15,11 @@ import (
@@ -15,9 +15,11 @@ import (
// Commands.
// Commands.
const (
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"
CmdPlugins = "plugins"
// The computer plugin's approvals (control level "ask"): list what waits, approve or deny one by its id.
// The computer plugin's approvals (control level "ask"): list what waits, approve or deny one by its id.
CmdApprovals = "approvals"
CmdApprovals = "approvals"
@@ -22,6 +22,7 @@ import (
@@ -22,6 +22,7 @@ import (
"sort"
"sort"
"strings"
"strings"
"sync"
"sync"
+ "sync/atomic"
"time"
"time"
"github.com/OpenVibers/OpenVibe.Node/internal/config"
"github.com/OpenVibers/OpenVibe.Node/internal/config"
@@ -46,6 +47,10 @@ type Options struct {
@@ -46,6 +47,10 @@ type Options struct {
// Tokens are a Network-paired machine's node tokens; nil builds them from Creds.
// Tokens are a Network-paired machine's node tokens; nil builds them from Creds.
Tokens link.Tokens
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.
// For tests.
ReauthInterval time.Duration
ReauthInterval time.Duration
HeartbeatInterval time.Duration
HeartbeatInterval time.Duration
@@ -70,6 +75,9 @@ type Node struct {
@@ -70,6 +75,9 @@ type Node struct {
screen screenState
screen screenState
ctx context.Context
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
mu sync.Mutex
limits safety.Limits
limits safety.Limits
@@ -106,6 +114,9 @@ type Node struct {
@@ -106,6 +114,9 @@ type Node struct {
trayToldAt time.Time // when the computer plugin was last told the tray shows its approvals
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.
// InputGuard is how long after an input command `approve` stays refused.
const InputGuard = 2 * time.Second
const InputGuard = 2 * time.Second
@@ -227,6 +238,7 @@ func (n *Node) Run(parent context.Context) error {
@@ -227,6 +238,7 @@ func (n *Node) Run(parent context.Context) error {
defer cancel()
defer cancel()
var fatal error
var fatal error
n.ctx = ctx
n.ctx = ctx
+ n.quit = cancel
n.started = time.Now()
n.started = time.Now()
ctlErr := make(chan error, 1)
ctlErr := make(chan error, 1)
go func() { ctlErr <- localctl.Serve(ctx, n.opt.Paths.SocketPath(), n.control) }()
go func() { ctlErr <- localctl.Serve(ctx, n.opt.Paths.SocketPath(), n.control) }()
@@ -271,6 +283,9 @@ func (n *Node) Run(parent context.Context) error {
@@ -271,6 +283,9 @@ func (n *Node) Run(parent context.Context) error {
cancelPlugins()
cancelPlugins()
n.mgr.Wait()
n.mgr.Wait()
wg.Wait()
wg.Wait()
+ if fatal == nil && n.quitting.Load() {
+ return ErrQuit
+ }
return fatal
return fatal
}
}
@@ -1211,6 +1226,18 @@ func (n *Node) control(r localctl.Request) localctl.Response {
@@ -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, Error: "stopped, but the latch could not be saved: " + err.Error()}
}
}
return localctl.Response{OK: true}
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:
case localctl.CmdResume:
if _, err := n.latch.Resume(); err != nil {
if _, err := n.latch.Resume(); err != nil {
return localctl.Response{Error: err.Error()}
return localctl.Response{Error: err.Error()}
@@ -5,6 +5,7 @@ package service
@@ -5,6 +5,7 @@ package service
import (
import (
"context"
"context"
"errors"
"errors"
+ "os"
"runtime"
"runtime"
"sync"
"sync"
"time"
"time"
@@ -21,6 +22,15 @@ const (
@@ -21,6 +22,15 @@ const (
// Program is the body of the service: run until ctx ends.
// Program is the body of the service: run until ctx ends.
type Program func(ctx context.Context) error
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 {
type program struct {
run Program
run Program
mu sync.Mutex
mu sync.Mutex
@@ -36,6 +46,11 @@ func (p *program) Start(s ks.Service) error {
@@ -36,6 +46,11 @@ func (p *program) Start(s ks.Service) error {
p.mu.Unlock()
p.mu.Unlock()
go func() {
go func() {
err := p.run(ctx)
err := p.run(ctx)
+ if errors.Is(err, ErrQuit) {
+ close(p.done)
+ exit(0)
+ return
+ }
p.mu.Lock()
p.mu.Lock()
p.err = err
p.err = err
p.mu.Unlock()
p.mu.Unlock()