From b46cbee35e0992c04eaa00c4fbf1e6a297e60390 Mon Sep 17 00:00:00 2001 From: PatternCatalyst Date: Sun, 26 Jul 2026 01:53:26 -0400 Subject: [PATCH] =?UTF-8?q?docs(r11):=20embedded=20Lua=20policy=20engine?= =?UTF-8?q?=20=E2=80=94=20example=2042=20+=20chapter=2042=20(Deep=20Dives)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 12 opens: pmon lifts its restart/backoff decision out of the compiled host and into a single shared policy.lua, embedded three structurally different ways — C++23 via sol2 (Lua 5.4), Go via gopher-lua (Lua 5.1), Rust via mlua (Lua 5.4) — and hot-reloaded live on a real SIGHUP with no process restart. Example 42-embedded-lua-scripting (mode: local): - one Lua-5.1-safe policy.lua drives all three hosts to byte-identical decisions (diff-confirmed); policy-v2 is the SIGHUP edit (500ms→100ms backoff, max_restarts 8→2 flipping a restart into an escalate). - sandbox opens only base/string/table/math, nils the loader globals, and never loads os/io/package/debug; policy-evil.lua (os.execute) and policy-broken.lua (missing end) prove runtime + load-time rejection, with genuinely different diagnostics per binding. - verify.lua asserts behaviour (PASS 19/FAIL 0 per language), not exit codes. Chapter 42 to the spine: Tools-used box, 3 verbatim tri-language codetab sets, Figures 42.1 (host↔script boundary) + 42.2 (SIGHUP reload lifecycle), verified footer. validate.py: teach check_lua to skip fixtures declaring "validate: expect-syntax-error" (policy-broken.lua is invalid by design). Gate: test-all-examples.py --only 42 → 3 passed/0 failed; full --mode local → 96 passed/0 failed/3 skipped (skips are 38-otel, lgtm down). validate: OK. --- _docs/42-embedded-lua-scripting.md | 735 ++++++++ .../42-host-script-boundary.excalidraw | 1563 +++++++++++++++ assets/diagrams/42-host-script-boundary.svg | 52 + .../42-sighup-reload-lifecycle.excalidraw | 1671 +++++++++++++++++ .../diagrams/42-sighup-reload-lifecycle.svg | 53 + assets/diagrams/README.md | 2 + examples/42-embedded-lua-scripting/README.md | 72 + .../cpp/.clang-format | 8 + .../42-embedded-lua-scripting/cpp/.clang-tidy | 11 + .../cpp/CMakeLists.txt | 17 + .../cpp/CMakePresets.json | 63 + .../cpp/conanfile.txt | 11 + .../42-embedded-lua-scripting/cpp/demo.sh | 49 + .../cpp/src/main.cpp | 326 ++++ examples/42-embedded-lua-scripting/demo.sh | 23 + examples/42-embedded-lua-scripting/go/demo.sh | 47 + examples/42-embedded-lua-scripting/go/go.mod | 7 + examples/42-embedded-lua-scripting/go/go.sum | 2 + examples/42-embedded-lua-scripting/go/main.go | 326 ++++ .../policy-broken.lua | 17 + .../42-embedded-lua-scripting/policy-evil.lua | 23 + .../42-embedded-lua-scripting/policy-v2.lua | 96 + examples/42-embedded-lua-scripting/policy.lua | 127 ++ .../42-embedded-lua-scripting/rust/Cargo.lock | 317 ++++ .../42-embedded-lua-scripting/rust/Cargo.toml | 13 + .../42-embedded-lua-scripting/rust/demo.sh | 47 + .../rust/rust-toolchain.toml | 3 + .../rust/src/main.rs | 327 ++++ examples/42-embedded-lua-scripting/verify.lua | 76 + examples/manifest.yaml | 4 + scripts/validate.py | 8 + 31 files changed, 6096 insertions(+) create mode 100644 _docs/42-embedded-lua-scripting.md create mode 100644 assets/diagrams/42-host-script-boundary.excalidraw create mode 100644 assets/diagrams/42-host-script-boundary.svg create mode 100644 assets/diagrams/42-sighup-reload-lifecycle.excalidraw create mode 100644 assets/diagrams/42-sighup-reload-lifecycle.svg create mode 100644 examples/42-embedded-lua-scripting/README.md create mode 100644 examples/42-embedded-lua-scripting/cpp/.clang-format create mode 100644 examples/42-embedded-lua-scripting/cpp/.clang-tidy create mode 100644 examples/42-embedded-lua-scripting/cpp/CMakeLists.txt create mode 100644 examples/42-embedded-lua-scripting/cpp/CMakePresets.json create mode 100644 examples/42-embedded-lua-scripting/cpp/conanfile.txt create mode 100755 examples/42-embedded-lua-scripting/cpp/demo.sh create mode 100644 examples/42-embedded-lua-scripting/cpp/src/main.cpp create mode 100755 examples/42-embedded-lua-scripting/demo.sh create mode 100755 examples/42-embedded-lua-scripting/go/demo.sh create mode 100644 examples/42-embedded-lua-scripting/go/go.mod create mode 100644 examples/42-embedded-lua-scripting/go/go.sum create mode 100644 examples/42-embedded-lua-scripting/go/main.go create mode 100644 examples/42-embedded-lua-scripting/policy-broken.lua create mode 100644 examples/42-embedded-lua-scripting/policy-evil.lua create mode 100644 examples/42-embedded-lua-scripting/policy-v2.lua create mode 100644 examples/42-embedded-lua-scripting/policy.lua create mode 100644 examples/42-embedded-lua-scripting/rust/Cargo.lock create mode 100644 examples/42-embedded-lua-scripting/rust/Cargo.toml create mode 100755 examples/42-embedded-lua-scripting/rust/demo.sh create mode 100644 examples/42-embedded-lua-scripting/rust/rust-toolchain.toml create mode 100644 examples/42-embedded-lua-scripting/rust/src/main.rs create mode 100644 examples/42-embedded-lua-scripting/verify.lua diff --git a/_docs/42-embedded-lua-scripting.md b/_docs/42-embedded-lua-scripting.md new file mode 100644 index 0000000..d985296 --- /dev/null +++ b/_docs/42-embedded-lua-scripting.md @@ -0,0 +1,735 @@ +--- +title: "Embedding Lua as a policy engine: one policy.lua, three host runtimes" +order: 42 +part: "Deep Dives" +description: "lua-policy moves pmon's restart/backoff decision out of the compiled binary and into a single sandboxed policy.lua, embedded three structurally different ways -- C++23 via sol2, Go via gopher-lua (Lua 5.1), Rust via mlua (Lua 5.4) -- and reloaded live on a real SIGHUP with no process restart. Verified on the Fedora 44 host: byte-identical decisions across all three languages (diff-confirmed), PASS 19/FAIL 0 per language, and three genuinely different sandbox-rejection diagnostics for the same malicious policy." +duration: "45 minutes" +--- + +Chapter 12 taught `pmon` to treat a signal as data instead of an +interruption: block `SIGCHLD` process-wide, then read it off a `signalfd` on +the next turn of an ordinary `poll` loop. Chapter 41's capstone fleet leaned +on the same discipline for `SIGTERM`/`SIGINT`. This chapter reaches for a +different signal — `SIGHUP`, "please re-read your configuration" — to +reload something `pmon` has never had before: a restart policy that is not +compiled in. + +Every earlier `pmon` shipped its restart/backoff logic as C++, Go, or Rust +source, rebuilt whenever the policy changed. This chapter's `pmon` keeps the +supervisor's *mechanism* — track a child, replay its exit — in the host +language, and moves the restart *decision* into `policy.lua`, one file with +one contract: `on_exit(info) -> decision`. Embedding Lua is not new. What is +worth a chapter is embedding the *same* file into three sandboxed +interpreters built three structurally different ways — sol2's C++ DSL over +PUC-Rio 5.4, gopher-lua's pure-Go 5.1 VM, mlua's Rust bindings over a +vendored PUC-Rio 5.4 — and proving all three reach byte-identical restart +decisions from it, live reload included. + +{% include excalidraw.html + file="42-host-script-boundary" + alt="Three stacked host-runtime boxes on the left -- C++23/sol2 against PUC-Rio Lua 5.4, Go/gopher-lua against Lua 5.1, Rust/mlua against a vendored PUC-Rio Lua 5.4 -- each with a bidirectional arrow into a center box labeled policy.lua, one file, marked on_exit(info) going in and decision{action,delay_ms,reason} coming back. The policy.lua box sits inside a shaded sandbox-boundary band with a ghost box reading blocked: os, io, package, debug (never opened); nilled: load/loadfile/dofile/require/collectgarbage/loadstring. A host table box on the right connects to policy.lua with a bidirectional arrow labeled host.log / host.now_ms. A caption note reads Lua 5.1 (gopher-lua) vs Lua 5.4 (sol2, mlua) forces the shared subset -- one policy.lua drives all three." + caption="Figure 42.1 — the host↔script boundary: three differently-built sandboxed Lua states, one shared policy.lua, and the host table it calls back into" %} + +> **Tools used** — `lua` (host, drives `verify.lua` and is the PUC-Rio 5.4 +> reference interpreter `policy.lua` was checked against), `kill` (host, +> sends the real `SIGHUP` that triggers the live reload — a shell builtin / +> coreutils command), and the three build toolchains `cmake`+`conan` / `go` +> / `cargo` (host). `lua`, `cmake`, `conan`, `go`, and `cargo` all appear in +> `scripts/check-host.sh`; `kill` ships in Fedora's base install. + +## The host↔script boundary: `on_exit(info) -> decision` + +`pmon`'s contract with `policy.lua` is four callbacks wide (Figure 42.1), +and every one crosses the boundary as plain data — numbers, strings, and +tables, never a function pointer or closure the host has to reason about: +`on_exit(info)` in, a `{action, delay_ms, reason}` decision back, plus two +callbacks the policy can call into the host, `host.log(msg)` and +`host.now_ms()`. + +Every simulated child exit becomes one call across that boundary. The host +builds an `info` table from a hardcoded `Event`/`event`/`ExitEvent` struct, +calls `on_exit` under a **protected** call — so a bug in the policy becomes +a returned error, never a crash — and reads back three fields from the +table Lua handed back. That marshaling looks remarkably similar across all +three despite three unrelated Lua C APIs underneath: + +{% include codetabs.html langs="C++|Go|Rust" %} + +```cpp +[[nodiscard]] std::expected decide(sol::state& lua, const Event& ev) { + sol::table info = lua.create_table(); + info["name"] = std::string{ev.name}; + info["exit_code"] = ev.exit_code; + info["restarts"] = ev.restarts; + info["consecutive_failures"] = ev.consecutive_failures; + info["uptime_ms"] = ev.uptime_ms; + info["last_backoff_ms"] = ev.last_backoff_ms; + + sol::protected_function fn = lua["on_exit"]; + if (!fn.valid()) { + return std::unexpected(std::string{"policy defines no on_exit"}); + } + sol::protected_function_result result = fn(info); + if (!result.valid()) { + sol::error err = result; + return std::unexpected(err.what()); + } + sol::table decision = result; + return Decision{ + .action = decision["action"], + .delay_ms = decision["delay_ms"], + .reason = decision["reason"], + }; +} +``` + +```go +func callOnExit(L *lua.LState, ev event) (decision, error) { + info := L.NewTable() + info.RawSetString("name", lua.LString(ev.name)) + if ev.exitCode != nil { + info.RawSetString("exit_code", lua.LNumber(*ev.exitCode)) + } + if ev.signal != nil { + info.RawSetString("signal", lua.LString(*ev.signal)) + } + info.RawSetString("restarts", lua.LNumber(ev.restarts)) + info.RawSetString("consecutive_failures", lua.LNumber(ev.consecutiveFailures)) + info.RawSetString("uptime_ms", lua.LNumber(ev.uptimeMs)) + info.RawSetString("last_backoff_ms", lua.LNumber(ev.lastBackoffMs)) + + fn, ok := L.GetGlobal("on_exit").(*lua.LFunction) + if !ok { + return decision{}, fmt.Errorf("policy defines no on_exit function") + } + if err := L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true}, info); err != nil { + return decision{}, fmt.Errorf("on_exit: %w", luaErr(err)) + } + ret := L.Get(-1) + L.Pop(1) + tbl, ok := ret.(*lua.LTable) + if !ok { + return decision{}, fmt.Errorf("on_exit returned %s, want table", ret.Type()) + } + return decision{ + action: tbl.RawGetString("action").String(), + delayMs: int64(lua.LVAsNumber(tbl.RawGetString("delay_ms"))), + reason: tbl.RawGetString("reason").String(), + }, nil +} +``` + +```rust +fn info_table(lua: &Lua, ev: &ExitEvent) -> mlua::Result { + let info = lua.create_table()?; + info.set("name", ev.name)?; + info.set("exit_code", ev.exit_code)?; + info.set("restarts", ev.restarts)?; + info.set("consecutive_failures", ev.consecutive_failures)?; + info.set("uptime_ms", ev.uptime_ms)?; + info.set("last_backoff_ms", ev.last_backoff_ms)?; + Ok(info) +} + +/// Call `on_exit(info)` under a protected call and print the resulting +/// decision. The `reason` text is entirely Lua's (built with +/// `string.format` in policy.lua) -- this only formats the wrapping +/// `pmon: decision ...` line around whatever the policy returned. +fn decide(lua: &Lua, ev: &ExitEvent) -> mlua::Result<()> { + let info = info_table(lua, ev)?; + let on_exit: Function = lua.globals().get("on_exit")?; + let decision: Table = on_exit.call(info)?; + + let action: String = decision.get("action")?; + let delay_ms: i64 = decision.get("delay_ms")?; + let reason: String = decision.get("reason")?; + + println!( + "pmon: decision child={} action={} delay_ms={} reason=\"{}\"", + ev.name, action, delay_ms, reason + ); + Ok(()) +} +``` + +`sol::protected_function`, `L.CallByParam{Protect:true}`, and a plain +`Function::call` returning `mlua::Result` are three names for the same idea: +a Lua-side error inside `on_exit` comes back to the host as data, not a +`longjmp` or a process-ending panic. `signal` is the one field every binding +treats as *conditionally present*: this scenario never kills a child with a +signal, so C++'s `info["signal"]` is simply never set, and Go's +`event.signal` being a `*string` makes that "absent, not nil" contract +explicit in the host's own type. + +That marshaling is scaffolding around the one function that makes the +restart decision, quoted here once because it is one file, not a codetab: + +```lua +function on_exit(info) + local name = info.name or "?" + local exit_code = info.exit_code -- integer or nil (signal death) + local signal = info.signal -- string or nil (e.g. "SIGSEGV") + local restarts = info.restarts or 0 + local consecutive = info.consecutive_failures or 0 + local uptime_ms = info.uptime_ms or -1 + local last_backoff_ms = info.last_backoff_ms or 0 + + -- Clean exit: the child chose to stop. Do not second-guess it. + if signal == nil and exit_code == 0 then + return { + action = "stop", + delay_ms = 0, + reason = string.format("%s exited 0: clean shutdown, not restarting", name), + } + end + + local consecutive_failures = consecutive + 1 + local how + if signal ~= nil then + how = "signal " .. signal + else + how = "exit code " .. tostring(exit_code) + end + + -- Crash-looping: several failures in a row, each dying almost immediately. + -- This is the case a flat restart counter misses -- 8 restarts spread over + -- an hour is normal churn, 3 restarts in 3 seconds is a broken binary. + local fast = uptime_ms >= 0 and uptime_ms < CONFIG.fast_crash_ms + if fast and consecutive_failures >= CONFIG.fast_crash_streak then + return { + action = "escalate", + delay_ms = 0, + reason = string.format( + "%s: %d fast failures in a row (last %s after %dms uptime) -- crash-looping, paging", + name, consecutive_failures, how, uptime_ms), + } + end + + -- Lifetime give-up: failing steadily, not in a tight loop, but it has burned + -- through its restart budget. + if restarts >= CONFIG.max_restarts then + return { + action = "escalate", + delay_ms = 0, + reason = string.format( + "%s: giving up after %d restarts (last %s)", name, restarts, how), + } + end + + local delay_ms = next_backoff_ms(last_backoff_ms) + return { + action = "restart", + delay_ms = delay_ms, + reason = string.format( + "%s: restart %d after %s (uptime %dms), backing off %dms", + name, restarts + 1, how, uptime_ms, delay_ms), + } +end +``` + +Four rules, checked in order, and every one is a decision the *host* never +makes: a clean exit always `stop`s; a tight streak of fast failures (three +inside `fast_crash_ms`, 2000 ms) `escalate`s regardless of lifetime restart +count — the case a flat counter misses, since 8 restarts over an hour is +normal churn and 3 in 3 seconds is a broken binary; burning through the +lifetime `max_restarts` budget also `escalate`s; anything else `restart`s +with a backoff doubled from whatever delay the host last used, floored at +`base_backoff_ms` and capped at `max_backoff_ms`. The whole restart policy +is under 130 lines of Lua, none of it duplicated in any of the three hosts. + +## One `policy.lua`, two Lua versions + +`policy.lua` is a single 128-line file, and it is not just shared by +convention — it is written to run correctly under **two different Lua +language versions** at once. sol2 and mlua both build against PUC-Rio **Lua +5.4.6**; gopher-lua is a pure-Go reimplementation of **Lua 5.1**. Lua 5.2 +through 5.4 added integer division (`//`), bitwise operators, `goto`, and +to-be-closed variables (``); 5.1 has none of them. `policy.lua`'s own +header comment states the constraint plainly: it "uses only features +present in BOTH Lua 5.1 and 5.4 ... so the exact same file drives all three +hosts." Concretely, the backoff doubling reads `delay = last_backoff_ms * +2`, not a bitwise left-shift, and every formatted string goes through +`string.format`, identical in both versions. This is the same lesson the +book has repeated elsewhere — a `hardware_concurrency()` call lying about a +container, a `runtime.NumCPU()` lying about an affinity mask — reached here +through language-version drift instead of kernel drift. + +## The sandbox: what `policy.lua` cannot touch + +`policy.lua` never sees `os`, `io`, `package`, or `debug` — those libraries +are simply never opened, in any binding — and `load`, `loadfile`, `dofile`, +`require`, `collectgarbage`, and `loadstring` are explicitly nilled out +afterward, even though their *owning* libraries were never opened either. +That belt-and-suspenders removal matters because those six globals are +reachable straight from `base`, the one library every binding does open; a +global with the same name could otherwise be reintroduced by a careless +policy. The three bindings build that sandbox with three different APIs, +but the shape is identical — open four libraries, remove six globals, +register one `host` table: + +{% include codetabs.html langs="C++|Go|Rust" %} + +```cpp +sol::state make_sandbox() { + sol::state lua; + lua.open_libraries(sol::lib::base, sol::lib::string, sol::lib::table, sol::lib::math); + lua["load"] = sol::lua_nil; + lua["loadfile"] = sol::lua_nil; + lua["dofile"] = sol::lua_nil; + lua["require"] = sol::lua_nil; + lua["collectgarbage"] = sol::lua_nil; + lua["loadstring"] = sol::lua_nil; + + sol::table host = lua.create_named_table("host"); + host.set_function("log", [](const std::string& msg) { say("pmon: policy: " + msg); }); + host.set_function("now_ms", []() -> long long { + return std::chrono::duration_cast( + steady_clock::now().time_since_epoch()) + .count(); + }); + return lua; +} +``` + +```go +func newSandbox() *lua.LState { + L := lua.NewState(lua.Options{SkipOpenLibs: true}) + + for _, lib := range []struct { + name string + open lua.LGFunction + }{ + {lua.BaseLibName, lua.OpenBase}, + {lua.StringLibName, lua.OpenString}, + {lua.TabLibName, lua.OpenTable}, + {lua.MathLibName, lua.OpenMath}, + } { + L.Push(L.NewFunction(lib.open)) + L.Push(lua.LString(lib.name)) + L.Call(1, 0) + } + + for _, g := range []string{"load", "loadfile", "dofile", "require", "collectgarbage", "loadstring"} { + L.SetGlobal(g, lua.LNil) + } + + // The unopened libraries (os, io, package, debug, coroutine) are left as + // plain nils by SkipOpenLibs, which gopher-lua's VM reports generically + // ("attempt to index a non-table object(nil) with key 'execute'") -- it + // never names the global the way PUC-Rio Lua does. Trade a nil for a + // guard table whose __index raises a message that names the blocked + // global explicitly: same sandboxing (the real library is still never + // opened), a clearer error for whoever runs `check`. + for _, blocked := range []string{"os", "io", "package", "debug", "coroutine"} { + name := blocked + guard := L.NewTable() + mt := L.NewTable() + mt.RawSetString("__index", L.NewFunction(func(L *lua.LState) int { + key := L.CheckString(2) + L.RaiseError("sandbox: global '%s' is not available (blocked field '%s')", name, key) + return 0 + })) + L.SetMetatable(guard, mt) + L.SetGlobal(name, guard) + } + + host := L.NewTable() + L.SetFuncs(host, map[string]lua.LGFunction{ + "log": func(L *lua.LState) int { + fmt.Printf("pmon: policy: %s\n", L.CheckString(1)) + return 0 + }, + "now_ms": func(L *lua.LState) int { + L.Push(lua.LNumber(time.Now().UnixMilli())) + return 1 + }, + }) + L.SetGlobal("host", host) + + return L +} +``` + +```rust +fn build_sandbox() -> mlua::Result { + let lua = Lua::new_with( + StdLib::STRING | StdLib::TABLE | StdLib::MATH, + LuaOptions::default(), + )?; + + for g in [ + "load", + "loadfile", + "dofile", + "require", + "collectgarbage", + "loadstring", + ] { + lua.globals().set(g, Value::Nil)?; + } + + let host = lua.create_table()?; + host.set( + "log", + lua.create_function(|_, msg: String| { + println!("pmon: policy: {msg}"); + Ok(()) + })?, + )?; + host.set("now_ms", lua.create_function(|_, ()| Ok(now_ms()))?)?; + lua.globals().set("host", host)?; + + Ok(lua) +} +``` + +The Go tab is longest for a real reason: `gopher-lua`'s `SkipOpenLibs` leaves +`os`/`io`/`package`/`debug`/`coroutine` as ordinary `nil` globals, and its +own nil-index error is generic — it never names *which* global was nil. +sol2 and mlua get PUC-Rio's actual error message for free; gopher-lua's host +installs a **guard table** per blocked library instead, with a metamethod +that names the field explicitly — a strictly more informative error for +whoever runs `check`. This split shows up again, unprompted, in "Errors, +three ways" below. + +## How the code works + +Both subcommands — `run` and `check` — funnel through the same three +functions: `make_sandbox`/`newSandbox`/`build_sandbox` builds the +interpreter, `load_policy`/`loadPolicy` runs the chunk under a protected +load and calls the policy's optional `on_load()` hook (only for the +`policy loaded: version=...` banner), and `decide`/`callOnExit`/`decide` +drives one `on_exit` call. `run` replays a fixed two-phase scenario — three +simulated exits against v1, a real `SIGHUP`, three more against whatever the +policy file now contains — hardcoded because the point here is the policy +engine, not a process reaper. `check` loads one policy path, probes +`on_exit` once, and reports `pmon: policy ok version=...` or `pmon: policy +error: ...`, exiting 0 or 1 — the "will this file behave" gate `demo.sh` +calls before ever running a policy for real. Every print in the C++ port +routes through a `say()` helper that calls `std::fflush(stdout)`: `demo.sh` +polls the redirected stdout file for the `awaiting SIGHUP` sentinel, and an +unflushed write would leave it staring at a stale, empty file. + +## Errors, three ways + +Each host's own source comments name its own "errors, three ways" split — +three unrelated idioms for the identical job, turning an interpreter +failure into data instead of a crash. **C++**: fallible operations return +`std::expected`, converting a failed +`sol::protected_function_result` into its `sol::error` message; `die()` is +reserved for CLI-usage/OS-resource failures with no data-driven recovery. +**Go**: `die()` prints the fully `%w`-wrapped error chain and exits 1; +`luaErr()` unwraps gopher-lua's `*lua.ApiError` via `errors.As` — never a +bare type assertion — so a caller sees `attempt to index a nil value +(global 'os')` rather than a Go-shaped wrapper string. **Rust**: CLI parse +failures `bail!` out of `parse_args`; host-side operations return +`anyhow::Result` composed with `.context()`; every boundary crossing into +Lua is an `mlua::Result` that a protected load/call turns into data instead +of a panic. + +The highlight, though, is what those three idioms produce for the *same* +bad input. `policy-evil.lua` reaches for `os.execute("id > /tmp/pwned")` +inside `on_exit`; `policy-broken.lua` is missing an `end`. Running +`./demo.sh check ../policy-evil.lua` against all three: + +```console +[host]$ ./demo.sh cpp check ../policy-evil.lua +pmon: policy error: ../policy-evil.lua:21: attempt to index a nil value (global 'os') +stack traceback: + [C]: in metamethod 'index' + ../policy-evil.lua:21: in function 'base.on_exit' + ... +[host]$ ./demo.sh go check ../policy-evil.lua +pmon: policy error: on_exit: ../policy-evil.lua:21: sandbox: global 'os' is not available (blocked field 'execute') +[host]$ ./demo.sh rust check ../policy-evil.lua +pmon: policy error: runtime error: [string "../policy-evil.lua"]:21: attempt to index a nil value (global 'os') +stack traceback: + [C]: in metamethod 'index' + ... +``` + +sol2 and mlua report the *identical* diagnostic shape — PUC-Rio's own +"attempt to index a nil value (global 'os')" plus a native stack traceback — +because both are the real reference interpreter underneath, reached through +different Rust/C++ FFI. Go's line differs by design: it's `newSandbox`'s +guard-table metamethod firing, and it names something PUC-Rio's message +doesn't — *which field* (`execute`) the policy tried to reach. Same +rejection, same line number, three genuinely different diagnostics. + +`policy-broken.lua`'s missing `end` is a **load**-time failure — caught +before `on_exit` is ever called — and it shows the same split one syntax +error later: + +```console +[host]$ ./demo.sh cpp check ../policy-broken.lua +pmon: policy error: ../policy-broken.lua:16: 'end' expected (to close 'if' at line 13) near 'return' +[host]$ ./demo.sh go check ../policy-broken.lua +pmon: policy error: loading ../policy-broken.lua: ../policy-broken.lua line:16(column:8) near 'return': syntax error +[host]$ ./demo.sh rust check ../policy-broken.lua +pmon: policy error: syntax error: [string "../policy-broken.lua"]:16: 'end' expected (to close 'if' at line 13) near 'return' +``` + +Once again cpp and rust agree almost to the byte — PUC-Rio's own parser +message — and gopher-lua's parser reports the same line and near-token in +its own format. In every case the running supervisor is unaffected: `check` +loads the bad file in isolation, reports the rejection, and exits 1; a +`run`-time `SIGHUP` reload that hits a bad policy would report the same +error and simply keep serving whatever policy was already loaded — a +`policy-*.lua` typo is a reported problem, never a crash of `pmon` itself. + +## Concurrency lens + +Every host consumes the *same* `SIGHUP` through three different primitives — +the same divergence Chapter 41 mapped for `SIGTERM`, reached here through a +reload path with one new wrinkle: after the signal lands, **every** binding +discards the whole interpreter and builds a brand-new one. + +{% include codetabs.html langs="C++|Go|Rust" %} + +```cpp +// Block until SIGHUP arrives on `sigfd`, draining exactly one signalfd_siginfo. +void wait_for_sighup(int sigfd) { + pollfd pfd{.fd = sigfd, .events = POLLIN, .revents = 0}; + for (;;) { + const int n = ::poll(&pfd, 1, -1); + if (n < 0) { + if (errno == EINTR) { + continue; + } + die("poll: " + last_error().message()); + } + signalfd_siginfo info{}; + if (::read(sigfd, &info, sizeof info) == sizeof info && info.ssi_signo == SIGHUP) { + return; + } + } +} + +// Fresh sandbox + host table + protected load + on_load(), all rebuilt from +// scratch — the simplest correct way to guarantee reload leaves no state +// from the previous policy behind. +[[nodiscard]] std::expected reload(const std::string& policy_path) { + sol::state lua = make_sandbox(); + if (auto loaded = load_policy(lua, policy_path); !loaded) { + return std::unexpected(loaded.error()); + } + return lua; +} +``` + +```go + fmt.Println("pmon: awaiting SIGHUP to reload policy") + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGHUP) + <-sigCh + fmt.Println("pmon: reload requested") + + // Re-read the same --policy path (its content has since changed) into a + // brand-new sandboxed state; the old one is discarded, not mutated. + L.Close() + L = newSandbox() + if err := loadPolicy(L, policyPath); err != nil { + L.Close() + return err + } +``` + +```rust + let reload = Arc::new(AtomicBool::new(false)); + signal_hook::flag::register(signal_hook::consts::SIGHUP, reload.clone()) + .context("registering SIGHUP handler")?; + + println!("pmon: awaiting SIGHUP to reload policy"); + while !reload.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(20)); + } + println!("pmon: reload requested"); + + // Rebuild the whole Lua state from scratch on reload, rather than mutate + // the old one in place: a fresh sandbox guarantees nothing from the old + // policy (globals, closures) survives into the new one. + let lua = build_sandbox() + .map_err(|e| anyhow::anyhow!("{e}")) + .context("rebuilding sandbox for reload")?; + load_policy(&lua, policy_path) + .map_err(|e| anyhow::anyhow!("{e}")) + .context("reloading policy")?; +``` + +C++ reuses ch12/ch13's pattern outright: `SIGHUP` is blocked process-wide +with `sigprocmask` up front (in `make_sighup_fd`, not shown), then consumed +as ordinary readability on a `signalfd` inside a `poll` loop — no signal +handler runs at all. Go reaches for the idiomatic equivalent: `signal.Notify` +hands a buffered channel to the Go runtime's own signal-handling machinery — +delivery happens on a dedicated runtime thread, never in user code — and +`cmdRun` simply blocks on `<-sigCh`. Rust's `signal-hook` sits between those +two: it installs a real, async-signal-safe `sigaction` handler, but that +handler is only permitted to flip an `AtomicBool` — no allocation, no Lua +call, no I/O — and `cmd_run`'s own thread polls that flag every 20 ms. The +flag is registered **before** the "awaiting SIGHUP" line prints, closing a +race a naïve implementation would have: a `SIGHUP` landing the instant after +the sentinel appears is never missed. + +All three converge on the same rebuild discipline once the signal lands: +throw the whole interpreter away and construct a fresh one — the +correctness argument for reload, not a code-simplicity shortcut. A policy +can define arbitrary local state, upvalues, and closures; mutating a live +state in place would leave open the chance the *old* policy's state +survives into the *new* one's behavior. Building fresh guarantees the +opposite. One asymmetry worth naming even though this chapter never +exercises it: Go's `*lua.LState` is **not** goroutine-safe — a real `pmon` +wanting concurrent `on_exit` calls against a `gopher-lua` policy would need +its own serialization, where sol2's and mlua's states carry the identical +single-threaded constraint under a different name. + +## Build, run, observe + +```bash +[host]$ cd examples/42-embedded-lua-scripting && ./demo.sh build +``` + +`./demo.sh run` copies `policy.lua` into a scratch file, starts `pmon +run --policy `, waits for the `awaiting SIGHUP` sentinel, copies +`policy-v2.lua` over the same path, and sends a real `kill -HUP` — no fixed +sleep, no guesswork. The output is identical, byte for byte, across all +three languages (diff-confirmed this session): + +```console +[host]$ ./demo.sh cpp run +pmon: policy: policy loaded: version=1.0.0 base_backoff_ms=500 max_backoff_ms=30000 max_restarts=8 +pmon: decision child=web action=stop delay_ms=0 reason="web exited 0: clean shutdown, not restarting" +pmon: decision child=worker action=restart delay_ms=500 reason="worker: restart 1 after exit code 1 (uptime 9000ms), backing off 500ms" +pmon: decision child=worker action=restart delay_ms=1000 reason="worker: restart 2 after exit code 1 (uptime 9000ms), backing off 1000ms" +pmon: awaiting SIGHUP to reload policy +pmon: reload requested +pmon: policy: policy loaded: version=1.1.0 base_backoff_ms=100 max_backoff_ms=30000 max_restarts=2 +pmon: decision child=api action=restart delay_ms=100 reason="api: restart 1 after exit code 1 (uptime 5000ms), backing off 100ms" +pmon: decision child=worker action=escalate delay_ms=0 reason="worker: giving up after 2 restarts (last exit code 1)" +``` + +Read as a story: v1 loads (500 ms base, give up after 8). `web` exits 0 — +`stop`. `worker` crashes twice — `restart` at 500 ms, then the doubled +1000 ms backoff. `pmon` parks on `SIGHUP`; `demo.sh` swaps in +`policy-v2.lua` and signals the same, never-restarted PID. Reload picks up +v1.1.0 (100 ms base, give up after only 2). `api`'s fresh crash backs off +100 ms, v2's tighter base; `worker`, already carrying 2 lifetime restarts, +immediately hits v2's `max_restarts=2` and `escalate`s — the same event +that would have been a normal `restart` under v1's `max_restarts=8`. The +policy changed behavior with zero rebuild and zero process restart. + +`./demo.sh check` is the load-and-probe path from "Errors, three +ways" above, and it rejects a hostile policy the same way in every language: + +```console +[host]$ ./demo.sh rust check ../policy-evil.lua +pmon: policy error: runtime error: [string "../policy-evil.lua"]:21: attempt to index a nil value (global 'os') +``` + +(exit 1, full traceback above). And the whole matrix, through the shared harness: + +```console +[host]$ python3 scripts/test-all-examples.py --only 42-embedded-lua-scripting +verifying... + verify 42-embedded-lua-scripting [cpp]: PASS + verify 42-embedded-lua-scripting [go]: PASS + verify 42-embedded-lua-scripting [rust]: PASS + +example cpp go rust +42-embedded-lua-scripting PASS PASS PASS +3 passed, 0 failed, 0 skipped +``` + +Each language's `verify.lua` reported `PASS 19 / FAIL 0`. + +{% include excalidraw.html + file="42-sighup-reload-lifecycle" + alt="A horizontal sequence of six boxes inside a band labeled one pmon process -- PID never changes: load v1 (base=500ms max_restarts=8), phase-1 decisions (web: stop; worker: restart 500ms to 1000ms), awaiting SIGHUP (a dashed ghost box), reload (fresh Lua state rebuilt, on_load logs v1.1.0), phase-2 decisions (api: restart 100ms; worker: escalate after 2 restarts), and exit 0. Above the awaiting-SIGHUP box, a ghost box shows the operator action -- edit the policy file, then kill -HUP $(pgrep app) -- with an arrow into the flow. Below it, three sub-boxes show the three per-language consumption mechanisms: C++ signalfd + poll, Go signal.Notify(ch), Rust signal_hook flag (AtomicBool + 20ms poll). A bottom note reads: the process is NOT restarted -- only a fresh, sandboxed Lua state is built in place and the decision logic changes underneath it." + caption="Figure 42.2 — the SIGHUP reload lifecycle: v1's decisions, a real SIGHUP consumed three different ways, a fresh Lua state built in place, and v1.1.0's tighter decisions — same process throughout" %} + +## Cross-check: one script, three interpreters, byte-identical decisions + +The claim this chapter makes is falsifiable and was actually falsified +against: `policy.lua`, unmodified, drives sol2/PUC-Lua 5.4, gopher-lua's +pure-Go 5.1 VM, and mlua/PUC-Lua 5.4 to the *exact same sequence of restart +decisions*, including the exact backoff numbers and the exact `reason` +strings Lua's `string.format` produced. That is not "the same shape of +output" — it is a byte-for-byte `diff` of three processes built from three +different compilers, three different runtimes, and (for Go) a genuinely +different Lua language version, running the identical two-phase scenario +with the identical `SIGHUP` reload in between. This is possible because +every value crossing the boundary is host-primitive data, never a Lua value +the host interprets loosely; it is *correct* because of the shared-subset +discipline above, which guarantees `policy.lua` compiles to the same +observable program on Lua 5.1 and 5.4 alike. `test-all-examples.py --only +42-embedded-lua-scripting` passing 3/0 and each `verify.lua` reporting +`PASS 19 / FAIL 0` are the gate; the diff-confirmed identical `./demo.sh + run` transcript above is the proof the gate is checking behavior, +not three green exit codes agreeing by coincidence. + +## What you learned + +- **The host↔script boundary is four callbacks, all plain data**: + `on_exit(info) -> decision` plus `host.log`/`host.now_ms`, crossed as + tables of numbers and strings, never a closure — what makes a + diff-identical run across three unrelated Lua bindings possible. +- **A shared-subset policy file survives a language-version gap, not just a + binding-API gap.** `policy.lua` runs identically on PUC-Rio Lua 5.4 (sol2, + mlua) and gopher-lua's pure-Go Lua 5.1 because it never uses `//`, `goto`, + ``, bitwise operators, or 5.2+-only stdlib. +- **A protected call turns an interpreter bug into a return value.** + `sol::protected_function`, `L.CallByParam{Protect:true}`, and + `mlua::Result` all guarantee the same thing: a bad `on_exit` degrades to + `pmon: policy error: ...` and exit 1, never a crash of the supervisor. +- **The sandbox-rejection diagnostic differs by binding, and that is + informative, not a bug.** sol2 and mlua share PUC-Rio's native `attempt + to index a nil value (global 'os')`; gopher-lua's guard table reports + `sandbox: global 'os' is not available (blocked field 'execute')` — + strictly more specific, since gopher-lua's own nil-index error never + names the global at all. +- **`SIGHUP` reload rebuilds the interpreter, never restarts the process.** + `pmon`'s PID and child-tracking state survive untouched; only the Lua + state is thrown away and rebuilt from `policy-v2.lua`, so no closure or + global from the old policy leaks into the new one. +- **Three signals, one outcome, three primitives.** C++ reuses ch12's + `sigprocmask` + `signalfd` + `poll`; Go hands the signal to the runtime's + own machinery via `signal.Notify` and blocks on a channel; Rust's + `signal-hook` flips an `AtomicBool` from a minimal `sigaction` handler a + separate thread polls every 20 ms, armed before the "awaiting" line prints + to close the missed-signal race. + +Two deep dives remain in this part — Rust macros for systems code, and the +Go runtime from a systems programmer's seat — the same instinct turned on a +single language's own machinery. + +--- + +

verified — on the Fedora 44 +reference host this session: python3 scripts/test-all-examples.py +--only 42-embedded-lua-scripting printed 3 passed, 0 +failed (build cpp/go/rust: ok; verify cpp/go/rust: PASS), with each +language's own verify.lua reporting PASS 19 / FAIL 0. +A full local regression (--mode local) gave 96 passed, 0 +failed, 3 skipped (the 3 skips are the unrelated 38-otel +example, LGTM unreachable) — no regressions from this chapter's example. +./demo.sh <lang> run produced byte-identical output across +cpp/go/rust (diff-confirmed): v1.0.0 (base 500ms, max_restarts=8) driving a +`stop` for a clean exit and a `restart` backoff that doubled 500ms → 1000ms, +then a real kill -HUP reload to v1.1.0 (base 100ms, +max_restarts=2) driving a 100ms `restart` and an `escalate` once the +worker's lifetime restart count (2, carried over from phase one) hit v2's +lower ceiling — the same process throughout, PID unchanged. The sandbox +rejection diagnostics for policy-evil.lua (os.execute) and +policy-broken.lua (missing `end`) were captured verbatim per +language via ./demo.sh <lang> check ../policy-evil.lua +and the `-broken` equivalent, confirming sol2/mlua share PUC-Lua 5.4's +native "attempt to index a nil value (global 'os')" text while gopher-lua's +guard table reports "sandbox: global 'os' is not available (blocked field +'execute')". Library pins: sol2/3.5.0 + +lua/5.4.6 via Conan 2 (C++), gopher-lua v1.1.2 +(Go, Lua 5.1 semantics), mlua 0.12 with lua54 + +vendored features plus signal-hook 0.3 and +anyhow 1 (Rust); the host's own lua is 5.4.8 +(PUC-Rio), driving verify.lua itself. Not exercised: this +example is mode: local per examples/manifest.yaml — +there is no VM or LGTM path for it, so no lab-guest or telemetry run +applies here.

diff --git a/assets/diagrams/42-host-script-boundary.excalidraw b/assets/diagrams/42-host-script-boundary.excalidraw new file mode 100644 index 0000000..5f02320 --- /dev/null +++ b/assets/diagrams/42-host-script-boundary.excalidraw @@ -0,0 +1,1563 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "r1740850744", + "type": "rectangle", + "x": 20, + "y": 20, + "width": 300, + "height": 560, + "angle": 0, + "strokeColor": "#dddddd", + "backgroundColor": "#eef4fb", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 108396324, + "versionNonce": 450719892, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1333483413", + "type": "text", + "x": 32, + "y": 26, + "width": 125.4, + "height": 15.0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 469410602, + "versionNonce": 1002732801, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "three host runtimes", + "fontSize": 12, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "three host runtimes", + "lineHeight": 1.25, + "baseline": 12 + }, + { + "id": "r1163936712", + "type": "rectangle", + "x": 360, + "y": 120, + "width": 340, + "height": 360, + "angle": 0, + "strokeColor": "#dddddd", + "backgroundColor": "#f4f4f4", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 542511116, + "versionNonce": 1680724183, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1321305947", + "type": "text", + "x": 372, + "y": 126, + "width": 349.8, + "height": 15.0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1632879026, + "versionNonce": 1443376061, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "sandbox boundary (enforced by the host, per language)", + "fontSize": 12, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "sandbox boundary (enforced by the host, per language)", + "lineHeight": 1.25, + "baseline": 12 + }, + { + "id": "r1513961925", + "type": "rectangle", + "x": 40, + "y": 60, + "width": 260, + "height": 100, + "angle": 0, + "strokeColor": "#2f6db5", + "backgroundColor": "#eef4fb", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 18882911, + "versionNonce": 73117062, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t694016956", + "type": "text", + "x": 50, + "y": 68, + "width": 138.60000000000002, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 92866082, + "versionNonce": 720475469, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "C++23 (sol2 3.5.0)", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "C++23 (sol2 3.5.0)", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1504084151", + "type": "text", + "x": 50, + "y": 88, + "width": 229.9, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1938885678, + "versionNonce": 1672836763, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "open_libraries(base/string/table/math)", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "open_libraries(base/string/table/math)", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t672909634", + "type": "text", + "x": 50, + "y": 103, + "width": 187.55, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 760487178, + "versionNonce": 1586141121, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "sol::protected_function on_exit", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "sol::protected_function on_exit", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t1107791621", + "type": "text", + "x": 50, + "y": 118, + "width": 114.95, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 81426612, + "versionNonce": 1306680958, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Lua 5.4.6 (PUC-Rio)", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Lua 5.4.6 (PUC-Rio)", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1774821736", + "type": "rectangle", + "x": 40, + "y": 250, + "width": 260, + "height": 100, + "angle": 0, + "strokeColor": "#2f6db5", + "backgroundColor": "#eef4fb", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1865208611, + "versionNonce": 165783375, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t462765972", + "type": "text", + "x": 50, + "y": 258, + "width": 169.4, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1606740841, + "versionNonce": 1529528653, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Go (gopher-lua v1.1.2)", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Go (gopher-lua v1.1.2)", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t903171878", + "type": "text", + "x": 50, + "y": 278, + "width": 157.3, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1848531334, + "versionNonce": 1251010101, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "OpenBase/String/Table/Math", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "OpenBase/String/Table/Math", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t580252559", + "type": "text", + "x": 50, + "y": 293, + "width": 163.35000000000002, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1195397625, + "versionNonce": 1183855154, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "L.CallByParam{Protect:true}", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "L.CallByParam{Protect:true}", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t1355397431", + "type": "text", + "x": 50, + "y": 308, + "width": 102.85000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 393396987, + "versionNonce": 631747217, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Lua 5.1 semantics", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Lua 5.1 semantics", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r248053213", + "type": "rectangle", + "x": 40, + "y": 440, + "width": 260, + "height": 100, + "angle": 0, + "strokeColor": "#2f6db5", + "backgroundColor": "#eef4fb", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 716214274, + "versionNonce": 140968608, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t823917546", + "type": "text", + "x": 50, + "y": 448, + "width": 246.40000000000003, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1587374735, + "versionNonce": 899909028, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Rust (mlua 0.12, lua54+vendored)", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Rust (mlua 0.12, lua54+vendored)", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1760430924", + "type": "text", + "x": 50, + "y": 468, + "width": 151.25, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1184019803, + "versionNonce": 59460425, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "StdLib::STRING|TABLE|MATH", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "StdLib::STRING|TABLE|MATH", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t1984609791", + "type": "text", + "x": 50, + "y": 483, + "width": 163.35000000000002, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 432132522, + "versionNonce": 138666765, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "on_exit.call::
(info)", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "on_exit.call::
(info)", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t1631768471", + "type": "text", + "x": 50, + "y": 498, + "width": 163.35000000000002, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1744896343, + "versionNonce": 201961490, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Lua 5.4 (PUC-Rio, vendored)", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Lua 5.4 (PUC-Rio, vendored)", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r873125972", + "type": "rectangle", + "x": 400, + "y": 220, + "width": 260, + "height": 140, + "angle": 0, + "strokeColor": "#e8870c", + "backgroundColor": "#fff8ef", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1717028392, + "versionNonce": 111385296, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t757536882", + "type": "text", + "x": 410, + "y": 228, + "width": 161.70000000000002, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 363481440, + "versionNonce": 1653805721, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "policy.lua (one file)", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "policy.lua (one file)", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t90896386", + "type": "text", + "x": 410, + "y": 248, + "width": 151.25, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 725947870, + "versionNonce": 243978229, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "on_exit(info) -> decision", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "on_exit(info) -> decision", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t675767459", + "type": "text", + "x": 410, + "y": 263, + "width": 169.4, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1440909599, + "versionNonce": 1218315294, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "on_load() \u00b7 POLICY_VERSION", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "on_load() \u00b7 POLICY_VERSION", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t1054621489", + "type": "text", + "x": 410, + "y": 278, + "width": 151.25, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1211099014, + "versionNonce": 1141522032, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "shared Lua 5.1/5.4 subset", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "shared Lua 5.1/5.4 subset", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r920066896", + "type": "rectangle", + "x": 400, + "y": 400, + "width": 260, + "height": 60, + "angle": 0, + "strokeColor": "#999999", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 606637261, + "versionNonce": 1527518919, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t363048538", + "type": "text", + "x": 410, + "y": 408, + "width": 238.70000000000002, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 168581693, + "versionNonce": 734080101, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "blocked: os, io, package, debug", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "blocked: os, io, package, debug", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t785052081", + "type": "text", + "x": 410, + "y": 428, + "width": 375.1, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1088225916, + "versionNonce": 1125825631, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "nilled: load/loadfile/dofile/require/collectgarbage/loadstring", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "nilled: load/loadfile/dofile/require/collectgarbage/loadstring", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1735275778", + "type": "rectangle", + "x": 760, + "y": 250, + "width": 220, + "height": 100, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1521397545, + "versionNonce": 1020063052, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1378638211", + "type": "text", + "x": 770, + "y": 258, + "width": 161.70000000000002, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 47879301, + "versionNonce": 806081513, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "host table (bound in)", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "host table (bound in)", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1014398029", + "type": "text", + "x": 770, + "y": 278, + "width": 139.15, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1540145600, + "versionNonce": 398955910, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "host.log(msg) -> stdout", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "host.log(msg) -> stdout", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t48962791", + "type": "text", + "x": 770, + "y": 293, + "width": 266.20000000000005, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1078298821, + "versionNonce": 850956781, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "host.now_ms() -> ms since epoch/steady clock", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "host.now_ms() -> ms since epoch/steady clock", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a1392699967", + "type": "arrow", + "x": 300, + "y": 110, + "width": 100, + "height": 150, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 567706287, + "versionNonce": 1686765392, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 100, + 150 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t1662542154", + "type": "text", + "x": 350.0, + "y": 171.0, + "width": 302.5, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1728028585, + "versionNonce": 704684841, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "on_exit(info) / decision{action,delay_ms,reason}", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "on_exit(info) / decision{action,delay_ms,reason}", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a118673305", + "type": "arrow", + "x": 300, + "y": 300, + "width": 100, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1718611285, + "versionNonce": 1740455670, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 100, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t468000651", + "type": "text", + "x": 350.0, + "y": 286.0, + "width": 187.55, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1852157224, + "versionNonce": 114015156, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "on_exit(info) / decision{...}", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "on_exit(info) / decision{...}", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a1324932859", + "type": "arrow", + "x": 300, + "y": 490, + "width": 100, + "height": 150, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1737877743, + "versionNonce": 1121796830, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 100, + -150 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t1160188527", + "type": "text", + "x": 350.0, + "y": 401.0, + "width": 187.55, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 307842599, + "versionNonce": 955036881, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "on_exit(info) / decision{...}", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "on_exit(info) / decision{...}", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a1999546686", + "type": "arrow", + "x": 660, + "y": 290, + "width": 100, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1611481418, + "versionNonce": 1425384867, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 100, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t1184670961", + "type": "text", + "x": 710.0, + "y": 276.0, + "width": 133.10000000000002, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1045300942, + "versionNonce": 1112293036, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "host.log / host.now_ms", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "host.log / host.now_ms", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t1922409382", + "type": "text", + "x": 30, + "y": 35, + "width": 211.20000000000002, + "height": 15.0, + "angle": 0, + "strokeColor": "#2f6db5", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 81611197, + "versionNonce": 994017192, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "pmon (three ports, one behavior)", + "fontSize": 12, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "pmon (three ports, one behavior)", + "lineHeight": 1.25, + "baseline": 12 + }, + { + "id": "t1250037876", + "type": "text", + "x": 530, + "y": 490, + "width": 347.875, + "height": 14.375, + "angle": 0, + "strokeColor": "#1d1d1d", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1018939626, + "versionNonce": 13129460, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Lua 5.1 (gopher-lua) vs Lua 5.4 (sol2, mlua) forces the", + "fontSize": 11.5, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Lua 5.1 (gopher-lua) vs Lua 5.4 (sol2, mlua) forces the", + "lineHeight": 1.25, + "baseline": 11.5 + }, + { + "id": "t60700358", + "type": "text", + "x": 530, + "y": 506, + "width": 366.85, + "height": 14.375, + "angle": 0, + "strokeColor": "#1d1d1d", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 71281710, + "versionNonce": 1756292752, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "shared subset: no //, no goto, no , no bitwise ops,", + "fontSize": 11.5, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "shared subset: no //, no goto, no , no bitwise ops,", + "lineHeight": 1.25, + "baseline": 11.5 + }, + { + "id": "t1800327211", + "type": "text", + "x": 530, + "y": 522, + "width": 347.875, + "height": 14.375, + "angle": 0, + "strokeColor": "#1d1d1d", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1219109641, + "versionNonce": 1417163423, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "no 5.2+-only stdlib -- one policy.lua drives all three.", + "fontSize": 11.5, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "no 5.2+-only stdlib -- one policy.lua drives all three.", + "lineHeight": 1.25, + "baseline": 11.5 + }, + { + "id": "t250625875", + "type": "text", + "x": 870, + "y": 370, + "width": 175.45000000000002, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1523515202, + "versionNonce": 1862445077, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "every (re)load builds a fresh", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "every (re)load builds a fresh", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t933964667", + "type": "text", + "x": 870, + "y": 386, + "width": 169.4, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 37101351, + "versionNonce": 489288100, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "sandboxed state from scratch", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "sandboxed state from scratch", + "lineHeight": 1.25, + "baseline": 11 + } + ], + "appState": { + "viewBackgroundColor": "#ffffff", + "gridSize": null + }, + "files": {} +} \ No newline at end of file diff --git a/assets/diagrams/42-host-script-boundary.svg b/assets/diagrams/42-host-script-boundary.svg new file mode 100644 index 0000000..a78fc8d --- /dev/null +++ b/assets/diagrams/42-host-script-boundary.svg @@ -0,0 +1,52 @@ + + + +three host runtimes + +sandbox boundary (enforced by the host, per language) + +C++23 (sol2 3.5.0) +open_libraries(base/string/table/math) +sol::protected_function on_exit +Lua 5.4.6 (PUC-Rio) + +Go (gopher-lua v1.1.2) +OpenBase/String/Table/Math +L.CallByParam{Protect:true} +Lua 5.1 semantics + +Rust (mlua 0.12, lua54+vendored) +StdLib::STRING|TABLE|MATH +on_exit.call::<Table>(info) +Lua 5.4 (PUC-Rio, vendored) + +policy.lua (one file) +on_exit(info) -> decision +on_load() · POLICY_VERSION +shared Lua 5.1/5.4 subset + +blocked: os, io, package, debug +nilled: load/loadfile/dofile/require/collectgarbage/loadstring + +host table (bound in) +host.log(msg) -> stdout +host.now_ms() -> ms since epoch/steady clock + +on_exit(info) / decision{action,delay_ms,reason} +on_exit(info) / decision{action,delay_ms,reason} + +on_exit(info) / decision{...} +on_exit(info) / decision{...} + +on_exit(info) / decision{...} +on_exit(info) / decision{...} + +host.log / host.now_ms +host.log / host.now_ms +pmon (three ports, one behavior) +Lua 5.1 (gopher-lua) vs Lua 5.4 (sol2, mlua) forces the +shared subset: no //, no goto, no <close>, no bitwise ops, +no 5.2+-only stdlib -- one policy.lua drives all three. +every (re)load builds a fresh +sandboxed state from scratch + \ No newline at end of file diff --git a/assets/diagrams/42-sighup-reload-lifecycle.excalidraw b/assets/diagrams/42-sighup-reload-lifecycle.excalidraw new file mode 100644 index 0000000..23df3af --- /dev/null +++ b/assets/diagrams/42-sighup-reload-lifecycle.excalidraw @@ -0,0 +1,1671 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "r598587517", + "type": "rectangle", + "x": 20, + "y": 20, + "width": 1160, + "height": 460, + "angle": 0, + "strokeColor": "#dddddd", + "backgroundColor": "#fafafa", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1319229358, + "versionNonce": 348107124, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t572321003", + "type": "text", + "x": 32, + "y": 26, + "width": 244.20000000000002, + "height": 15.0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1962693010, + "versionNonce": 1315942464, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "one pmon process -- PID never changes", + "fontSize": 12, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "one pmon process -- PID never changes", + "lineHeight": 1.25, + "baseline": 12 + }, + { + "id": "r1070170126", + "type": "rectangle", + "x": 40, + "y": 90, + "width": 160, + "height": 90, + "angle": 0, + "strokeColor": "#2f6db5", + "backgroundColor": "#eef4fb", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1391401569, + "versionNonce": 1027477081, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1428346259", + "type": "text", + "x": 50, + "y": 98, + "width": 53.900000000000006, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 792901726, + "versionNonce": 1596084996, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "load v1", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "load v1", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1895774598", + "type": "text", + "x": 50, + "y": 118, + "width": 102.85000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 994336792, + "versionNonce": 1792856510, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "policy.lua v1.0.0", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "policy.lua v1.0.0", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t1649504748", + "type": "text", + "x": 50, + "y": 133, + "width": 151.25, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1762764189, + "versionNonce": 242138684, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "base=500ms max_restarts=8", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "base=500ms max_restarts=8", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r370460038", + "type": "rectangle", + "x": 230, + "y": 90, + "width": 160, + "height": 90, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 332792479, + "versionNonce": 1920008696, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t313781167", + "type": "text", + "x": 240, + "y": 98, + "width": 130.9, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1507725297, + "versionNonce": 675800809, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "phase-1 decisions", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "phase-1 decisions", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t550321504", + "type": "text", + "x": 240, + "y": 118, + "width": 54.45, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 330096139, + "versionNonce": 1975269074, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "web: stop", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "web: stop", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t1106590699", + "type": "text", + "x": 240, + "y": 133, + "width": 187.55, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1560710229, + "versionNonce": 1467068862, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "worker: restart 500ms -> 1000ms", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "worker: restart 500ms -> 1000ms", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1769385983", + "type": "rectangle", + "x": 420, + "y": 90, + "width": 160, + "height": 90, + "angle": 0, + "strokeColor": "#999999", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 696538579, + "versionNonce": 1214151680, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1874131873", + "type": "text", + "x": 430, + "y": 98, + "width": 115.50000000000001, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1823455944, + "versionNonce": 1810402443, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "awaiting SIGHUP", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "awaiting SIGHUP", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1205992328", + "type": "text", + "x": 430, + "y": 118, + "width": 127.05000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1455011168, + "versionNonce": 109077367, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "pmon: awaiting SIGHUP", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "pmon: awaiting SIGHUP", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t77542586", + "type": "text", + "x": 430, + "y": 133, + "width": 96.80000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 434174033, + "versionNonce": 58000326, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "to reload policy", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "to reload policy", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1062641210", + "type": "rectangle", + "x": 610, + "y": 90, + "width": 160, + "height": 90, + "angle": 0, + "strokeColor": "#e8870c", + "backgroundColor": "#fff8ef", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 209318401, + "versionNonce": 700433576, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1729196881", + "type": "text", + "x": 620, + "y": 98, + "width": 46.2, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1429247247, + "versionNonce": 900158764, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "reload", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "reload", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1422418827", + "type": "text", + "x": 620, + "y": 118, + "width": 139.15, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1352214618, + "versionNonce": 1285358525, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "fresh Lua state rebuilt", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "fresh Lua state rebuilt", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t1478854087", + "type": "text", + "x": 620, + "y": 133, + "width": 145.20000000000002, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1988683105, + "versionNonce": 432908074, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "on_load(): v1.1.0 logged", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "on_load(): v1.1.0 logged", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1495507702", + "type": "rectangle", + "x": 800, + "y": 90, + "width": 160, + "height": 90, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1021397442, + "versionNonce": 1404786219, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t519908497", + "type": "text", + "x": 810, + "y": 98, + "width": 130.9, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1004989401, + "versionNonce": 203145407, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "phase-2 decisions", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "phase-2 decisions", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1575905606", + "type": "text", + "x": 810, + "y": 118, + "width": 108.9, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 883780411, + "versionNonce": 1054150159, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "api: restart 100ms", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "api: restart 100ms", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t1519046449", + "type": "text", + "x": 810, + "y": 133, + "width": 175.45000000000002, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 634008428, + "versionNonce": 1385682821, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "worker: escalate (2 restarts)", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "worker: escalate (2 restarts)", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1652920580", + "type": "rectangle", + "x": 990, + "y": 90, + "width": 160, + "height": 90, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "#111111", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 676678775, + "versionNonce": 555923839, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1101289284", + "type": "text", + "x": 1000, + "y": 98, + "width": 46.2, + "height": 17.5, + "angle": 0, + "strokeColor": "#ffffff", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 162226187, + "versionNonce": 10039944, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "exit 0", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "exit 0", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1276594299", + "type": "text", + "x": 1000, + "y": 118, + "width": 114.95, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1323942500, + "versionNonce": 1891759541, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "same PID throughout", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "same PID throughout", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1983383134", + "type": "rectangle", + "x": 430, + "y": 20, + "width": 260, + "height": 50, + "angle": 0, + "strokeColor": "#999999", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1928132331, + "versionNonce": 1927871256, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t924493726", + "type": "text", + "x": 440, + "y": 28, + "width": 269.5, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1204033509, + "versionNonce": 1083203660, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "[host]$ operator edits policy path,", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "[host]$ operator edits policy path,", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t890312037", + "type": "text", + "x": 440, + "y": 48, + "width": 169.4, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 312414823, + "versionNonce": 1118775396, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "then: kill -HUP $(pgrep app)", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "then: kill -HUP $(pgrep app)", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1690567571", + "type": "rectangle", + "x": 250, + "y": 260, + "width": 170, + "height": 60, + "angle": 0, + "strokeColor": "#999999", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1375903780, + "versionNonce": 1246927558, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t1125656322", + "type": "text", + "x": 260, + "y": 268, + "width": 154.0, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 972983518, + "versionNonce": 1355152490, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "C++: signalfd + poll", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "C++: signalfd + poll", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t1772654107", + "type": "text", + "x": 260, + "y": 288, + "width": 169.4, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 302563043, + "versionNonce": 1025017616, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "(sigprocmask-blocked SIGHUP)", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "(sigprocmask-blocked SIGHUP)", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1458317513", + "type": "rectangle", + "x": 430, + "y": 260, + "width": 170, + "height": 60, + "angle": 0, + "strokeColor": "#999999", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1295737776, + "versionNonce": 550665792, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t809720842", + "type": "text", + "x": 440, + "y": 268, + "width": 161.70000000000002, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1907871392, + "versionNonce": 1917795198, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Go: signal.Notify(ch)", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Go: signal.Notify(ch)", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t445650541", + "type": "text", + "x": 440, + "y": 288, + "width": 157.3, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1758871861, + "versionNonce": 1652870899, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "<-ch on runtime sig thread", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "<-ch on runtime sig thread", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "r1370460602", + "type": "rectangle", + "x": 610, + "y": 260, + "width": 170, + "height": 60, + "angle": 0, + "strokeColor": "#999999", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1447674908, + "versionNonce": 1960562605, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "t743174184", + "type": "text", + "x": 620, + "y": 268, + "width": 169.4, + "height": 17.5, + "angle": 0, + "strokeColor": "#111111", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1137248705, + "versionNonce": 1656037550, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Rust: signal_hook flag", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Rust: signal_hook flag", + "lineHeight": 1.25, + "baseline": 14 + }, + { + "id": "t782088642", + "type": "text", + "x": 620, + "y": 288, + "width": 133.10000000000002, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1961495097, + "versionNonce": 420191056, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "AtomicBool + 20ms poll", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "AtomicBool + 20ms poll", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a1467161661", + "type": "arrow", + "x": 200, + "y": 135, + "width": 30, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1136600870, + "versionNonce": 1240870352, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 30, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a1492948359", + "type": "arrow", + "x": 390, + "y": 135, + "width": 30, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 284754651, + "versionNonce": 152544271, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 30, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a1278978506", + "type": "arrow", + "x": 580, + "y": 135, + "width": 30, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 400939571, + "versionNonce": 295896246, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 30, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t472591351", + "type": "text", + "x": 595.0, + "y": 121.0, + "width": 121.00000000000001, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 987724112, + "versionNonce": 793998821, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "real SIGHUP consumed", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "real SIGHUP consumed", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "a575717853", + "type": "arrow", + "x": 770, + "y": 135, + "width": 30, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 1142148376, + "versionNonce": 331006415, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 30, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a517097465", + "type": "arrow", + "x": 960, + "y": 135, + "width": 30, + "height": 0, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 680776108, + "versionNonce": 495230148, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 30, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a1200304309", + "type": "arrow", + "x": 560, + "y": 70, + "width": 60, + "height": 20, + "angle": 0, + "strokeColor": "#b8650a", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 859484844, + "versionNonce": 1953667321, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -60, + 20 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "a1969188264", + "type": "arrow", + "x": 500, + "y": 180, + "width": 0, + "height": 80, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "seed": 520373384, + "versionNonce": 95787331, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 80 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "t1471756478", + "type": "text", + "x": 500.0, + "y": 206.0, + "width": 139.15, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 185104956, + "versionNonce": 176599474, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "each waits for the same", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "each waits for the same", + "lineHeight": 1.25, + "baseline": 11 + }, + { + "id": "t635065834", + "type": "text", + "x": 500, + "y": 480, + "width": 501.6, + "height": 15.0, + "angle": 0, + "strokeColor": "#1d1d1d", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1800505415, + "versionNonce": 1222025378, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "The process is NOT restarted -- pmon keeps its PID and child-tracking state;", + "fontSize": 12, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "The process is NOT restarted -- pmon keeps its PID and child-tracking state;", + "lineHeight": 1.25, + "baseline": 12 + }, + { + "id": "t1855186617", + "type": "text", + "x": 500, + "y": 496, + "width": 613.5250000000001, + "height": 14.375, + "angle": 0, + "strokeColor": "#1d1d1d", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1543815826, + "versionNonce": 865010028, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "only a fresh, sandboxed Lua state is built in place and the decision logic changes underneath it.", + "fontSize": 11.5, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "only a fresh, sandboxed Lua state is built in place and the decision logic changes underneath it.", + "lineHeight": 1.25, + "baseline": 11.5 + } + ], + "appState": { + "viewBackgroundColor": "#ffffff", + "gridSize": null + }, + "files": {} +} \ No newline at end of file diff --git a/assets/diagrams/42-sighup-reload-lifecycle.svg b/assets/diagrams/42-sighup-reload-lifecycle.svg new file mode 100644 index 0000000..281e406 --- /dev/null +++ b/assets/diagrams/42-sighup-reload-lifecycle.svg @@ -0,0 +1,53 @@ + + + +one pmon process -- PID never changes + +load v1 +policy.lua v1.0.0 +base=500ms max_restarts=8 + +phase-1 decisions +web: stop +worker: restart 500ms -> 1000ms + +awaiting SIGHUP +pmon: awaiting SIGHUP +to reload policy + +reload +fresh Lua state rebuilt +on_load(): v1.1.0 logged + +phase-2 decisions +api: restart 100ms +worker: escalate (2 restarts) + +exit 0 +same PID throughout + +[host]$ operator edits policy path, +then: kill -HUP $(pgrep app) + +C++: signalfd + poll +(sigprocmask-blocked SIGHUP) + +Go: signal.Notify(ch) +<-ch on runtime sig thread + +Rust: signal_hook flag +AtomicBool + 20ms poll + + + +real SIGHUP consumed +real SIGHUP consumed + + + + +each waits for the same +each waits for the same +The process is NOT restarted -- pmon keeps its PID and child-tracking state; +only a fresh, sandboxed Lua state is built in place and the decision logic changes underneath it. + \ No newline at end of file diff --git a/assets/diagrams/README.md b/assets/diagrams/README.md index 4d4bb14..37e29b1 100644 --- a/assets/diagrams/README.md +++ b/assets/diagrams/README.md @@ -81,3 +81,5 @@ here as you add them: | `40-fastpath-datapath` | 40 | one echo round trip, naive versus fastpath: the naive server's per-message heap buffer and blocking `read(2)` that sleeps through the kernel scheduler (one voluntary context switch per message) against the fastpath server's single reused buffer on a `sched_setaffinity`-pinned core busy-polling `recv(2)` over `EAGAIN`, never entering the scheduler (≈ zero voluntary context switches) — the measured 20500-vs-3 contrast that the chapter reads from `/proc//status` | | `36-proc-datasource-map` | 36 | the six /proc + cgroup PSI sources sysagent reads (stat, loadavg, meminfo, diskstats, net/dev, PSI), counters marked apart from gauges, feeding `take_snapshot`'s t0-read / concurrent-reader / sleep / t1-read / delta sequence that turns two cumulative `/proc/stat` reads into one `cpu_util_pct` | | `36-agent-architecture` | 36 | sysagent's `sample`/`serve` subcommands sharing one procfs reader layer, against the row of cross-check tools run at the same moment (vmstat, mpstat, free -m, ss -s, a direct `/proc/meminfo` read, `kill -INT`), with forward references to Chapter 37 (USE method), 38 (OTLP export), and 45 (PCP pmrep) | +| `42-host-script-boundary` | 42 | the three host runtimes (C++/sol2, Go/gopher-lua, Rust/mlua) each calling into one shared `policy.lua` with `on_exit(info)`/`decision{action,delay_ms,reason}`, the `host.log`/`host.now_ms` callbacks bound in, the sandbox boundary blocking os/io/package/debug and nilling the loader globals, and the Lua 5.1-vs-5.4 gap that forces the shared subset | +| `42-sighup-reload-lifecycle` | 42 | the hot-reload sequence -- load v1, phase-1 decisions (500/1000ms), parking on SIGHUP, an operator editing the policy file and sending `kill -HUP`, reload with a fresh Lua state and `on_load()` logging v1.1.0, phase-2 decisions (100ms restart, escalate), exit -- with the three per-language SIGHUP-consumption mechanisms shown side by side and the process's PID never changing | diff --git a/examples/42-embedded-lua-scripting/README.md b/examples/42-embedded-lua-scripting/README.md new file mode 100644 index 0000000..64caa9e --- /dev/null +++ b/examples/42-embedded-lua-scripting/README.md @@ -0,0 +1,72 @@ +# 42 — embedded-lua-scripting + +`pmon` lifts its restart/backoff decision out of the host binary and into a +shared **`policy.lua`**. Each host — C++ (sol2), Go (gopher-lua), Rust (mlua) — +embeds a Lua interpreter, sandboxes it, and calls `on_exit(info)` after each +supervised child dies. Lua returns a `{ action, delay_ms, reason }` decision the +host obeys. Edit `policy.lua`, send **SIGHUP**, and the running supervisor +retunes with no rebuild and no restart of `pmon` itself. + +One script, three host runtimes, identical observable behaviour. + +## The host ↔ script boundary + +``` +host --info--> on_exit(info) -- {name, exit_code|signal, restarts, + -- consecutive_failures, uptime_ms, + -- last_backoff_ms} +on_exit --decision--> host -- {action="restart"|"stop"|"escalate", + -- delay_ms, reason} +policy --host.log(msg)--> host -- structured log from inside the policy +policy --host.now_ms()--> host -- monotonic-ish clock, for time-aware policies +``` + +The sandbox opens only `base`, `string`, `table`, `math`, and removes +`load`/`loadfile`/`dofile`/`require`; `os`, `io`, `package` and `debug` are +never loaded. A policy therefore cannot touch the filesystem, spawn processes, +or pull in new code — `policy-evil.lua` proves it. + +## Policy files + +| file | role | +|---|---| +| `policy.lua` | v1 — base backoff 500ms, give up after 8 restarts | +| `policy-v2.lua` | v2 — base backoff 100ms, give up after 2 restarts (the SIGHUP edit) | +| `policy-evil.lua` | calls `os.execute` — rejected by the sandbox at runtime | +| `policy-broken.lua` | a syntax error — rejected at load | + +`policy.lua` uses only the intersection of Lua 5.1 (gopher-lua) and Lua 5.4 +(sol2/mlua): no `//`, no bitwise operators, no ``/``, no +5.2+-only stdlib. That is what lets one file drive all three hosts. + +## Run it + +```bash +# full scenario + live SIGHUP reload, one language: +./demo.sh cpp run +./demo.sh go run +./demo.sh rust run + +# load-check a single policy under the sandbox: +./demo.sh rust check ../policy-evil.lua # -> pmon: policy error: ... 'os' +./demo.sh rust check ../policy.lua # -> pmon: policy ok version=1.0.0 + +# try the reload by hand: run the binary, edit the policy, signal it +cp policy.lua /tmp/p.lua +./cpp/build/release/app run --policy /tmp/p.lua & +cp policy-v2.lua /tmp/p.lua && kill -HUP %1 +``` + +## Verify + +`verify.lua` asserts the *same* decision lines across cpp/go/rust: the v1 load +banner, the 500ms→1000ms backoff doubling, the clean-exit `stop`, the SIGHUP +reload to v1.1.0, the post-reload 100ms backoff and early `escalate`, plus the +sandbox and syntax rejections from `check`. Behaviour, not exit codes. + +```bash +LSP_LANG=cpp lua verify.lua # (run from this directory) +``` + +Mode: `local`. Libraries: `sol2/3.5.0`+`lua/5.4.6` (Conan), `gopher-lua v1.1.2`, +`mlua 0.12` (`lua54`+`vendored`). diff --git a/examples/42-embedded-lua-scripting/cpp/.clang-format b/examples/42-embedded-lua-scripting/cpp/.clang-format new file mode 100644 index 0000000..2c8640f --- /dev/null +++ b/examples/42-embedded-lua-scripting/cpp/.clang-format @@ -0,0 +1,8 @@ +--- +BasedOnStyle: LLVM +Language: Cpp +Standard: c++20 +IndentWidth: 4 +ColumnLimit: 100 +PointerAlignment: Left +AllowShortFunctionsOnASingleLine: Inline diff --git a/examples/42-embedded-lua-scripting/cpp/.clang-tidy b/examples/42-embedded-lua-scripting/cpp/.clang-tidy new file mode 100644 index 0000000..2f275ba --- /dev/null +++ b/examples/42-embedded-lua-scripting/cpp/.clang-tidy @@ -0,0 +1,11 @@ +--- +Checks: > + bugprone-*, + modernize-*, + performance-*, + readability-*, + -modernize-use-trailing-return-type, + -readability-identifier-length +WarningsAsErrors: '' +HeaderFilterRegex: '.*' +FormatStyle: file diff --git a/examples/42-embedded-lua-scripting/cpp/CMakeLists.txt b/examples/42-embedded-lua-scripting/cpp/CMakeLists.txt new file mode 100644 index 0000000..f744633 --- /dev/null +++ b/examples/42-embedded-lua-scripting/cpp/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.25) +project(embedded_lua CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Both sol2 and lua come from Conan (see conanfile.txt) via CMakeDeps, which +# exports namespaced CONFIG-mode targets (sol2::sol2, lua::lua) rather than +# the CMake-bundled FindLua.cmake module's LUA_LIBRARIES/LUA_INCLUDE_DIR +# variables -- find_package(lua CONFIG REQUIRED), not find_package(Lua). +find_package(sol2 CONFIG REQUIRED) +find_package(lua CONFIG REQUIRED) +find_package(Threads REQUIRED) + +add_executable(app src/main.cpp) +target_link_libraries(app PRIVATE sol2::sol2 lua::lua Threads::Threads) diff --git a/examples/42-embedded-lua-scripting/cpp/CMakePresets.json b/examples/42-embedded-lua-scripting/cpp/CMakePresets.json new file mode 100644 index 0000000..bb52022 --- /dev/null +++ b/examples/42-embedded-lua-scripting/cpp/CMakePresets.json @@ -0,0 +1,63 @@ +{ + "version": 6, + "cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 }, + "configurePresets": [ + { + "name": "_base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + }, + { + "name": "release", + "inherits": "_base", + "displayName": "Release (-O2 with debug info)", + "toolchainFile": "${sourceDir}/build/release/conan_toolchain.cmake", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } + }, + { + "name": "debug", + "inherits": "_base", + "displayName": "Debug", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } + }, + { + "name": "asan", + "inherits": "_base", + "displayName": "ASan + UBSan instrumented build", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_FLAGS": "-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined", + "CMAKE_EXE_LINKER_FLAGS": "-fsanitize=address,undefined" + } + }, + { + "name": "release-clang", + "inherits": "release", + "displayName": "Release (clang)", + "cacheVariables": { + "CMAKE_C_COMPILER": "clang", + "CMAKE_CXX_COMPILER": "clang++" + } + }, + { + "name": "debug-clang", + "inherits": "debug", + "displayName": "Debug (clang)", + "cacheVariables": { + "CMAKE_C_COMPILER": "clang", + "CMAKE_CXX_COMPILER": "clang++" + } + } + ], + "buildPresets": [ + { "name": "release", "configurePreset": "release" }, + { "name": "debug", "configurePreset": "debug" }, + { "name": "asan", "configurePreset": "asan" }, + { "name": "release-clang", "configurePreset": "release-clang" }, + { "name": "debug-clang", "configurePreset": "debug-clang" } + ] +} diff --git a/examples/42-embedded-lua-scripting/cpp/conanfile.txt b/examples/42-embedded-lua-scripting/cpp/conanfile.txt new file mode 100644 index 0000000..6e87f45 --- /dev/null +++ b/examples/42-embedded-lua-scripting/cpp/conanfile.txt @@ -0,0 +1,11 @@ +[requires] +sol2/3.5.0 +lua/5.4.6 + +[options] +lua/*:with_tools=False +lua/*:with_readline=False + +[generators] +CMakeDeps +CMakeToolchain diff --git a/examples/42-embedded-lua-scripting/cpp/demo.sh b/examples/42-embedded-lua-scripting/cpp/demo.sh new file mode 100755 index 0000000..fdc5aa4 --- /dev/null +++ b/examples/42-embedded-lua-scripting/cpp/demo.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +BIN=build/release/app + +build() { + conan install . --output-folder=build/release -s build_type=Release --build=missing + cmake --preset release + cmake --build --preset release +} + +# run: replay the two-phase exit scenario, hot-reload the policy on a real +# SIGHUP between phases, and stream the supervisor's output. The reload is +# race-free: we wait for the "awaiting SIGHUP" sentinel before editing the +# watched policy file and signalling, so there is no fixed sleep to tune. +run() { + if [[ ! -x "$BIN" ]]; then build; fi + local work; work="$(mktemp -d)" + trap 'rm -rf "$work"' RETURN + cp ../policy.lua "$work/policy.lua" + "./$BIN" run --policy "$work/policy.lua" > "$work/out" 2>&1 & + local pid=$! + local i + for i in $(seq 1 400); do + if grep -q 'awaiting SIGHUP' "$work/out" 2>/dev/null; then break; fi + sleep 0.025 + done + cp ../policy-v2.lua "$work/policy.lua" + kill -HUP "$pid" + local rc=0 + wait "$pid" || rc=$? + cat "$work/out" + return "$rc" +} + +# check: load one policy under the sandbox and report ok / rejected. +check() { + if [[ ! -x "$BIN" ]]; then build; fi + exec "./$BIN" check --policy "$@" +} + +case "${1:-}" in + build) build ;; + run) shift; run ;; + check) shift; check "$@" ;; + "") build; run ;; + *) echo "usage: $0 [build|run|check ]" >&2; exit 2 ;; +esac diff --git a/examples/42-embedded-lua-scripting/cpp/src/main.cpp b/examples/42-embedded-lua-scripting/cpp/src/main.cpp new file mode 100644 index 0000000..7ae188b --- /dev/null +++ b/examples/42-embedded-lua-scripting/cpp/src/main.cpp @@ -0,0 +1,326 @@ +// pmon — chapter 42: an embedded-Lua restart policy via sol2. +// +// The restart/backoff decision lives entirely in policy.lua, not in this +// binary: the host loads it into a *sandboxed* Lua 5.4 state (base/string/ +// table/math only — no os, io, package, debug; load/loadfile/dofile/require/ +// collectgarbage/loadstring removed even though their libraries were never +// opened, since a global with that name could otherwise be reintroduced by a +// careless policy), calls `on_exit(info)` for every simulated child exit, and +// prints exactly what the policy hands back. A real SIGHUP — blocked with +// sigprocmask and consumed as data through a signalfd + poll, the same +// pattern ch12/ch13 use for SIGCHLD — reloads the policy file with no +// restart of pmon itself: the running supervisor keeps its child-tracking +// state, only the decision logic changes underneath it. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +using std::chrono::steady_clock; + +[[nodiscard]] std::error_code last_error() { + return {errno, std::system_category()}; +} + +// Every stdout write is a bare fflush away from a race with the parent shell +// that greps this process's redirected output file for a sentinel line (see +// demo.sh's `run()`) — std::println is fully buffered once stdout is not a +// tty, so every print here is followed by an explicit flush. +void say(std::string_view line) { + std::println("{}", line); + std::fflush(stdout); +} + +[[noreturn]] void die(const std::string& msg) { + std::println(stderr, "pmon: {}", msg); + std::exit(1); +} + +// --------------------------------------------------------------------------- +// Sandbox: open only base/string/table/math, then strip the globals that +// would let a policy touch the filesystem, spawn processes, or load new code +// even though their owning libraries (os, io, package, debug) were never +// opened in the first place. +// --------------------------------------------------------------------------- + +sol::state make_sandbox() { + sol::state lua; + lua.open_libraries(sol::lib::base, sol::lib::string, sol::lib::table, sol::lib::math); + lua["load"] = sol::lua_nil; + lua["loadfile"] = sol::lua_nil; + lua["dofile"] = sol::lua_nil; + lua["require"] = sol::lua_nil; + lua["collectgarbage"] = sol::lua_nil; + lua["loadstring"] = sol::lua_nil; + + sol::table host = lua.create_named_table("host"); + host.set_function("log", [](const std::string& msg) { say("pmon: policy: " + msg); }); + host.set_function("now_ms", []() -> long long { + return std::chrono::duration_cast( + steady_clock::now().time_since_epoch()) + .count(); + }); + return lua; +} + +// --------------------------------------------------------------------------- +// Errors, three ways (C++): fallible operations return std::expected, converting a failed sol::protected_function_result into its +// sol::error message; die() is reserved for CLI-usage/OS-resource failures +// that have no data-driven recovery. +// --------------------------------------------------------------------------- + +// Load `path` under a protected parse+run, then call on_load() if the policy +// defines one (also protected). Any failure here is a policy-load error. +[[nodiscard]] std::expected load_policy(sol::state& lua, const std::string& path) { + sol::protected_function_result loaded = lua.safe_script_file(path, sol::script_pass_on_error); + if (!loaded.valid()) { + sol::error err = loaded; + return std::unexpected(err.what()); + } + sol::protected_function on_load = lua["on_load"]; + if (on_load.valid()) { + sol::protected_function_result ran = on_load(); + if (!ran.valid()) { + sol::error err = ran; + return std::unexpected(err.what()); + } + } + return {}; +} + +// One simulated child-exit event to feed on_exit(). +struct Event { + std::string_view name; + long long exit_code; + long long restarts; + long long consecutive_failures; + long long uptime_ms; + long long last_backoff_ms; +}; + +struct Decision { + std::string action; + long long delay_ms = 0; + std::string reason; +}; + +// Build `info`, call the policy's on_exit(info) under a protected call, and +// read back action/delay_ms/reason. `signal` stays absent from the table +// (nil) for every event this chapter replays. +[[nodiscard]] std::expected decide(sol::state& lua, const Event& ev) { + sol::table info = lua.create_table(); + info["name"] = std::string{ev.name}; + info["exit_code"] = ev.exit_code; + info["restarts"] = ev.restarts; + info["consecutive_failures"] = ev.consecutive_failures; + info["uptime_ms"] = ev.uptime_ms; + info["last_backoff_ms"] = ev.last_backoff_ms; + + sol::protected_function fn = lua["on_exit"]; + if (!fn.valid()) { + return std::unexpected(std::string{"policy defines no on_exit"}); + } + sol::protected_function_result result = fn(info); + if (!result.valid()) { + sol::error err = result; + return std::unexpected(err.what()); + } + sol::table decision = result; + return Decision{ + .action = decision["action"], + .delay_ms = decision["delay_ms"], + .reason = decision["reason"], + }; +} + +void print_decision(std::string_view name, const Decision& dec) { + say(std::format(R"(pmon: decision child={} action={} delay_ms={} reason="{}")", name, dec.action, + dec.delay_ms, dec.reason)); +} + +// --------------------------------------------------------------------------- +// SIGHUP, the systems-programming content: block it process-wide up front so +// there is never a window where it could interrupt a syscall or fire a +// default handler, then consume it as ordinary readability on a signalfd — +// same pattern ch12/ch13 use for SIGCHLD, just a different signal. +// --------------------------------------------------------------------------- + +[[nodiscard]] std::expected make_sighup_fd() { + sigset_t mask; + sigemptyset(&mask); + sigaddset(&mask, SIGHUP); + if (::sigprocmask(SIG_BLOCK, &mask, nullptr) != 0) { + return std::unexpected(last_error()); + } + const int fd = ::signalfd(-1, &mask, SFD_CLOEXEC); + if (fd < 0) { + return std::unexpected(last_error()); + } + return fd; +} + +// Block until SIGHUP arrives on `sigfd`, draining exactly one signalfd_siginfo. +void wait_for_sighup(int sigfd) { + pollfd pfd{.fd = sigfd, .events = POLLIN, .revents = 0}; + for (;;) { + const int n = ::poll(&pfd, 1, -1); + if (n < 0) { + if (errno == EINTR) { + continue; + } + die("poll: " + last_error().message()); + } + signalfd_siginfo info{}; + if (::read(sigfd, &info, sizeof info) == sizeof info && info.ssi_signo == SIGHUP) { + return; + } + } +} + +// Fresh sandbox + host table + protected load + on_load(), all rebuilt from +// scratch — the simplest correct way to guarantee reload leaves no state +// from the previous policy behind. +[[nodiscard]] std::expected reload(const std::string& policy_path) { + sol::state lua = make_sandbox(); + if (auto loaded = load_policy(lua, policy_path); !loaded) { + return std::unexpected(loaded.error()); + } + return lua; +} + +// --------------------------------------------------------------------------- +// Subcommands +// --------------------------------------------------------------------------- + +int run_phase(sol::state& lua, const std::vector& events) { + for (const auto& ev : events) { + auto dec = decide(lua, ev); + if (!dec) { + std::println(stderr, "pmon: policy error: {}", dec.error()); + return 1; + } + print_decision(ev.name, *dec); + } + return 0; +} + +int cmd_run(const std::string& policy_path) { + auto sigfd = make_sighup_fd(); + if (!sigfd) { + std::println(stderr, "pmon: signalfd: {}", sigfd.error().message()); + return 1; + } + + auto phase1 = reload(policy_path); + if (!phase1) { + std::println(stderr, "pmon: policy error: {}", phase1.error()); + return 1; + } + sol::state lua = std::move(*phase1); + + static const std::vector kPhase1{ + {.name = "web", .exit_code = 0, .restarts = 0, .consecutive_failures = 0, .uptime_ms = 120000, .last_backoff_ms = 0}, + {.name = "worker", .exit_code = 1, .restarts = 0, .consecutive_failures = 0, .uptime_ms = 9000, .last_backoff_ms = 0}, + {.name = "worker", .exit_code = 1, .restarts = 1, .consecutive_failures = 1, .uptime_ms = 9000, .last_backoff_ms = 500}, + }; + if (run_phase(lua, kPhase1) != 0) { + return 1; + } + + say("pmon: awaiting SIGHUP to reload policy"); + wait_for_sighup(*sigfd); + say("pmon: reload requested"); + ::close(*sigfd); + + // Re-read the same --policy path — its content is now policy-v2.lua. + auto phase2 = reload(policy_path); + if (!phase2) { + std::println(stderr, "pmon: policy error: {}", phase2.error()); + return 1; + } + lua = std::move(*phase2); + + static const std::vector kPhase2{ + {.name = "api", .exit_code = 1, .restarts = 0, .consecutive_failures = 0, .uptime_ms = 5000, .last_backoff_ms = 0}, + {.name = "worker", .exit_code = 1, .restarts = 2, .consecutive_failures = 2, .uptime_ms = 9000, .last_backoff_ms = 1000}, + }; + if (run_phase(lua, kPhase2) != 0) { + return 1; + } + return 0; +} + +int cmd_check(const std::string& policy_path) { + sol::state lua = make_sandbox(); + if (auto loaded = load_policy(lua, policy_path); !loaded) { + std::println("pmon: policy error: {}", loaded.error()); + std::fflush(stdout); + return 1; + } + + const Event probe{.name = "probe", .exit_code = 1, .restarts = 0, .consecutive_failures = 0, + .uptime_ms = 9000, .last_backoff_ms = 0}; + auto dec = decide(lua, probe); + if (!dec) { + std::println("pmon: policy error: {}", dec.error()); + std::fflush(stdout); + return 1; + } + + std::string version = lua["POLICY_VERSION"]; + say(std::format("pmon: policy ok version={}", version)); + return 0; +} + +void usage() { + std::println(stderr, "usage: app --policy PATH"); +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + usage(); + return 2; + } + const std::string_view subcommand = argv[1]; + if (subcommand != "run" && subcommand != "check") { + usage(); + return 2; + } + + std::optional policy; + for (int i = 2; i < argc; ++i) { + const std::string_view arg = argv[i]; + if (arg == "--policy" && i + 1 < argc) { + policy = argv[++i]; + } else { + usage(); + return 2; + } + } + if (!policy) { + usage(); + return 2; + } + + return subcommand == "run" ? cmd_run(*policy) : cmd_check(*policy); +} diff --git a/examples/42-embedded-lua-scripting/demo.sh b/examples/42-embedded-lua-scripting/demo.sh new file mode 100755 index 0000000..afbbe4c --- /dev/null +++ b/examples/42-embedded-lua-scripting/demo.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +langs=(cpp go rust) + +case "${1:-all}" in + cpp|go|rust) + lang="$1" + shift + exec "./$lang/demo.sh" "$@" + ;; + all) + for lang in "${langs[@]}"; do "./$lang/demo.sh"; done + ;; + build) + for lang in "${langs[@]}"; do "./$lang/demo.sh" build; done + ;; + *) + echo "usage: $0 [cpp|go|rust|all|build] [args...]" >&2 + exit 2 + ;; +esac diff --git a/examples/42-embedded-lua-scripting/go/demo.sh b/examples/42-embedded-lua-scripting/go/demo.sh new file mode 100755 index 0000000..7434ed0 --- /dev/null +++ b/examples/42-embedded-lua-scripting/go/demo.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +BIN=bin/app + +build() { + go build -o "$BIN" . +} + +# run: replay the two-phase exit scenario, hot-reload the policy on a real +# SIGHUP between phases, and stream the supervisor's output. The reload is +# race-free: we wait for the "awaiting SIGHUP" sentinel before editing the +# watched policy file and signalling, so there is no fixed sleep to tune. +run() { + if [[ ! -x "$BIN" ]]; then build; fi + local work; work="$(mktemp -d)" + trap 'rm -rf "$work"' RETURN + cp ../policy.lua "$work/policy.lua" + "./$BIN" run --policy "$work/policy.lua" > "$work/out" 2>&1 & + local pid=$! + local i + for i in $(seq 1 400); do + if grep -q 'awaiting SIGHUP' "$work/out" 2>/dev/null; then break; fi + sleep 0.025 + done + cp ../policy-v2.lua "$work/policy.lua" + kill -HUP "$pid" + local rc=0 + wait "$pid" || rc=$? + cat "$work/out" + return "$rc" +} + +# check: load one policy under the sandbox and report ok / rejected. +check() { + if [[ ! -x "$BIN" ]]; then build; fi + exec "./$BIN" check --policy "$@" +} + +case "${1:-}" in + build) build ;; + run) shift; run ;; + check) shift; check "$@" ;; + "") build; run ;; + *) echo "usage: $0 [build|run|check ]" >&2; exit 2 ;; +esac diff --git a/examples/42-embedded-lua-scripting/go/go.mod b/examples/42-embedded-lua-scripting/go/go.mod new file mode 100644 index 0000000..3d7c844 --- /dev/null +++ b/examples/42-embedded-lua-scripting/go/go.mod @@ -0,0 +1,7 @@ +module github.com/patterncatalyst/linux-systems-programming/examples/42-embedded-lua-scripting/go + +go 1.26 + +toolchain go1.26.5 + +require github.com/yuin/gopher-lua v1.1.2 diff --git a/examples/42-embedded-lua-scripting/go/go.sum b/examples/42-embedded-lua-scripting/go/go.sum new file mode 100644 index 0000000..ced91eb --- /dev/null +++ b/examples/42-embedded-lua-scripting/go/go.sum @@ -0,0 +1,2 @@ +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= diff --git a/examples/42-embedded-lua-scripting/go/main.go b/examples/42-embedded-lua-scripting/go/main.go new file mode 100644 index 0000000..8445286 --- /dev/null +++ b/examples/42-embedded-lua-scripting/go/main.go @@ -0,0 +1,326 @@ +// pmon: a tiny process supervisor whose restart/backoff policy is not +// compiled in -- it is a Lua script (policy.lua), loaded into a sandboxed +// gopher-lua state and re-loaded on demand with a real SIGHUP. See the +// chapter for why this beats a JSON config: the policy can compute (Lua +// closures, exponential backoff, crash-loop detection) instead of merely +// describing. +// +// Two subcommands: +// +// app run --policy PATH run the two-phase demo scenario, reload on SIGHUP +// app check --policy PATH load PATH, probe on_exit once, report ok/error +package main + +import ( + "errors" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + lua "github.com/yuin/gopher-lua" +) + +// event describes one supervised-child exit, handed to the policy's on_exit +// as a plain Lua table of primitives. exitCode/signal are pointers because +// the Lua contract treats "field absent" and "field nil" as the same thing: +// a signal death has no exit_code, a normal exit has no signal. +type event struct { + name string + exitCode *int64 + signal *string + restarts int64 + consecutiveFailures int64 + uptimeMs int64 + lastBackoffMs int64 +} + +// decision is what the policy's on_exit returned: what to do about the +// child, when, and why. reason is Lua's string, verbatim -- the host never +// constructs decision text itself. +type decision struct { + action string + delayMs int64 + reason string +} + +func main() { + if len(os.Args) < 2 { + usage() + } + switch os.Args[1] { + case "run": + policyPath, err := parsePolicyFlag(os.Args[2:]) + if err != nil { + usage() + } + if err := cmdRun(policyPath); err != nil { + die(err) + } + case "check": + policyPath, err := parsePolicyFlag(os.Args[2:]) + if err != nil { + usage() + } + os.Exit(cmdCheck(policyPath)) + default: + usage() + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: app run --policy PATH | app check --policy PATH") + os.Exit(2) +} + +func parsePolicyFlag(args []string) (string, error) { + if len(args) != 2 || args[0] != "--policy" || args[1] == "" { + return "", fmt.Errorf("expected --policy PATH") + } + return args[1], nil +} + +// die is the terminal-error path for `run`: print the fully wrapped error +// chain and exit 1. Every error reaching here was built with fmt.Errorf's +// %w, so the chain (CLI step -> Lua-load step -> Lua's own message) survives +// intact -- this is "errors, three ways" step one: wrapped errors. +func die(err error) { + fmt.Fprintln(os.Stderr, "pmon:", err) + os.Exit(1) +} + +// luaErr is "errors, three ways" step two: unwrap gopher-lua's *lua.ApiError +// (via errors.As, not a bare type assertion) down to the Lua-side message, +// so a caller sees "attempt to index a nil value (global 'os')" rather than +// a Go-shaped wrapper string. +func luaErr(err error) error { + var apiErr *lua.ApiError + if errors.As(err, &apiErr) { + return errors.New(strings.TrimSpace(apiErr.Object.String())) + } + return err +} + +// newSandbox builds a fresh Lua state with only base/string/table/math +// opened -- no os, io, package, debug, coroutine -- and the loader globals +// that could pull in more code or touch the filesystem removed. Every +// (re)load, at startup and again on each SIGHUP, gets its own state built +// from scratch: a misbehaving policy can never leave residue for the next +// one to inherit. +func newSandbox() *lua.LState { + L := lua.NewState(lua.Options{SkipOpenLibs: true}) + + for _, lib := range []struct { + name string + open lua.LGFunction + }{ + {lua.BaseLibName, lua.OpenBase}, + {lua.StringLibName, lua.OpenString}, + {lua.TabLibName, lua.OpenTable}, + {lua.MathLibName, lua.OpenMath}, + } { + L.Push(L.NewFunction(lib.open)) + L.Push(lua.LString(lib.name)) + L.Call(1, 0) + } + + for _, g := range []string{"load", "loadfile", "dofile", "require", "collectgarbage", "loadstring"} { + L.SetGlobal(g, lua.LNil) + } + + // The unopened libraries (os, io, package, debug, coroutine) are left as + // plain nils by SkipOpenLibs, which gopher-lua's VM reports generically + // ("attempt to index a non-table object(nil) with key 'execute'") -- it + // never names the global the way PUC-Rio Lua does. Trade a nil for a + // guard table whose __index raises a message that names the blocked + // global explicitly: same sandboxing (the real library is still never + // opened), a clearer error for whoever runs `check`. + for _, blocked := range []string{"os", "io", "package", "debug", "coroutine"} { + name := blocked + guard := L.NewTable() + mt := L.NewTable() + mt.RawSetString("__index", L.NewFunction(func(L *lua.LState) int { + key := L.CheckString(2) + L.RaiseError("sandbox: global '%s' is not available (blocked field '%s')", name, key) + return 0 + })) + L.SetMetatable(guard, mt) + L.SetGlobal(name, guard) + } + + host := L.NewTable() + L.SetFuncs(host, map[string]lua.LGFunction{ + "log": func(L *lua.LState) int { + fmt.Printf("pmon: policy: %s\n", L.CheckString(1)) + return 0 + }, + "now_ms": func(L *lua.LState) int { + L.Push(lua.LNumber(time.Now().UnixMilli())) + return 1 + }, + }) + L.SetGlobal("host", host) + + return L +} + +// loadPolicy compiles and executes the policy chunk under the sandbox (a +// protected load -- a syntax error becomes a returned error, never a panic), +// then calls on_load() if the policy defines one, also protected. +func loadPolicy(L *lua.LState, path string) error { + if err := L.DoFile(path); err != nil { + return fmt.Errorf("loading %s: %w", path, luaErr(err)) + } + if fn, ok := L.GetGlobal("on_load").(*lua.LFunction); ok { + if err := L.CallByParam(lua.P{Fn: fn, NRet: 0, Protect: true}); err != nil { + return fmt.Errorf("on_load in %s: %w", path, luaErr(err)) + } + } + return nil +} + +func i64ptr(n int64) *int64 { return &n } + +// callOnExit builds the info table from ev, calls the policy's on_exit under +// a protected call, and reads back action/delay_ms/reason from the returned +// table. signal/exit_code are set on the table only when ev carries them, +// matching the "a nil field is simply absent" contract. +func callOnExit(L *lua.LState, ev event) (decision, error) { + info := L.NewTable() + info.RawSetString("name", lua.LString(ev.name)) + if ev.exitCode != nil { + info.RawSetString("exit_code", lua.LNumber(*ev.exitCode)) + } + if ev.signal != nil { + info.RawSetString("signal", lua.LString(*ev.signal)) + } + info.RawSetString("restarts", lua.LNumber(ev.restarts)) + info.RawSetString("consecutive_failures", lua.LNumber(ev.consecutiveFailures)) + info.RawSetString("uptime_ms", lua.LNumber(ev.uptimeMs)) + info.RawSetString("last_backoff_ms", lua.LNumber(ev.lastBackoffMs)) + + fn, ok := L.GetGlobal("on_exit").(*lua.LFunction) + if !ok { + return decision{}, fmt.Errorf("policy defines no on_exit function") + } + if err := L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true}, info); err != nil { + return decision{}, fmt.Errorf("on_exit: %w", luaErr(err)) + } + ret := L.Get(-1) + L.Pop(1) + tbl, ok := ret.(*lua.LTable) + if !ok { + return decision{}, fmt.Errorf("on_exit returned %s, want table", ret.Type()) + } + return decision{ + action: tbl.RawGetString("action").String(), + delayMs: int64(lua.LVAsNumber(tbl.RawGetString("delay_ms"))), + reason: tbl.RawGetString("reason").String(), + }, nil +} + +func printDecision(name string, d decision) { + fmt.Printf("pmon: decision child=%s action=%s delay_ms=%d reason=\"%s\"\n", + name, d.action, d.delayMs, d.reason) +} + +// phase1Events and phase2Events are the fixed scenario `run` replays: three +// exits against the v1 policy, a SIGHUP-triggered reload to v2, then two +// more exits against v2. Hardcoded here (not read from anywhere) because the +// point of this example is the policy engine, not a real process reaper. +func phase1Events() []event { + return []event{ + {name: "web", exitCode: i64ptr(0), restarts: 0, consecutiveFailures: 0, uptimeMs: 120000, lastBackoffMs: 0}, + {name: "worker", exitCode: i64ptr(1), restarts: 0, consecutiveFailures: 0, uptimeMs: 9000, lastBackoffMs: 0}, + {name: "worker", exitCode: i64ptr(1), restarts: 1, consecutiveFailures: 1, uptimeMs: 9000, lastBackoffMs: 500}, + } +} + +func phase2Events() []event { + return []event{ + {name: "api", exitCode: i64ptr(1), restarts: 0, consecutiveFailures: 0, uptimeMs: 5000, lastBackoffMs: 0}, + {name: "worker", exitCode: i64ptr(1), restarts: 2, consecutiveFailures: 2, uptimeMs: 9000, lastBackoffMs: 1000}, + } +} + +func runPhase(L *lua.LState, events []event) error { + for _, ev := range events { + d, err := callOnExit(L, ev) + if err != nil { + return err + } + printDecision(ev.name, d) + } + return nil +} + +// cmdRun drives the two-phase scenario. The SIGHUP reload is the +// systems-programming content of this chapter (same pattern as ch12): pmon +// is a long-running process that picks up a new policy without restarting. +// signal.Notify + a blocking receive on a buffered channel is Go's idiomatic +// async-signal-safe equivalent of signalfd+poll -- the actual OS-level +// signal handling happens in the runtime's signal thread, never in +// user Go code, so there is nothing unsafe to do inside a handler here. +func cmdRun(policyPath string) error { + L := newSandbox() + if err := loadPolicy(L, policyPath); err != nil { + L.Close() + return err + } + if err := runPhase(L, phase1Events()); err != nil { + L.Close() + return err + } + + fmt.Println("pmon: awaiting SIGHUP to reload policy") + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGHUP) + <-sigCh + fmt.Println("pmon: reload requested") + + // Re-read the same --policy path (its content has since changed) into a + // brand-new sandboxed state; the old one is discarded, not mutated. + L.Close() + L = newSandbox() + if err := loadPolicy(L, policyPath); err != nil { + L.Close() + return err + } + if err := runPhase(L, phase2Events()); err != nil { + L.Close() + return err + } + + L.Close() + return nil +} + +// cmdCheck loads one policy under the sandbox and probes on_exit once. Any +// failure -- a syntax error at load, an on_load error, or a sandbox +// violation reached from on_exit -- is reported the same way: the operator +// deciding whether to trust this file doesn't need to know which step +// failed, only that it did. +func cmdCheck(policyPath string) int { + L := newSandbox() + defer L.Close() + + if err := loadPolicy(L, policyPath); err != nil { + fmt.Printf("pmon: policy error: %s\n", err) + return 1 + } + + probe := event{ + name: "probe", exitCode: i64ptr(1), + restarts: 0, consecutiveFailures: 0, uptimeMs: 9000, lastBackoffMs: 0, + } + if _, err := callOnExit(L, probe); err != nil { + fmt.Printf("pmon: policy error: %s\n", err) + return 1 + } + + version := L.GetGlobal("POLICY_VERSION").String() + fmt.Printf("pmon: policy ok version=%s\n", version) + return 0 +} diff --git a/examples/42-embedded-lua-scripting/policy-broken.lua b/examples/42-embedded-lua-scripting/policy-broken.lua new file mode 100644 index 0000000..6324097 --- /dev/null +++ b/examples/42-embedded-lua-scripting/policy-broken.lua @@ -0,0 +1,17 @@ +-- policy-broken.lua -- a policy with a syntax error. [validate: expect-syntax-error] +-- +-- The host loads policies with a protected load, so a syntax error surfaces as +-- data (a returned error), not a crash. `pmon check --policy policy-broken.lua` +-- reports the parse failure and exits non-zero; the running supervisor would +-- keep its previous good policy. This is the load-time half of "Errors, three +-- ways" -- the runtime half is policy-evil.lua. + +POLICY_VERSION = "broken" + +function on_exit(info) + -- Deliberate syntax error: `end` is missing to close the `if`. + if info.exit_code == 0 then + return { action = "stop", delay_ms = 0, reason = "clean" } + -- (no `end` here) -- parser reports "'end' expected" + return { action = "restart", delay_ms = 0, reason = "crash" } +end diff --git a/examples/42-embedded-lua-scripting/policy-evil.lua b/examples/42-embedded-lua-scripting/policy-evil.lua new file mode 100644 index 0000000..28312a2 --- /dev/null +++ b/examples/42-embedded-lua-scripting/policy-evil.lua @@ -0,0 +1,23 @@ +-- policy-evil.lua -- a policy that tries to break out of the sandbox. +-- +-- The host loads every policy inside a sandbox: os, io, package and debug are +-- never opened, so `os` is not available to the script. This policy tries to +-- shell out anyway. Reaching for `os.execute` raises a Lua error (the exact +-- wording differs per host -- PUC-Lua names the nil global, gopher-lua's guard +-- table names the blocked field), which the host catches through its protected +-- call and reports as a rejected policy -- the supervisor keeps running, the +-- malicious policy never takes effect. `pmon check --policy policy-evil.lua` +-- shows the rejection. + +POLICY_VERSION = "evil" + +function on_load() + -- nothing +end + +function on_exit(info) + -- Sandbox blocks this: `os` is not available, so this line raises a Lua + -- error the host catches (e.g. "attempt to index a nil value (global 'os')"). + os.execute("id > /tmp/pwned") + return { action = "restart", delay_ms = 0, reason = "should never get here" } +end diff --git a/examples/42-embedded-lua-scripting/policy-v2.lua b/examples/42-embedded-lua-scripting/policy-v2.lua new file mode 100644 index 0000000..ea34bea --- /dev/null +++ b/examples/42-embedded-lua-scripting/policy-v2.lua @@ -0,0 +1,96 @@ +-- policy-v2.lua -- the "edited" policy the demo drops in before sending SIGHUP. +-- +-- Identical in shape to policy.lua; only CONFIG and POLICY_VERSION change. The +-- host re-reads its --policy path on SIGHUP, so swapping this file in and +-- signalling the running supervisor retunes restart behaviour with no rebuild +-- and no restart of pmon itself. That is the whole point of the chapter. + +local CONFIG = { + base_backoff_ms = 100, -- much tighter first backoff than v1's 500ms + max_backoff_ms = 30000, + max_restarts = 2, -- give up far sooner than v1's 8 + fast_crash_ms = 2000, + fast_crash_streak = 3, +} + +POLICY_VERSION = "1.1.0" + +local function next_backoff_ms(last_backoff_ms) + local delay + if last_backoff_ms == nil or last_backoff_ms <= 0 then + delay = CONFIG.base_backoff_ms + else + delay = last_backoff_ms * 2 + end + if delay > CONFIG.max_backoff_ms then + delay = CONFIG.max_backoff_ms + end + return delay +end + +local function safe_log(msg) + if host ~= nil and host.log ~= nil then + host.log(msg) + end +end + +function on_load() + safe_log(string.format( + "policy loaded: version=%s base_backoff_ms=%d max_backoff_ms=%d max_restarts=%d", + POLICY_VERSION, CONFIG.base_backoff_ms, CONFIG.max_backoff_ms, CONFIG.max_restarts)) +end + +function on_exit(info) + local name = info.name or "?" + local exit_code = info.exit_code + local signal = info.signal + local restarts = info.restarts or 0 + local consecutive = info.consecutive_failures or 0 + local uptime_ms = info.uptime_ms or -1 + local last_backoff_ms = info.last_backoff_ms or 0 + + if signal == nil and exit_code == 0 then + return { + action = "stop", + delay_ms = 0, + reason = string.format("%s exited 0: clean shutdown, not restarting", name), + } + end + + local consecutive_failures = consecutive + 1 + local how + if signal ~= nil then + how = "signal " .. signal + else + how = "exit code " .. tostring(exit_code) + end + + local fast = uptime_ms >= 0 and uptime_ms < CONFIG.fast_crash_ms + if fast and consecutive_failures >= CONFIG.fast_crash_streak then + return { + action = "escalate", + delay_ms = 0, + reason = string.format( + "%s: %d fast failures in a row (last %s after %dms uptime) -- crash-looping, paging", + name, consecutive_failures, how, uptime_ms), + } + end + + if restarts >= CONFIG.max_restarts then + return { + action = "escalate", + delay_ms = 0, + reason = string.format( + "%s: giving up after %d restarts (last %s)", name, restarts, how), + } + end + + local delay_ms = next_backoff_ms(last_backoff_ms) + return { + action = "restart", + delay_ms = delay_ms, + reason = string.format( + "%s: restart %d after %s (uptime %dms), backing off %dms", + name, restarts + 1, how, uptime_ms, delay_ms), + } +end diff --git a/examples/42-embedded-lua-scripting/policy.lua b/examples/42-embedded-lua-scripting/policy.lua new file mode 100644 index 0000000..a5e0bcf --- /dev/null +++ b/examples/42-embedded-lua-scripting/policy.lua @@ -0,0 +1,127 @@ +-- policy.lua -- pmon restart/backoff policy. +-- +-- Loaded once at startup and again on every SIGHUP. The host (C++/sol2 and +-- Rust/mlua on Lua 5.4, Go/gopher-lua on Lua 5.1) calls on_exit() after each +-- supervised child exits and acts on the table it returns. Nothing here is +-- language-specific: it uses only features present in BOTH Lua 5.1 and 5.4 +-- (no //, no goto, no , no bitwise operators, no 5.2+-only stdlib) so +-- the exact same file drives all three hosts. +-- +-- Contract: +-- on_exit(info) -> decision -- required +-- on_load() -- optional, called once right after (re)load +-- host.log(msg) -- host callback, available inside this file +-- host.now_ms() -- host callback, monotonic-ish wall clock ms +-- +-- The host sandboxes this script: os, io, package and debug are NOT loaded, +-- and load/loadfile/dofile/require are removed, so a policy cannot touch the +-- filesystem, spawn processes, or pull in new code -- see the chapter. + +-- Tunables. Nothing outside this table needs to change to retune the policy, +-- which is the whole point of shipping it as data-shaped Lua instead of a +-- recompiled host. +local CONFIG = { + base_backoff_ms = 500, -- first restart waits this long + max_backoff_ms = 30000, -- backoff never grows past this + max_restarts = 8, -- give up for good after this many lifetime restarts + fast_crash_ms = 2000, -- an exit faster than this "didn't really start" + fast_crash_streak = 3, -- this many fast failures in a row -> escalate now +} + +POLICY_VERSION = "1.0.0" + +-- Compute the next backoff delay from the delay the host actually used last +-- time (info.last_backoff_ms), not from a restart counter -- the host doesn't +-- need to know the policy's math, and the policy doesn't need the host's +-- counters to stay in lockstep with its own. It just doubles whatever it is +-- handed back, with a floor and a cap. +local function next_backoff_ms(last_backoff_ms) + local delay + if last_backoff_ms == nil or last_backoff_ms <= 0 then + delay = CONFIG.base_backoff_ms + else + delay = last_backoff_ms * 2 + end + if delay > CONFIG.max_backoff_ms then + delay = CONFIG.max_backoff_ms + end + return delay +end + +local function safe_log(msg) + if host ~= nil and host.log ~= nil then + host.log(msg) + end +end + +-- Called once, right after the host loads or reloads this file. Purely for +-- observability: it is how "SIGHUP picked up the new policy" becomes a line +-- in the log instead of an assertion about internal state. +function on_load() + safe_log(string.format( + "policy loaded: version=%s base_backoff_ms=%d max_backoff_ms=%d max_restarts=%d", + POLICY_VERSION, CONFIG.base_backoff_ms, CONFIG.max_backoff_ms, CONFIG.max_restarts)) +end + +-- The one function the host requires. info is a plain table of primitives; the +-- return value is a plain table with at least action, delay_ms and reason set. +function on_exit(info) + local name = info.name or "?" + local exit_code = info.exit_code -- integer or nil (signal death) + local signal = info.signal -- string or nil (e.g. "SIGSEGV") + local restarts = info.restarts or 0 + local consecutive = info.consecutive_failures or 0 + local uptime_ms = info.uptime_ms or -1 + local last_backoff_ms = info.last_backoff_ms or 0 + + -- Clean exit: the child chose to stop. Do not second-guess it. + if signal == nil and exit_code == 0 then + return { + action = "stop", + delay_ms = 0, + reason = string.format("%s exited 0: clean shutdown, not restarting", name), + } + end + + local consecutive_failures = consecutive + 1 + local how + if signal ~= nil then + how = "signal " .. signal + else + how = "exit code " .. tostring(exit_code) + end + + -- Crash-looping: several failures in a row, each dying almost immediately. + -- This is the case a flat restart counter misses -- 8 restarts spread over + -- an hour is normal churn, 3 restarts in 3 seconds is a broken binary. + local fast = uptime_ms >= 0 and uptime_ms < CONFIG.fast_crash_ms + if fast and consecutive_failures >= CONFIG.fast_crash_streak then + return { + action = "escalate", + delay_ms = 0, + reason = string.format( + "%s: %d fast failures in a row (last %s after %dms uptime) -- crash-looping, paging", + name, consecutive_failures, how, uptime_ms), + } + end + + -- Lifetime give-up: failing steadily, not in a tight loop, but it has burned + -- through its restart budget. + if restarts >= CONFIG.max_restarts then + return { + action = "escalate", + delay_ms = 0, + reason = string.format( + "%s: giving up after %d restarts (last %s)", name, restarts, how), + } + end + + local delay_ms = next_backoff_ms(last_backoff_ms) + return { + action = "restart", + delay_ms = delay_ms, + reason = string.format( + "%s: restart %d after %s (uptime %dms), backing off %dms", + name, restarts + 1, how, uptime_ms, delay_ms), + } +end diff --git a/examples/42-embedded-lua-scripting/rust/Cargo.lock b/examples/42-embedded-lua-scripting/rust/Cargo.lock new file mode 100644 index 0000000..5773e34 --- /dev/null +++ b/examples/42-embedded-lua-scripting/rust/Cargo.lock @@ -0,0 +1,317 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "embedded-lua" +version = "0.1.0" +dependencies = [ + "anyhow", + "mlua", + "signal-hook", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "lua-src" +version = "550.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c110c2fa33f34e0de05448e1f3eb2e0631e7a69e2d8ae1586cffc9fc9f9949" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.7.2+b925b3e" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920cf654b23d217c550ceea57c32cd2a413ea27b6d47ed77b5ee0cf655adefa6" +dependencies = [ + "cc", + "which", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mlua" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad72ffa037cf5970c9860674f32f703fda25d86cf217475fe7a79c5f9961bcaa" +dependencies = [ + "bstr", + "either", + "libc", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash", +] + +[[package]] +name = "mlua-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92136787b906d4e55cfe96cd6c62e010bb1a56889d0d6cf83eb016dbad07576b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "lua-src", + "luajit-src", + "pkg-config", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/examples/42-embedded-lua-scripting/rust/Cargo.toml b/examples/42-embedded-lua-scripting/rust/Cargo.toml new file mode 100644 index 0000000..abc3e3b --- /dev/null +++ b/examples/42-embedded-lua-scripting/rust/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "embedded-lua" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "app" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +mlua = { version = "0.12", features = ["lua54", "vendored"] } +signal-hook = "0.3" diff --git a/examples/42-embedded-lua-scripting/rust/demo.sh b/examples/42-embedded-lua-scripting/rust/demo.sh new file mode 100755 index 0000000..45e2b0b --- /dev/null +++ b/examples/42-embedded-lua-scripting/rust/demo.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +BIN=target/release/app + +build() { + cargo build --release +} + +# run: replay the two-phase exit scenario, hot-reload the policy on a real +# SIGHUP between phases, and stream the supervisor's output. The reload is +# race-free: we wait for the "awaiting SIGHUP" sentinel before editing the +# watched policy file and signalling, so there is no fixed sleep to tune. +run() { + if [[ ! -x "$BIN" ]]; then build; fi + local work; work="$(mktemp -d)" + trap 'rm -rf "$work"' RETURN + cp ../policy.lua "$work/policy.lua" + "./$BIN" run --policy "$work/policy.lua" > "$work/out" 2>&1 & + local pid=$! + local i + for i in $(seq 1 400); do + if grep -q 'awaiting SIGHUP' "$work/out" 2>/dev/null; then break; fi + sleep 0.025 + done + cp ../policy-v2.lua "$work/policy.lua" + kill -HUP "$pid" + local rc=0 + wait "$pid" || rc=$? + cat "$work/out" + return "$rc" +} + +# check: load one policy under the sandbox and report ok / rejected. +check() { + if [[ ! -x "$BIN" ]]; then build; fi + exec "./$BIN" check --policy "$@" +} + +case "${1:-}" in + build) build ;; + run) shift; run ;; + check) shift; check "$@" ;; + "") build; run ;; + *) echo "usage: $0 [build|run|check ]" >&2; exit 2 ;; +esac diff --git a/examples/42-embedded-lua-scripting/rust/rust-toolchain.toml b/examples/42-embedded-lua-scripting/rust/rust-toolchain.toml new file mode 100644 index 0000000..4099f1a --- /dev/null +++ b/examples/42-embedded-lua-scripting/rust/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy", "rust-src"] diff --git a/examples/42-embedded-lua-scripting/rust/src/main.rs b/examples/42-embedded-lua-scripting/rust/src/main.rs new file mode 100644 index 0000000..e38c488 --- /dev/null +++ b/examples/42-embedded-lua-scripting/rust/src/main.rs @@ -0,0 +1,327 @@ +// pmon -- a restart/backoff supervisor whose policy lives in Lua, not in this +// binary. `policy.lua` (and its SIGHUP-swapped sibling `policy-v2.lua`) is +// loaded into a *sandboxed* mlua state: only base/string/table/math are +// opened, and the loader globals that could read files or eval new code from +// inside the sandbox (`load`, `loadfile`, `dofile`, `require`, +// `collectgarbage`, `loadstring`) are removed after the fact. `os`, `io`, +// `package` and `debug` are simply never opened, so a policy that reaches for +// `os.execute` hits a nil global -- see `run_check` and `policy-evil.lua`. +// +// Errors, three ways: CLI parse failures `bail!` out of `parse_args`; +// fallible host-side operations return `anyhow::Result` composed with +// `.context()`; and every boundary crossing into Lua (`exec`, `.call`, table +// `get`) is an `mlua::Result` that a protected load/call turns into data +// instead of a panic, so a bad policy degrades to a reported error, not a +// crash of the supervisor. + +use std::env; +use std::fs; +use std::process::exit; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use mlua::{Function, Lua, LuaOptions, StdLib, Table, Value}; + +/// Wall-clock milliseconds since the UNIX epoch, exposed to policies as +/// `host.now_ms()`. "Monotonic-ish" is all the contract asks for -- policies +/// only use it for human-readable timestamps, never for scheduling. +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +/// Build a fresh, sandboxed Lua state: only base/string/table/math opened +/// (`os`/`io`/`package`/`debug` are never opened at all), then strip the +/// loader globals that could otherwise read files or evaluate fresh source +/// from inside the sandbox even without a filesystem library. +fn build_sandbox() -> mlua::Result { + let lua = Lua::new_with( + StdLib::STRING | StdLib::TABLE | StdLib::MATH, + LuaOptions::default(), + )?; + + for g in [ + "load", + "loadfile", + "dofile", + "require", + "collectgarbage", + "loadstring", + ] { + lua.globals().set(g, Value::Nil)?; + } + + let host = lua.create_table()?; + host.set( + "log", + lua.create_function(|_, msg: String| { + println!("pmon: policy: {msg}"); + Ok(()) + })?, + )?; + host.set("now_ms", lua.create_function(|_, ()| Ok(now_ms()))?)?; + lua.globals().set("host", host)?; + + Ok(lua) +} + +/// Load and execute the policy chunk under the sandbox (a protected load, so +/// a syntax error comes back as `Err` instead of aborting), then call its +/// optional `on_load()` hook. Both steps are `mlua::Result` all the way +/// through, so callers decide how to surface a failure. +fn load_policy(lua: &Lua, path: &str) -> mlua::Result<()> { + let src = fs::read_to_string(path) + .map_err(|e| mlua::Error::RuntimeError(format!("reading {path}: {e}")))?; + lua.load(&src).set_name(path).exec()?; + + if let Ok(on_load) = lua.globals().get::("on_load") { + on_load.call::<()>(())?; + } + Ok(()) +} + +/// One supervised-child exit event, exactly as the shared scenario spec +/// describes it. `signal` is never set in this chapter's scenario (every +/// event here is an exit-code death), so the `info` table simply never gets +/// a `signal` field -- matching "a nil field is absent from the table". +struct ExitEvent { + name: &'static str, + exit_code: i64, + restarts: i64, + consecutive_failures: i64, + uptime_ms: i64, + last_backoff_ms: i64, +} + +fn info_table(lua: &Lua, ev: &ExitEvent) -> mlua::Result
{ + let info = lua.create_table()?; + info.set("name", ev.name)?; + info.set("exit_code", ev.exit_code)?; + info.set("restarts", ev.restarts)?; + info.set("consecutive_failures", ev.consecutive_failures)?; + info.set("uptime_ms", ev.uptime_ms)?; + info.set("last_backoff_ms", ev.last_backoff_ms)?; + Ok(info) +} + +/// Call `on_exit(info)` under a protected call and print the resulting +/// decision. The `reason` text is entirely Lua's (built with +/// `string.format` in policy.lua) -- this only formats the wrapping +/// `pmon: decision ...` line around whatever the policy returned. +fn decide(lua: &Lua, ev: &ExitEvent) -> mlua::Result<()> { + let info = info_table(lua, ev)?; + let on_exit: Function = lua.globals().get("on_exit")?; + let decision: Table = on_exit.call(info)?; + + let action: String = decision.get("action")?; + let delay_ms: i64 = decision.get("delay_ms")?; + let reason: String = decision.get("reason")?; + + println!( + "pmon: decision child={} action={} delay_ms={} reason=\"{}\"", + ev.name, action, delay_ms, reason + ); + Ok(()) +} + +fn phase1(lua: &Lua) -> mlua::Result<()> { + decide( + lua, + &ExitEvent { + name: "web", + exit_code: 0, + restarts: 0, + consecutive_failures: 0, + uptime_ms: 120_000, + last_backoff_ms: 0, + }, + )?; + decide( + lua, + &ExitEvent { + name: "worker", + exit_code: 1, + restarts: 0, + consecutive_failures: 0, + uptime_ms: 9_000, + last_backoff_ms: 0, + }, + )?; + decide( + lua, + &ExitEvent { + name: "worker", + exit_code: 1, + restarts: 1, + consecutive_failures: 1, + uptime_ms: 9_000, + last_backoff_ms: 500, + }, + )?; + Ok(()) +} + +fn phase2(lua: &Lua) -> mlua::Result<()> { + decide( + lua, + &ExitEvent { + name: "api", + exit_code: 1, + restarts: 0, + consecutive_failures: 0, + uptime_ms: 5_000, + last_backoff_ms: 0, + }, + )?; + decide( + lua, + &ExitEvent { + name: "worker", + exit_code: 1, + restarts: 2, + consecutive_failures: 2, + uptime_ms: 9_000, + last_backoff_ms: 1_000, + }, + )?; + Ok(()) +} + +/// The two-phase scenario: load v1, replay phase 1, block for a real SIGHUP, +/// reload the (now v2) policy from the same path, replay phase 2. +/// +/// SIGHUP handling is the systems-programming content here: `signal-hook` +/// installs an async-signal-safe handler that only flips an `AtomicBool` (no +/// allocation, no Lua call, no I/O inside the signal handler itself), and +/// this thread polls that flag instead of doing any real work inside signal +/// context -- the same discipline ch12 uses for signalfd/self-pipe. The flag +/// is registered *before* we announce readiness, so a SIGHUP that lands the +/// instant after the "awaiting" line prints is never missed. +fn cmd_run(policy_path: &str) -> Result<()> { + // mlua::Error is not Send+Sync in this (non-"send"-feature, single + // threaded) build, so it cannot implement std::error::Error the way + // anyhow::Context wants; fold it into an anyhow::Error by its Display + // text instead of trying to attach `.context()` directly to an + // `mlua::Result`. + let lua = build_sandbox() + .map_err(|e| anyhow::anyhow!("{e}")) + .context("building sandbox")?; + load_policy(&lua, policy_path) + .map_err(|e| anyhow::anyhow!("{e}")) + .context("loading policy")?; + phase1(&lua) + .map_err(|e| anyhow::anyhow!("{e}")) + .context("phase 1 decisions")?; + + let reload = Arc::new(AtomicBool::new(false)); + signal_hook::flag::register(signal_hook::consts::SIGHUP, reload.clone()) + .context("registering SIGHUP handler")?; + + println!("pmon: awaiting SIGHUP to reload policy"); + while !reload.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(20)); + } + println!("pmon: reload requested"); + + // Rebuild the whole Lua state from scratch on reload, rather than mutate + // the old one in place: a fresh sandbox guarantees nothing from the old + // policy (globals, closures) survives into the new one. + let lua = build_sandbox() + .map_err(|e| anyhow::anyhow!("{e}")) + .context("rebuilding sandbox for reload")?; + load_policy(&lua, policy_path) + .map_err(|e| anyhow::anyhow!("{e}")) + .context("reloading policy")?; + phase2(&lua) + .map_err(|e| anyhow::anyhow!("{e}")) + .context("phase 2 decisions")?; + + Ok(()) +} + +/// Load `policy_path` under the sandbox and run one probe `on_exit` call. +/// Returns the policy's declared version on success; the `mlua::Error` on +/// failure already carries the Lua-side message (a syntax error's location, +/// or -- for `policy-evil.lua` -- "attempt to index a nil value (global +/// 'os')", which is how the sandbox rejection gets reported without the host +/// knowing anything about what the policy tried to do). +fn run_check(policy_path: &str) -> mlua::Result { + let lua = build_sandbox()?; + load_policy(&lua, policy_path)?; + + let version: String = lua.globals().get("POLICY_VERSION").unwrap_or_default(); + + let probe = ExitEvent { + name: "probe", + exit_code: 1, + restarts: 0, + consecutive_failures: 0, + uptime_ms: 9_000, + last_backoff_ms: 0, + }; + let info = info_table(&lua, &probe)?; + let on_exit: Function = lua.globals().get("on_exit")?; + let _decision: Table = on_exit.call(info)?; + + Ok(version) +} + +fn cmd_check(policy_path: &str) -> ! { + match run_check(policy_path) { + Ok(version) => { + println!("pmon: policy ok version={version}"); + exit(0); + } + Err(e) => { + println!("pmon: policy error: {e}"); + exit(1); + } + } +} + +const USAGE: &str = "usage: app --policy "; + +/// CLI parse failures are the one class of error that never touches Lua or +/// the filesystem, so they get the plain `bail!` treatment instead of the +/// `mlua::Result` plumbing used everywhere else. +fn parse_args(args: &[String]) -> Result<(String, String)> { + if args.len() < 2 { + bail!("{USAGE}"); + } + let sub = args[1].clone(); + if sub != "run" && sub != "check" { + bail!("{USAGE}"); + } + let rest = &args[2..]; + if rest.len() != 2 || rest[0] != "--policy" { + bail!("{USAGE}"); + } + Ok((sub, rest[1].clone())) +} + +fn main() { + let args: Vec = env::args().collect(); + let (sub, policy_path) = match parse_args(&args) { + Ok(v) => v, + Err(e) => { + eprintln!("{e}"); + exit(2); + } + }; + + match sub.as_str() { + "run" => { + if let Err(e) = cmd_run(&policy_path) { + eprintln!("pmon: error: {e:?}"); + exit(1); + } + } + "check" => cmd_check(&policy_path), + _ => unreachable!("parse_args only returns run|check"), + } +} diff --git a/examples/42-embedded-lua-scripting/verify.lua b/examples/42-embedded-lua-scripting/verify.lua new file mode 100644 index 0000000..accf063 --- /dev/null +++ b/examples/42-embedded-lua-scripting/verify.lua @@ -0,0 +1,76 @@ +-- Verify the embedded-Lua policy engine for LSP_LANG (cpp|go|rust). +-- +-- The C++/Go/Rust hosts all embed the SAME policy.lua and must produce the +-- SAME observable decisions -- that identity is what this script asserts, not +-- merely a zero exit. Every assertion is a checks.expect_match against the +-- host's fixed decision-line format, so it is deterministic and language- +-- neutral (no wall-clock timing, no process introspection). + +local script_dir = arg[0]:match("(.*/)") or "./" +local repo_root = os.getenv("REPO_ROOT") or (script_dir .. "../..") +package.path = package.path .. ";" .. repo_root .. "/scripts/lib/?.lua" +local checks = require("checks") + +local lang = os.getenv("LSP_LANG") +if lang ~= "cpp" and lang ~= "go" and lang ~= "rust" then + checks.skip("LSP_LANG must be cpp|go|rust (got " .. tostring(lang) .. ")") +end + +-- Build once up front so a compile failure is a distinct, obvious failure. +local b = checks.run("./demo.sh " .. lang .. " build") +checks.expect_exit(b, 0, lang .. ": builds") + +-- The main scenario: replay a two-phase exit sequence through policy.lua, +-- reload policy-v2.lua on a real SIGHUP between phases, replay the rest. +local r = checks.run("./demo.sh " .. lang .. " run") +checks.expect_exit(r, 0, lang .. ": run exits 0") + +-- v1 policy loads at startup and announces its tunables (proves on_load fired +-- and host.log crossed the boundary Lua->host). +checks.expect_match(r.out, "policy: policy loaded: version=1%.0%.0", + lang .. ": v1 policy loads at startup") +checks.expect_match(r.out, "base_backoff_ms=500 max_backoff_ms=30000 max_restarts=8", + lang .. ": v1 tunables reported from Lua") + +-- Clean exit -> stop, never restarted. +checks.expect_match(r.out, "child=web action=stop", + lang .. ": clean-exit child gets action=stop") + +-- Exponential backoff is computed in Lua, not hardcoded in the host: the first +-- restart backs off the base 500ms, the next doubles to 1000ms. +checks.expect_match(r.out, "child=worker action=restart delay_ms=500", + lang .. ": first backoff is base 500ms") +checks.expect_match(r.out, "child=worker action=restart delay_ms=1000", + lang .. ": backoff doubles to 1000ms") + +-- Hot reload: real SIGHUP re-reads the --policy path, now holding v2. +checks.expect_match(r.out, "awaiting SIGHUP", lang .. ": supervisor waits for SIGHUP") +checks.expect_match(r.out, "reload requested", lang .. ": SIGHUP handled") +checks.expect_match(r.out, "policy: policy loaded: version=1%.1%.0", + lang .. ": SIGHUP reloads to v2 policy") + +-- Post-reload decisions use v2's numbers: smaller base backoff, and the lower +-- max_restarts turns a would-be restart into an escalate. +checks.expect_match(r.out, "action=restart delay_ms=100", + lang .. ": post-reload decision uses v2's smaller base backoff") +checks.expect_match(r.out, "child=worker action=escalate", + lang .. ": v2's lower max_restarts triggers escalate") + +-- Sandbox + error handling via `check`: a good policy loads clean... +local ok = checks.run("./demo.sh " .. lang .. " check ../policy.lua") +checks.expect_exit(ok, 0, lang .. ": check accepts a good policy") +checks.expect_match(ok.out, "policy ok version=1%.0%.0", + lang .. ": good policy reports ok") + +-- ...a policy that reaches for os.execute is rejected by the sandbox... +local evil = checks.run("./demo.sh " .. lang .. " check ../policy-evil.lua") +checks.expect_exit(evil, 1, lang .. ": check rejects a sandbox-escaping policy") +checks.expect_match(evil.out, "policy error:", lang .. ": sandbox rejection reported") +checks.expect_match(evil.out, "os", lang .. ": rejection names the blocked global") + +-- ...and a syntax error is caught at load, not crashed on. +local broken = checks.run("./demo.sh " .. lang .. " check ../policy-broken.lua") +checks.expect_exit(broken, 1, lang .. ": check rejects a syntax-broken policy") +checks.expect_match(broken.out, "policy error:", lang .. ": parse error reported") + +checks.finish() diff --git a/examples/manifest.yaml b/examples/manifest.yaml index 0661b3d..e5c15cd 100644 --- a/examples/manifest.yaml +++ b/examples/manifest.yaml @@ -172,3 +172,7 @@ examples: mode: vm-peer requires: [lgtm] timeout: 600 + - id: 42-embedded-lua-scripting + langs: [cpp, go, rust] + mode: local + timeout: 360 diff --git a/scripts/validate.py b/scripts/validate.py index 95d8289..a07708e 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -281,7 +281,15 @@ def check_lua() -> None: print("note: lua not on PATH; skipping .lua syntax checks", file=sys.stderr) return stdin_check = "local f, e = loadfile(arg[1]) if not f then io.stderr:write(tostring(e) .. '\\n') os.exit(1) end" + # A .lua file that declares "validate: expect-syntax-error" is an + # intentionally-invalid fixture (e.g. chapter 42's policy-broken.lua, which + # demonstrates load-time rejection). Skip its syntax check by design. for luafile in script_files("*.lua"): + try: + if "validate: expect-syntax-error" in luafile.read_text(errors="replace"): + continue + except OSError: + pass if luac: r = subprocess.run([luac, "-p", str(luafile)], capture_output=True, text=True) else: