Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lua-v1-exec-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Legacy (v1) Lua drivers now run `driver_poll`, `driver_command`, `driver_default_mode` and `driver_cleanup` under an execution deadline (default 10 s), where previously only signed control-v2 drivers were bounded and a spinning driver could wedge its goroutine forever. A deadline abort is treated as a normal driver failure — restart and autonomous default mode. Per-driver override: `command_timeout_s` in the driver's YAML block (`0` restores the old unbounded behavior). `driver_init` stays unbounded for legacy drivers, since slow discovery at startup is legitimate.
7 changes: 7 additions & 0 deletions docs/writing-a-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ and stop emitting when it is stale so core's watchdog can work.
`driver_fingerprint(target)` is an optional passive setup probe. It must never
reconfigure the device.

`driver_poll`, `driver_command`, `driver_default_mode` and `driver_cleanup`
run under a 10 s execution deadline; hitting it is treated like any other
driver failure (restart and autonomous default mode). Slow is fine — wedged
is not. An operator can tune this per driver with `command_timeout_s` in the
driver's YAML block (`0` disables the deadline). `driver_init` is exempt:
slow discovery at startup is legitimate.

## Sign convention

Translate before calling `host.emit` and translate commands in the opposite
Expand Down
25 changes: 25 additions & 0 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,13 @@ type Driver struct {
// Disabled skips this driver at startup / reload. Set via the UI when
// you want to temporarily take a driver out without editing yaml.
Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"`
// CommandTimeoutS bounds one Lua entrypoint execution (driver_poll,
// driver_command, driver_default_mode, driver_cleanup) for legacy
// (non-control-v2) drivers. Unset → 10 s default; explicit 0 restores
// the historical unbounded behavior for a driver whose device is slow
// but working. Signed control-v2 drivers keep their own (tighter)
// host-enforced deadlines regardless of this value.
CommandTimeoutS *int `yaml:"command_timeout_s,omitempty" json:"command_timeout_s,omitempty"`
// Control opts this one site into one exact signed control artifact.
// The runtime rejects control unless all three pins match the active
// Device Support package. Merely selecting the beta channel or installing
Expand All @@ -786,6 +793,24 @@ type Driver struct {
Modbus *ModbusConfig `yaml:"modbus,omitempty" json:"modbus,omitempty"`
}

// DefaultDriverCommandTimeout is the execution deadline applied to a
// legacy driver's Lua entrypoints when command_timeout_s is unset.
// Generous on purpose: cloud-API drivers legitimately spend seconds per
// poll; the deadline exists to catch wedged-forever, not slow.
const DefaultDriverCommandTimeout = 10 * time.Second

// ExecTimeout resolves command_timeout_s: unset → the 10 s default,
// explicit 0 (or negative) → no deadline (historical behavior).
func (d Driver) ExecTimeout() time.Duration {
if d.CommandTimeoutS == nil {
return DefaultDriverCommandTimeout
}
if *d.CommandTimeoutS <= 0 {
return 0
}
return time.Duration(*d.CommandTimeoutS) * time.Second
}

// DriverControlOptIn is a per-site, fail-closed control grant. PackageID,
// Version and ArtifactSHA256 must match signed active package metadata.
type DriverControlOptIn struct {
Expand Down
35 changes: 33 additions & 2 deletions go/internal/drivers/lua.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ type LuaDriver struct {
Env *HostEnv
Path string

// ExecTimeout bounds one Lua entrypoint execution for legacy
// (non-control-v2) drivers. 0 = unbounded, the historical behavior.
// Control-v2 drivers ignore it: their per-entrypoint deadlines are
// host policy, not operator config. Set once at construction/Add
// time, before the driver's goroutine starts.
ExecTimeout time.Duration

mu sync.Mutex
L *lua.LState
}
Expand Down Expand Up @@ -209,7 +216,7 @@ func (d *LuaDriver) Poll(ctx context.Context) (time.Duration, error) {
if fn == lua.LNil {
return 0, nil
}
cleanup := d.setLifecycleContext(ctx, 10*time.Second)
cleanup := d.setExecContext(ctx, 10*time.Second)
defer cleanup()
d.Env.beginPollEvidence()
if err := d.L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true}); err != nil {
Expand Down Expand Up @@ -276,6 +283,8 @@ func (d *LuaDriver) Command(ctx context.Context, cmdJSON []byte) error {
if !ok {
power, _ = cmd["w"].(float64)
}
cleanup := d.setExecContext(ctx, 10*time.Second)
defer cleanup()
t := goToLua(d.L, cmd)
if err := d.L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true},
lua.LString(action), lua.LNumber(power), t); err != nil {
Expand Down Expand Up @@ -484,6 +493,28 @@ func (d *LuaDriver) setLifecycleContext(parent context.Context, timeout time.Dur
if d.Env.RuntimePolicy == nil || !d.Env.RuntimePolicy.IsControlV2() {
return func() {}
}
return d.applyContext(parent, timeout)
}

// setExecContext bounds a steady-state entrypoint (poll, command,
// default mode, cleanup). Control-v2 drivers keep their host-policy
// deadline; legacy drivers get the operator-configurable ExecTimeout,
// where 0 preserves the historical unbounded behavior. driver_init is
// deliberately NOT routed through here — legacy inits may legitimately
// block on slow discovery. A deadline abort surfaces as a Lua error
// from the protected call, which callers already treat as a driver
// failure (→ restart / watchdog default-mode path).
func (d *LuaDriver) setExecContext(parent context.Context, v2Timeout time.Duration) func() {
if d.Env.RuntimePolicy != nil && d.Env.RuntimePolicy.IsControlV2() {
return d.applyContext(parent, v2Timeout)
}
if d.ExecTimeout <= 0 {
return func() {}
}
return d.applyContext(parent, d.ExecTimeout)
}

func (d *LuaDriver) applyContext(parent context.Context, timeout time.Duration) func() {
if parent == nil {
parent = context.Background()
}
Expand Down Expand Up @@ -532,7 +563,7 @@ func (d *LuaDriver) call(name string) error {
if fn == lua.LNil {
return nil
}
cleanup := d.setLifecycleContext(context.Background(), 5*time.Second)
cleanup := d.setExecContext(context.Background(), 5*time.Second)
defer cleanup()
if err := d.L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true}); err != nil {
return err
Expand Down
158 changes: 158 additions & 0 deletions go/internal/drivers/lua_exec_timeout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package drivers

import (
"context"
"os"
"path/filepath"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/telemetry"
)

// A legacy (v1) driver whose poll and command spin forever, like a driver
// stuck in a retry loop against a dead device.
const spinningDriverSrc = `
function driver_init(config) end
function driver_poll()
while true do end
end
function driver_command(action, w, cmd)
while true do end
end
function driver_default_mode()
while true do end
end
`

func newSpinningDriver(t *testing.T, timeout time.Duration) *LuaDriver {
t.Helper()
path := filepath.Join(t.TempDir(), "spin.lua")
if err := os.WriteFile(path, []byte(spinningDriverSrc), 0644); err != nil {
t.Fatal(err)
}
d, err := NewLuaDriver(path, NewHostEnv("spin", telemetry.NewStore()))
if err != nil {
t.Fatalf("load: %v", err)
}
d.ExecTimeout = timeout
return d
}

func TestLegacyDriverPollTimesOut(t *testing.T) {
d := newSpinningDriver(t, 200*time.Millisecond)
defer d.Cleanup()
done := make(chan error, 1)
go func() {
_, err := d.Poll(context.Background())
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatal("spinning poll returned nil error, want deadline failure")
}
case <-time.After(5 * time.Second):
t.Fatal("spinning poll never returned; legacy drivers are unbounded")
}
}

func TestLegacyDriverCommandTimesOut(t *testing.T) {
d := newSpinningDriver(t, 200*time.Millisecond)
defer d.Cleanup()
done := make(chan error, 1)
go func() {
done <- d.Command(context.Background(), []byte(`{"action":"battery","power_w":0}`))
}()
select {
case err := <-done:
if err == nil {
t.Fatal("spinning command returned nil error, want deadline failure")
}
case <-time.After(5 * time.Second):
t.Fatal("spinning command never returned")
}
}

func TestLegacyDriverDefaultModeTimesOut(t *testing.T) {
d := newSpinningDriver(t, 200*time.Millisecond)
defer d.Cleanup()
done := make(chan error, 1)
go func() {
done <- d.DefaultMode()
}()
select {
case err := <-done:
if err == nil {
t.Fatal("spinning default_mode returned nil error, want deadline failure")
}
case <-time.After(5 * time.Second):
t.Fatal("spinning default_mode never returned")
}
}

// A well-behaved driver must keep working after another entrypoint hit its
// deadline: the abort must not poison the LState for subsequent calls.
const recoveringDriverSrc = `
slow = true
function driver_init(config) end
function driver_poll()
if slow then
slow = false
while true do end
end
host.emit("meter", { w = 42 })
return 1000
end
`

func TestLegacyDriverRecoversAfterTimeout(t *testing.T) {
path := filepath.Join(t.TempDir(), "recover.lua")
if err := os.WriteFile(path, []byte(recoveringDriverSrc), 0644); err != nil {
t.Fatal(err)
}
d, err := NewLuaDriver(path, NewHostEnv("recover", telemetry.NewStore()))
if err != nil {
t.Fatalf("load: %v", err)
}
d.ExecTimeout = 200 * time.Millisecond
defer d.Cleanup()

if _, err := d.Poll(context.Background()); err == nil {
t.Fatal("first poll should hit the deadline")
}
next, err := d.Poll(context.Background())
if err != nil {
t.Fatalf("second poll after timeout: %v", err)
}
if next != time.Second {
t.Fatalf("second poll interval = %v, want 1s", next)
}
}

func TestLegacyDriverZeroTimeoutStaysUnbounded(t *testing.T) {
// ExecTimeout 0 must not install any context: a normal driver runs
// exactly as before. (We can't wait forever to prove unboundedness;
// instead prove that a normal poll works and no context is present.)
path := filepath.Join(t.TempDir(), "plain.lua")
if err := os.WriteFile(path, []byte(testDriverSrc), 0644); err != nil {
t.Fatal(err)
}
env := NewHostEnv("plain", telemetry.NewStore())
env.BatteryCapacityWh = 9600
d, err := NewLuaDriver(path, env)
if err != nil {
t.Fatalf("load: %v", err)
}
d.ExecTimeout = 0
defer d.Cleanup()
if err := d.Init(context.Background(), map[string]any{"foo": "bar"}); err != nil {
t.Fatalf("init: %v", err)
}
if _, err := d.Poll(context.Background()); err != nil {
t.Fatalf("poll: %v", err)
}
if d.L.Context() != nil {
t.Fatal("no lifecycle context should remain installed")
}
}
1 change: 1 addition & 0 deletions go/internal/drivers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ func (r *Registry) Add(ctx context.Context, cfg config.Driver) error {
if err != nil {
return fmt.Errorf("load lua: %w", err)
}
luaDrv.ExecTimeout = cfg.ExecTimeout()
var drv driverRuntime = &luaRuntime{LuaDriver: luaDrv}

r.mu.Lock()
Expand Down