openvibers/openvibe.link Commits

Link v0.61.0: a headlight is not refused on a slow link (#168)

OP OpenVibers committed 18 h ago · fa812ac428 · 4 files, +59 −6

Link judged the 200 ms a headlight must have left of its 300 ms lifetime from the latest the server's clock could be, so the clock's uncertainty was spent twice: a connection whose fastest round trip was 163 ms refused every fresh headlight ("only 136 ms of the headlight's original 300 ms lifetime was left"), while drive commands passed (Maticus, 11-12). The reserve is now judged on the clock estimate; the uncertainty still decides whether the deadline has passed. The fake Bot can answer heartbeats late (HeartbeatAckDelay) and a test covers a 170 ms path both ways.

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 47b7888..1150e17 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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,
ddiff --git a/internal/agent/agent.go b/internal/agent/agent.go
iindex 1e6b0fe..782c0f7 100644
---- a/internal/agent/agent.go
++++ b/internal/agent/agent.go
@@@ -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.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))
return
ddiff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
iindex 69b25fb..0d21402 100644
---- a/internal/agent/agent_test.go
++++ b/internal/agent/agent_test.go
@@@ -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.
ddiff --git a/internal/fakebot/fakebot.go b/internal/fakebot/fakebot.go
iindex 0d7b5ad..f474d25 100644
---- a/internal/fakebot/fakebot.go
++++ b/internal/fakebot/fakebot.go
@@@ -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.Duration
mu sync.Mutex
codes map[string]string // code → robot id
@@@ -704,7 +707,11 @@ func (c *Conn) read() {
t := hb.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 {
c.srv.relayFrame(c, f.Msg)