diff --git a/.changeset/lua-v1-exec-timeout.md b/.changeset/lua-v1-exec-timeout.md new file mode 100644 index 00000000..0ad92f8b --- /dev/null +++ b/.changeset/lua-v1-exec-timeout.md @@ -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. diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index 7a2628b3..4ed332eb 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -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 diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 074d84e8..16f4a02a 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -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 @@ -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 { diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index bb62185c..884584e8 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -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 } @@ -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 { @@ -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 { @@ -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() } @@ -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 diff --git a/go/internal/drivers/lua_exec_timeout_test.go b/go/internal/drivers/lua_exec_timeout_test.go new file mode 100644 index 00000000..733e4945 --- /dev/null +++ b/go/internal/drivers/lua_exec_timeout_test.go @@ -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") + } +} diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index 29f99c31..895bf3e8 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -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()