Skip to content

Commit 2a738d2

Browse files
authored
refactor(lua): plugin cleanup + async hive.sh (#326)
## Summary Architectural cleanup of `internal/hive/plugins/lua/` plus the bundled async form of `hive.sh.*`. Bundled because the cleanup includes extracting an async-handle registry that's only meaningful once a second async module exists, and the async-sh work was the natural pair. ## Cleanup - **Test harness consolidation** — replaces five per-module harnesses (`newJSONHarness`/`newKVHarness`/`newTickerHarness`/`newShHarness` + a bespoke captureModule for sh) with one `newLuaHarness` + unified `captureModule` (`helpers_test.go`). - **Lua↔Go conversion helpers** — promotes `goToLua`/`luaToGo` from `JSONModule` into `lua_convert.go` so future modules can use them. `arrayMarker` is now an explicit parameter; callers without an array-tagging concept pass `nil`. Direct unit tests live in `lua_convert_test.go`. - **Async-handle registry extraction** — lifts the duplicated scaffolding (registry sub-table, handle map, root-context fan-out, metatable, cancel) shared by ticker and sh into `asyncRegistry` (`async_registry.go`). Both modules now embed one and use `Go`/`allocate`/`loadFunction`/`release`/`stop` for per-handle work. `asyncHandle` is the single shared type. - **Field-reader precondition** — collapses the four `lua*Field` preludes (RawGetString, reject LTFunction, type-assert) into `luaCommandField`. - **Error convention** — documents when to use `ArgError` vs `RaiseError` vs the two-return form in `modules.go`; standardises the `hive.<module>.<fn>:` prefix on KV and Commands errors. - **Logging convention** — documents per-module log levels in `modules.go`; `KVModule` gains a `Logger` and now logs Warn on store errors so operators see them even when the plugin `pcall`s the call. ShModule's sync/async start/finish/cancel logging is deduped through `execWithLogging` + `log{Start,Finish,PoolCancelled}`. - **Dead code + godoc** — drops unused `PluginName` fields on Ticker/ShModule (only LogModule actually uses its name); backfills godoc on `Plugin`'s exported methods and the bare module `Register` methods. ## Async hive.sh - `hive.sh.{run,output,exec}` accept a trailing function callback that switches the call to async mode: returns a handle immediately, runs the subprocess on a goroutine bound to a per-handle context, fires the callback on the dispatcher when finished. - `handle:cancel()` kills the in-flight subprocess and suppresses the pending callback. Plugin shutdown cancels every in-flight async call. - Async output uses a `(stdout, err)` callback shape because it cannot raise from the post-return callback; sync output still raises on non-zero exit. - Documented in `docs/configuration/plugins.md`; integration coverage in `test/integration/lua_plugin_sh_test.go`. ## Test plan - [x] `go test -count=1 -race ./internal/hive/plugins/lua/...` passes - [x] `golangci-lint run ./internal/hive/plugins/lua/...` is clean - [x] `go build -tags integration ./test/integration/...` compiles - [ ] CI green
1 parent 11b133d commit 2a738d2

19 files changed

Lines changed: 1844 additions & 1088 deletions

docs/configuration/plugins.md

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -271,23 +271,54 @@ Run shell commands from Lua. All three functions execute via `sh -c` and share t
271271
!!! danger "Trust boundary"
272272
`hive.sh` runs commands with the full privileges of the Hive process. Only enable Lua plugins from sources you trust — installing one is equivalent to running its shell commands as your user.
273273

274-
| Function | Purpose |
275-
| -------- | ------- |
276-
| `hive.sh.run(cmd)` | Run `cmd`. Returns the exit code. Never raises. |
277-
| `hive.sh.output(cmd)` | Run `cmd`, return stdout with one trailing newline stripped. Raises on non-zero exit or executor error. |
278-
| `hive.sh.exec(cmd[, opts])` | Run `cmd` with optional `{cwd, timeout}`. Returns `{stdout, stderr, code, err}`. Never raises. |
274+
Every `hive.sh.*` call is async: the call returns a handle immediately and the supplied callback fires on the dispatcher when the subprocess finishes. The callback is **required** — calling without one raises a Lua error.
275+
276+
| Function | Callback signature | Notes |
277+
| -------- | ------------------ | ----- |
278+
| `hive.sh.run(cmd, fn)` | `fn(code)` | Receives the exit code. |
279+
| `hive.sh.output(cmd, fn)` | `fn(stdout, err)` | On success: `stdout` is trimmed of one trailing newline and `err` is nil. On non-zero exit or executor error: `stdout` is nil and `err` is a string with the exit code and stderr. |
280+
| `hive.sh.exec(cmd[, opts], fn)` | `fn(result)` | `result` is `{stdout, stderr, code, err}`. |
279281

280282
`opts.timeout` is in seconds (number). `opts.cwd` overrides the working directory for that call only; an empty string inherits Hive's working directory.
281283

284+
Each call returns a handle with a `:cancel()` method that kills the in-flight subprocess and suppresses the pending callback.
285+
282286
```lua
283287
return function(hive)
284-
local code = hive.sh.run("git fetch origin") -- exit code only
285-
local head = hive.sh.output("git rev-parse HEAD") -- stdout, raises on non-zero
286-
local r = hive.sh.exec("ls -la", { cwd = "/tmp", timeout = 5 })
287-
-- r.stdout, r.stderr, r.code, r.err
288+
hive.sh.run("git fetch origin", function(code)
289+
hive.log.info("fetch exited with " .. code)
290+
end)
291+
292+
hive.sh.output("git rev-parse HEAD", function(head, err)
293+
if err ~= nil then
294+
hive.log.warn("rev-parse failed: " .. err)
295+
return
296+
end
297+
hive.log.info("HEAD is " .. head)
298+
end)
299+
300+
hive.sh.exec("ls -la", { cwd = "/tmp", timeout = 5 }, function(r)
301+
hive.log.info("listed " .. r.stdout)
302+
end)
303+
304+
local handle = hive.sh.run("sleep 600", function(_) end)
305+
hive.ticker.after("5s", function() handle:cancel() end)
288306
end
289307
```
290308

309+
!!! note "Callbacks run on the dispatcher"
310+
Every callback runs on the same Lua dispatcher as ticker fires and
311+
the plugin entrypoint. A slow callback serialises later Lua work in
312+
that plugin — keep callbacks short or hand long work off via another
313+
`hive.sh.*` call.
314+
315+
!!! warning "Shutdown cancels in-flight calls"
316+
Plugin reload or `hive` shutdown cancels every in-flight call: the
317+
per-handle context is cancelled, the subprocess is killed, and the
318+
pinned callback is released for GC. Pending callbacks may still fire
319+
once with a non-zero `code` and a cancellation `err`, but never
320+
after the runtime has fully closed.
321+
291322
!!! note "Shared shell pool"
292323
`hive.sh.*` calls draw from the same `plugins.shell_workers` budget as plugin status refreshes, so a slow shell command from Lua can delay other plugins. The default per-call timeout is 30s; tune it via `plugins.lua.shell_timeout`. Plugin shutdown cancels every in-flight command.
293324

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
package lua
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strconv"
7+
"sync"
8+
"sync/atomic"
9+
"time"
10+
11+
glua "github.com/yuin/gopher-lua"
12+
)
13+
14+
// asyncShutdownGracePeriod bounds how long shutdown waits for in-flight
15+
// goroutines to finish naturally before force-cancelling. Long enough
16+
// for fast plugin entrypoint chains (a few subprocess calls) to drain
17+
// at app exit; short enough that a stuck call can't stall shutdown
18+
// indefinitely. After the grace period elapses, rootCtx is cancelled
19+
// and any remaining work aborts via the per-handle ctx.
20+
const asyncShutdownGracePeriod = time.Second
21+
22+
// asyncRegistry holds the Lua-side and Go-side bookkeeping shared by
23+
// every async host module (hive.ticker, hive.sh, ...). Each module owns
24+
// one registry, initialises it during Register, and tears it down via
25+
// shutdown.
26+
//
27+
// The registry handles:
28+
//
29+
// - A per-module sub-table in the Lua registry that pins LFunctions
30+
// so callbacks survive Lua GC while a goroutine still references
31+
// them.
32+
// - A handle map keyed by id, storing the per-handle context.CancelFunc
33+
// so any goroutine can cancel a specific running operation.
34+
// - A root context whose cancellation fans out to every per-handle
35+
// context, used for module shutdown.
36+
// - A WaitGroup that lets shutdown block until in-flight goroutines
37+
// finish.
38+
// - The shared __index metatable installed on every handle userdata.
39+
//
40+
// Module code stays small: allocate to start a new operation, loadFunction
41+
// to invoke its callback on the dispatcher, release after a successful
42+
// dispatch, and stop or asyncHandle.Cancel for any-goroutine cancellation.
43+
type asyncRegistry struct {
44+
cfg asyncRegistryConfig
45+
46+
// registryKey namespaces the per-module sub-table in the Lua
47+
// registry. The instance pointer is appended so multiple modules of
48+
// the same kind on one LState don't stomp each other.
49+
registryKey string
50+
51+
// nextID is also the per-handle registry sub-key, stringified.
52+
nextID atomic.Uint64
53+
54+
mu sync.Mutex
55+
handles map[uint64]context.CancelFunc
56+
57+
rootCtx context.Context
58+
rootCancel context.CancelFunc
59+
60+
wg sync.WaitGroup
61+
62+
closeOnce sync.Once
63+
}
64+
65+
// asyncRegistryConfig configures the namespacing and Lua-facing names
66+
// for an asyncRegistry. KeyPrefix should end in a "." for readability.
67+
type asyncRegistryConfig struct {
68+
// KeyPrefix prefixes the per-module Lua registry sub-table key.
69+
// Example: "hive.ticker."
70+
KeyPrefix string
71+
// MetatableName names the Lua metatable installed on every handle
72+
// userdata. Example: "hive.ticker.handle"
73+
MetatableName string
74+
}
75+
76+
// asyncHandle is the Go-side state behind every async userdata returned
77+
// to Lua. Cancel is idempotent, safe from any goroutine, and routes
78+
// through the registry to delete the map entry, cancel the per-handle
79+
// context, and release the registry pin.
80+
type asyncHandle struct {
81+
id uint64
82+
once sync.Once
83+
registry *asyncRegistry
84+
runtime *Runtime
85+
}
86+
87+
// init prepares the registry for use. modulePtr is fmt-formatted with
88+
// %p to make registryKey unique per module instance (so two modules of
89+
// the same kind on one LState don't collide). Must run on the
90+
// dispatcher.
91+
func (r *asyncRegistry) init(state *glua.LState, modulePtr any, cfg asyncRegistryConfig) error {
92+
r.cfg = cfg
93+
r.handles = map[uint64]context.CancelFunc{}
94+
r.rootCtx, r.rootCancel = context.WithCancel(context.Background())
95+
r.registryKey = fmt.Sprintf("%s%p", cfg.KeyPrefix, modulePtr)
96+
97+
registry, ok := state.Get(glua.RegistryIndex).(*glua.LTable)
98+
if !ok {
99+
return fmt.Errorf("%slua registry is not a table", cfg.KeyPrefix)
100+
}
101+
state.SetField(registry, r.registryKey, state.NewTable())
102+
103+
mt := state.NewTypeMetatable(cfg.MetatableName)
104+
state.SetField(mt, "__index", state.SetFuncs(state.NewTable(), map[string]glua.LGFunction{
105+
"cancel": r.luaCancel,
106+
}))
107+
return nil
108+
}
109+
110+
// shutdown drains in-flight goroutines before force-cancelling. First
111+
// gives the chain up to asyncShutdownGracePeriod to finish naturally so
112+
// fast callback chains spawned during plugin Init complete cleanly;
113+
// only then cancels rootCtx and waits for stragglers. Clears the handle
114+
// map. Idempotent.
115+
func (r *asyncRegistry) shutdown() {
116+
r.closeOnce.Do(func() {
117+
// Phase 1: graceful drain. Wait up to asyncShutdownGracePeriod
118+
// for the chain to complete naturally. spawnAsync's goroutines
119+
// hold the wg until their dispatch (and any callback-spawned
120+
// work) finishes, so wg.Wait covers the full callback chain.
121+
drained := make(chan struct{})
122+
go func() {
123+
r.wg.Wait()
124+
close(drained)
125+
}()
126+
127+
select {
128+
case <-drained:
129+
case <-time.After(asyncShutdownGracePeriod):
130+
// Phase 2: force-cancel and wait for stragglers.
131+
if r.rootCancel != nil {
132+
r.rootCancel()
133+
}
134+
<-drained
135+
}
136+
137+
// Cancel rootCtx unconditionally so any goroutine that races
138+
// past the drain (none should, but defensively) sees a closed
139+
// context if it inspects ctx after spawnAsync returns.
140+
if r.rootCancel != nil {
141+
r.rootCancel()
142+
}
143+
144+
r.mu.Lock()
145+
r.handles = map[uint64]context.CancelFunc{}
146+
r.mu.Unlock()
147+
})
148+
}
149+
150+
// Go starts fn in a goroutine tracked by the registry's WaitGroup so
151+
// shutdown blocks until fn completes. Mirrors sync.WaitGroup.Go;
152+
// capitalised because lowercase `go` is a reserved keyword.
153+
func (r *asyncRegistry) Go(fn func()) { r.wg.Go(fn) }
154+
155+
// allocate pins fn under a fresh handle id, derives a per-handle context
156+
// from rootCtx, and records the cancel func in the handle map. Returns
157+
// the id and ctx for the goroutine to bind to. Must run on the
158+
// dispatcher.
159+
func (r *asyncRegistry) allocate(state *glua.LState, fn *glua.LFunction) (uint64, context.Context) {
160+
id := r.nextID.Add(1)
161+
ctx, cancel := context.WithCancel(r.rootCtx)
162+
163+
r.storeFunction(state, id, fn)
164+
165+
r.mu.Lock()
166+
r.handles[id] = cancel
167+
r.mu.Unlock()
168+
169+
return id, ctx
170+
}
171+
172+
// loadFunction returns the pinned function for id, or nil if already
173+
// released. Must run on the dispatcher.
174+
func (r *asyncRegistry) loadFunction(state *glua.LState, id uint64) *glua.LFunction {
175+
table := r.registryTable(state)
176+
if table == nil {
177+
return nil
178+
}
179+
fn, ok := state.GetField(table, strconv.FormatUint(id, 10)).(*glua.LFunction)
180+
if !ok {
181+
return nil
182+
}
183+
return fn
184+
}
185+
186+
// release drops the handle map entry and registry pin for id. Used after
187+
// a dispatch path has consumed the callback. Safe even if id is unknown.
188+
// Must run on the dispatcher.
189+
func (r *asyncRegistry) release(state *glua.LState, id uint64) {
190+
r.mu.Lock()
191+
delete(r.handles, id)
192+
r.mu.Unlock()
193+
194+
r.releaseFunction(state, id)
195+
}
196+
197+
// stop cancels the per-handle context, removes the map entry, and
198+
// schedules the registry pin release on the dispatcher. Safe from any
199+
// goroutine; a missing id is treated as already released.
200+
func (r *asyncRegistry) stop(rt *Runtime, id uint64) {
201+
r.mu.Lock()
202+
cancel, ok := r.handles[id]
203+
if ok {
204+
delete(r.handles, id)
205+
}
206+
r.mu.Unlock()
207+
if !ok {
208+
return
209+
}
210+
cancel()
211+
212+
rt.Submit(func(state *glua.LState) {
213+
r.releaseFunction(state, id)
214+
})
215+
}
216+
217+
// handleUserData wraps value in *LUserData with the registry's metatable.
218+
// Must run on the dispatcher.
219+
func (r *asyncRegistry) handleUserData(state *glua.LState, value any) *glua.LUserData {
220+
ud := state.NewUserData()
221+
ud.Value = value
222+
state.SetMetatable(ud, state.GetTypeMetatable(r.cfg.MetatableName))
223+
return ud
224+
}
225+
226+
// newHandle returns a fresh asyncHandle wired to this registry and the
227+
// supplied runtime. Pair with allocate to get the id.
228+
func (r *asyncRegistry) newHandle(id uint64, rt *Runtime) *asyncHandle {
229+
return &asyncHandle{id: id, registry: r, runtime: rt}
230+
}
231+
232+
// luaCancel is the __index method bound to handle userdata. Type-asserts
233+
// the userdata as *asyncHandle (which both ticker and sh use) and routes
234+
// to Cancel.
235+
func (r *asyncRegistry) luaCancel(state *glua.LState) int {
236+
ud := state.CheckUserData(1)
237+
h, ok := ud.Value.(*asyncHandle)
238+
if !ok {
239+
state.ArgError(1, "expected "+r.cfg.MetatableName+" handle")
240+
return 0
241+
}
242+
h.Cancel()
243+
return 0
244+
}
245+
246+
// storeFunction pins fn so Lua cannot GC it while the goroutine holds a
247+
// reference. Must run on the dispatcher.
248+
func (r *asyncRegistry) storeFunction(state *glua.LState, id uint64, fn *glua.LFunction) {
249+
table := r.registryTable(state)
250+
if table == nil {
251+
return
252+
}
253+
state.SetField(table, strconv.FormatUint(id, 10), fn)
254+
}
255+
256+
// releaseFunction drops the registry pin so Lua can GC the LFunction.
257+
// Must run on the dispatcher.
258+
func (r *asyncRegistry) releaseFunction(state *glua.LState, id uint64) {
259+
table := r.registryTable(state)
260+
if table == nil {
261+
return
262+
}
263+
state.SetField(table, strconv.FormatUint(id, 10), glua.LNil)
264+
}
265+
266+
// registryTable returns this registry's pinning sub-table, or nil if
267+
// init has not run.
268+
func (r *asyncRegistry) registryTable(state *glua.LState) *glua.LTable {
269+
registry, ok := state.Get(glua.RegistryIndex).(*glua.LTable)
270+
if !ok {
271+
return nil
272+
}
273+
table, ok := state.GetField(registry, r.registryKey).(*glua.LTable)
274+
if !ok {
275+
return nil
276+
}
277+
return table
278+
}
279+
280+
// Cancel stops the per-handle goroutine, removes the map entry, and
281+
// schedules the registry pin release on the dispatcher. Idempotent and
282+
// safe from any goroutine.
283+
func (h *asyncHandle) Cancel() {
284+
h.once.Do(func() {
285+
h.registry.stop(h.runtime, h.id)
286+
})
287+
}
288+
289+
// poison consumes the once so a later Cancel is a true no-op. Used by
290+
// post-dispatch cleanup paths that already released via the dispatcher
291+
// and want to suppress redundant Cancel work.
292+
func (h *asyncHandle) poison() {
293+
h.once.Do(func() {})
294+
}

0 commit comments

Comments
 (0)