openvibers/openvibe.link Commits
Link v0.61.0: a headlight is not refused on a slow link (#168)
Changes
@@@ -5,8 +5,8 @@"wave": "T15","plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)","kind": "client",-- "version": "0.60.0",-- "release": "v0.60.0: `install.sh --ai` installs the ai plugin it turns on. It used to configure the plugin without installing its package, so the Link started a plugin that was not there (\"No module named openvibe_ai\"); a script test now checks that every option installs the bundled plugin it turns on. The words say Stop: the panel's Stop is a plain, optional control that sends the machine's own halt, and Link's pages, docs and messages no longer use the old name. No behaviour change otherwise.",++ "version": "0.61.0",++ "release": "v0.61.0: a headlight is no longer refused on a slow link. Link checks the 200 ms it must have left of a headlight's 300 ms lifetime against its estimate of the server's clock, and counts the clock's uncertainty once, where it decides whether a deadline has already passed. Counting it twice refused every headlight on a connection whose fastest round trip was slow (164 ms of uncertainty read as \"only 136 ms of 300 left\" on a fresh command, while drive commands went through). The fake Bot can answer heartbeats late to test a slow path.","runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library","database": "none","deployed": false,@@@ -919,8 +919,11 @@ func (n *Agent) headlight(c protocol.Command, entry *safety.Entry, sentMS int64,return}// The original expiry is what provenance.expires_at_ms says; what is left of it is the deadline's time left plus-- // the gap between the two. At least HeadlightReserveMS of that original 300 ms must remain on receipt.-- originalLeft := left + time.Duration(pv.ExpiresAtMS-c.DeadlineMS)*time.Millisecond++ // the gap between the two. At least HeadlightReserveMS of that original 300 ms must remain on receipt, judged on the++ // link's estimate of the server's clock: the clock's uncertainty is spent once, above, where the worst case decides++ // whether the deadline has already passed. Counting it here as well refused every headlight on a connection whose++ // fastest round trip was slow (Maticus, 2026-11-12: 164 ms of uncertainty left "136 of 300 ms" on a fresh command).++ originalLeft := left + time.Duration(unc)*time.Millisecond + time.Duration(pv.ExpiresAtMS-c.DeadlineMS)*time.Millisecondif originalLeft < HeadlightReserveMS*time.Millisecond {nack(protocol.FaultExpired, fmt.Sprintf("only %d ms of the headlight's original %d ms lifetime was left on receipt (need at least %d)", max(0, originalLeft.Milliseconds()), HeadlightLifetimeMS, HeadlightReserveMS))return@@@ -545,6 +545,12 @@ func TestVideoAndTelemetry(t *testing.T) {// relays the driver's stage=headlight_pending/headlight_refused ack data back to Bot.func headlightPlugin(t *testing.T) (*env, string) {++ t.Helper()++ return headlightPluginWith(t, nil)++}++++// headlightPluginWith is headlightPlugin with prep run on the fake server first (a slow path, say).++func headlightPluginWith(t *testing.T, prep func(*fakebot.Server)) (*env, string) {t.Helper()py, err := exec.LookPath("python3")if err != nil {@@@ -552,7 +558,7 @@ func headlightPlugin(t *testing.T) (*env, string) {}root, _ := filepath.Abs("../..")record := filepath.Join(t.TempDir(), "headlight.jsonl")-- e := startWithPlugin(t, nil, true, &config.PluginConfig{++ e := startWithPlugin(t, prep, true, &config.PluginConfig{Name: "headlight",Command: []string{py, filepath.Join(root, "internal/agent/testdata/headlight_plugin.py")},Config: map[string]any{"record": record},@@@ -665,6 +671,43 @@ func TestHeadlightEnvelopeAndAckData(t *testing.T) {}}++// On a connection whose fastest round trip is slow (a large clock uncertainty), a fresh headlight still reaches the++// driver: the reserve is judged on the clock estimate, the uncertainty only on whether the deadline has passed++// (Maticus, 2026-11-12: 164 ms of uncertainty refused every headlight as "136 of 300 ms left").++func TestHeadlightReserveOnASlowLink(t *testing.T) {++ e, record := headlightPluginWith(t, func(s *fakebot.Server) { s.HeartbeatAckDelay = 170 * time.Millisecond })++ deadline := time.Now().Add(10 * time.Second)++ for {++ if d, ok := e.node.link.ClockUncertainty(); ok && d >= 160*time.Millisecond {++ break++ }++ if time.Now().After(deadline) {++ t.Fatal("no slow round trip measured")++ }++ time.Sleep(20 * time.Millisecond)++ }++ r := headlightCommand(t, e, "hl-slow", true, HeadlightDeadlineMS, &protocol.CommandProvenance{})++ if d := ackData(t, r); d["stage"] != "headlight_pending" {++ t.Fatalf("a fresh headlight on a slow link was refused: %#v", r)++ }++ got := readHeadlight(t, record)[0]++ if got.Provenance == nil || got.Provenance.ClockUncertaintyMS == nil || *got.Provenance.ClockUncertaintyMS < 160 {++ t.Fatalf("the driver is told the link's uncertainty: %+v", got.Provenance)++ }++ // A frame that really sat 150 ms on the way is still refused.++ cmd := protocol.Command{ID: "hl-slow-late", Kind: protocol.KindHeadlight, Value: json.RawMessage(`{"on":false}`), Provenance: &protocol.CommandProvenance{}}++ if err := e.conn.CommandLate(cmd, HeadlightDeadlineMS, 150*time.Millisecond); err != nil {++ t.Fatal(err)++ }++ late, err := e.conn.Reply("hl-slow-late", 5*time.Second)++ if err != nil {++ t.Fatal(err)++ }++ if n, ok := late.(protocol.Nack); !ok || n.FaultCode != protocol.FaultExpired {++ t.Fatalf("the late headlight was not refused expired: %#v", late)++ }++}++func TestHeadlightReserveAndRefusals(t *testing.T) {e, _ := headlightPlugin(t)// A frame that spent 150 ms on the way has only 150 ms of its original 300 ms lifetime left: never sent.@@@ -47,6 +47,9 @@ type Server struct {// HoldHeartbeatAcks makes new connections leave heartbeats unanswered until Conn.SetAutoAck(true): the device has// no measured round trip, so no bound on its clock estimate.HoldHeartbeatAcks bool++ // HeartbeatAckDelay answers each heartbeat this much later: a slow path, so a large clock uncertainty (a connection++ // whose fastest round trip is long).++ HeartbeatAckDelay time.Durationmu sync.Mutexcodes map[string]string // code → robot id@@@ -704,7 +707,11 @@ func (c *Conn) read() {t := hb.Tack.T, ack.Echo = t, &t}-- _ = c.Send(ack)++ if c.srv != nil && c.srv.HeartbeatAckDelay > 0 {++ time.AfterFunc(c.srv.HeartbeatAckDelay, func() { _ = c.Send(ack) })++ } else {++ _ = c.Send(ack)++ }}if c.srv != nil {c.srv.relayFrame(c, f.Msg)
@@ -5,8 +5,8 @@
@@ -5,8 +5,8 @@
"wave": "T15",
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
"kind": "client",
- "version": "0.60.0",
- "release": "v0.60.0: `install.sh --ai` installs the ai plugin it turns on. It used to configure the plugin without installing its package, so the Link started a plugin that was not there (\"No module named openvibe_ai\"); a script test now checks that every option installs the bundled plugin it turns on. The words say Stop: the panel's Stop is a plain, optional control that sends the machine's own halt, and Link's pages, docs and messages no longer use the old name. No behaviour change otherwise.",
+ "version": "0.61.0",
+ "release": "v0.61.0: a headlight is no longer refused on a slow link. Link checks the 200 ms it must have left of a headlight's 300 ms lifetime against its estimate of the server's clock, and counts the clock's uncertainty once, where it decides whether a deadline has already passed. Counting it twice refused every headlight on a connection whose fastest round trip was slow (164 ms of uncertainty read as \"only 136 ms of 300 left\" on a fresh command, while drive commands went through). The fake Bot can answer heartbeats late to test a slow path.",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"database": "none",
"deployed": false,
"deployed": false,
@@ -919,8 +919,11 @@ func (n *Agent) headlight(c protocol.Command, entry *safety.Entry, sentMS int64,
@@ -919,8 +919,11 @@ func (n *Agent) headlight(c protocol.Command, entry *safety.Entry, sentMS int64,
return
return
}
}
// The original expiry is what provenance.expires_at_ms says; what is left of it is the deadline's time left plus
// The original expiry is what provenance.expires_at_ms says; what is left of it is the deadline's time left plus
- // the gap between the two. At least HeadlightReserveMS of that original 300 ms must remain on receipt.
- originalLeft := left + time.Duration(pv.ExpiresAtMS-c.DeadlineMS)*time.Millisecond
+ // the gap between the two. At least HeadlightReserveMS of that original 300 ms must remain on receipt, judged on the
+ // link's estimate of the server's clock: the clock's uncertainty is spent once, above, where the worst case decides
+ // whether the deadline has already passed. Counting it here as well refused every headlight on a connection whose
+ // fastest round trip was slow (Maticus, 2026-11-12: 164 ms of uncertainty left "136 of 300 ms" on a fresh command).
+ originalLeft := left + time.Duration(unc)*time.Millisecond + time.Duration(pv.ExpiresAtMS-c.DeadlineMS)*time.Millisecond
if originalLeft < HeadlightReserveMS*time.Millisecond {
if originalLeft < HeadlightReserveMS*time.Millisecond {
nack(protocol.FaultExpired, fmt.Sprintf("only %d ms of the headlight's original %d ms lifetime was left on receipt (need at least %d)", max(0, originalLeft.Milliseconds()), HeadlightLifetimeMS, HeadlightReserveMS))
nack(protocol.FaultExpired, fmt.Sprintf("only %d ms of the headlight's original %d ms lifetime was left on receipt (need at least %d)", max(0, originalLeft.Milliseconds()), HeadlightLifetimeMS, HeadlightReserveMS))
return
return
@@ -545,6 +545,12 @@ func TestVideoAndTelemetry(t *testing.T) {
@@ -545,6 +545,12 @@ func TestVideoAndTelemetry(t *testing.T) {
// relays the driver's stage=headlight_pending/headlight_refused ack data back to Bot.
// relays the driver's stage=headlight_pending/headlight_refused ack data back to Bot.
func headlightPlugin(t *testing.T) (*env, string) {
func headlightPlugin(t *testing.T) (*env, string) {
+ t.Helper()
+ return headlightPluginWith(t, nil)
+}
+
+// headlightPluginWith is headlightPlugin with prep run on the fake server first (a slow path, say).
+func headlightPluginWith(t *testing.T, prep func(*fakebot.Server)) (*env, string) {
t.Helper()
t.Helper()
py, err := exec.LookPath("python3")
py, err := exec.LookPath("python3")
if err != nil {
if err != nil {
@@ -552,7 +558,7 @@ func headlightPlugin(t *testing.T) (*env, string) {
@@ -552,7 +558,7 @@ func headlightPlugin(t *testing.T) (*env, string) {
}
}
root, _ := filepath.Abs("../..")
root, _ := filepath.Abs("../..")
record := filepath.Join(t.TempDir(), "headlight.jsonl")
record := filepath.Join(t.TempDir(), "headlight.jsonl")
- e := startWithPlugin(t, nil, true, &config.PluginConfig{
+ e := startWithPlugin(t, prep, true, &config.PluginConfig{
Name: "headlight",
Name: "headlight",
Command: []string{py, filepath.Join(root, "internal/agent/testdata/headlight_plugin.py")},
Command: []string{py, filepath.Join(root, "internal/agent/testdata/headlight_plugin.py")},
Config: map[string]any{"record": record},
Config: map[string]any{"record": record},
@@ -665,6 +671,43 @@ func TestHeadlightEnvelopeAndAckData(t *testing.T) {
@@ -665,6 +671,43 @@ func TestHeadlightEnvelopeAndAckData(t *testing.T) {
}
}
}
}
+// On a connection whose fastest round trip is slow (a large clock uncertainty), a fresh headlight still reaches the
+// driver: the reserve is judged on the clock estimate, the uncertainty only on whether the deadline has passed
+// (Maticus, 2026-11-12: 164 ms of uncertainty refused every headlight as "136 of 300 ms left").
+func TestHeadlightReserveOnASlowLink(t *testing.T) {
+ e, record := headlightPluginWith(t, func(s *fakebot.Server) { s.HeartbeatAckDelay = 170 * time.Millisecond })
+ deadline := time.Now().Add(10 * time.Second)
+ for {
+ if d, ok := e.node.link.ClockUncertainty(); ok && d >= 160*time.Millisecond {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("no slow round trip measured")
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ r := headlightCommand(t, e, "hl-slow", true, HeadlightDeadlineMS, &protocol.CommandProvenance{})
+ if d := ackData(t, r); d["stage"] != "headlight_pending" {
+ t.Fatalf("a fresh headlight on a slow link was refused: %#v", r)
+ }
+ got := readHeadlight(t, record)[0]
+ if got.Provenance == nil || got.Provenance.ClockUncertaintyMS == nil || *got.Provenance.ClockUncertaintyMS < 160 {
+ t.Fatalf("the driver is told the link's uncertainty: %+v", got.Provenance)
+ }
+ // A frame that really sat 150 ms on the way is still refused.
+ cmd := protocol.Command{ID: "hl-slow-late", Kind: protocol.KindHeadlight, Value: json.RawMessage(`{"on":false}`), Provenance: &protocol.CommandProvenance{}}
+ if err := e.conn.CommandLate(cmd, HeadlightDeadlineMS, 150*time.Millisecond); err != nil {
+ t.Fatal(err)
+ }
+ late, err := e.conn.Reply("hl-slow-late", 5*time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n, ok := late.(protocol.Nack); !ok || n.FaultCode != protocol.FaultExpired {
+ t.Fatalf("the late headlight was not refused expired: %#v", late)
+ }
+}
+
func TestHeadlightReserveAndRefusals(t *testing.T) {
func TestHeadlightReserveAndRefusals(t *testing.T) {
e, _ := headlightPlugin(t)
e, _ := headlightPlugin(t)
// A frame that spent 150 ms on the way has only 150 ms of its original 300 ms lifetime left: never sent.
// A frame that spent 150 ms on the way has only 150 ms of its original 300 ms lifetime left: never sent.
@@ -47,6 +47,9 @@ type Server struct {
@@ -47,6 +47,9 @@ type Server struct {
// HoldHeartbeatAcks makes new connections leave heartbeats unanswered until Conn.SetAutoAck(true): the device has
// HoldHeartbeatAcks makes new connections leave heartbeats unanswered until Conn.SetAutoAck(true): the device has
// no measured round trip, so no bound on its clock estimate.
// no measured round trip, so no bound on its clock estimate.
HoldHeartbeatAcks bool
HoldHeartbeatAcks bool
+ // HeartbeatAckDelay answers each heartbeat this much later: a slow path, so a large clock uncertainty (a connection
+ // whose fastest round trip is long).
+ HeartbeatAckDelay time.Duration
mu sync.Mutex
mu sync.Mutex
codes map[string]string // code → robot id
codes map[string]string // code → robot id
@@ -704,7 +707,11 @@ func (c *Conn) read() {
@@ -704,7 +707,11 @@ func (c *Conn) read() {
t := hb.T
t := hb.T
ack.T, ack.Echo = t, &t
ack.T, ack.Echo = t, &t
}
}
- _ = c.Send(ack)
+ if c.srv != nil && c.srv.HeartbeatAckDelay > 0 {
+ time.AfterFunc(c.srv.HeartbeatAckDelay, func() { _ = c.Send(ack) })
+ } else {
+ _ = c.Send(ack)
+ }
}
}
if c.srv != nil {
if c.srv != nil {
c.srv.relayFrame(c, f.Msg)
c.srv.relayFrame(c, f.Msg)