From 92d7424014199939e41596bb7b93d2859cde79ae Mon Sep 17 00:00:00 2001 From: esrrhs Date: Fri, 16 Oct 2026 17:24:59 +0800 Subject: [PATCH] feat: drive Lua apps with a fixed init/update/fini lifecycle in the flua host flua used to take one entry function (default `main`) and leave the frame loop to the script. fakelua has no GC and its frame arena is only reset when a top-level Call starts (reentrant count back to zero), so a `while true` loop inside one Call makes the arena grow without bound and per-request allocations pile up forever. Every app had to hand-roll its own loop, and getting that wrong is exactly how the majiang server ended up leaking 2.3GB in 85 seconds. Entry points are now fixed and mandatory, always in package "main" -- no flags to rename them, so every app is written the same way: package "main" function init() ... end called once at startup function update() ... end called every frame function fini() ... end called once before exit - each phase is its own top-level Call, so the frame arena is reclaimed every tick; the host does not pace the loop (a script that wants a fixed cadence calls os.sleep(ms) itself) - missing entry points are reported up front with the fixed layout spelled out, instead of failing later with "function not found" - graceful shutdown, both forms run fini first: a non-zero return from update, or flua.set_exit(code) from Lua. os.exit stays a hard exit that skips fini. - flua deliberately installs no signal handler: reacting to SIGINT / SIGTERM is the application's decision, so the host stays out of it - --once runs init + fini without the update loop (one-shot script / self-check) - the script argument may be a directory: every .lua file inside is compiled in filename order - three small wrappers (GetNativeIntArg / NativeNil / HasEntryFunction) so a host can write native callbacks and probe for entries without touching inter::, which stays an internal detail of the interpreter and JIT - new cmd/test.lua asserts the lifecycle contract from inside the script (call counts and order, cross-frame state only surviving in container.map, flua.set_exit semantics, pcall/error across frame boundaries); it runs in the Linux, macOS and Windows CI jobs, replacing the bare `flua` invocation that only proved the binary starts - READMEs document the fixed layout The library behaviour is unchanged: include/fakelua.h only gains the three additive helpers above, so hosts that embed fakelua keep working as before. --- .github/workflows/build.yml | 10 +- .github/workflows/build_with_macos.yml | 5 +- .github/workflows/build_with_windows.yml | 5 +- README.md | 30 +++- README.zh.md | 27 ++- cmd/main.cpp | 213 +++++++++++++++++++++-- cmd/test.lua | 88 ++++++++++ include/fakelua.h | 19 ++ src/state/fakelua.cpp | 14 ++ 9 files changed, 386 insertions(+), 25 deletions(-) create mode 100644 cmd/test.lua diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f999146..a263f92 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -176,7 +176,10 @@ jobs: # Run all tests (including MySQL / Redis integration tests) # Must run from test/lua so relative Lua file paths resolve correctly (cd $GITHUB_WORKSPACE/test/lua && $GITHUB_WORKSPACE/build/bin/unit_tests) - ./bin/flua + # Lifecycle contract: init/update/fini call counts and order, + # flua.set_exit semantics, exit code. Entry points are fixed to + # package "main", so no flags are needed. + ./bin/flua "$GITHUB_WORKSPACE/cmd/test.lua" # 限定 release:matrix 有 debug/asan/cov,不限定的话一次 push 要跑四遍。 - name: Run benchmarks @@ -194,8 +197,9 @@ jobs: run: | set -eux cd build - echo "function main() return 0 end" > test.lua - ./bin/flua --repeat=2 test.lua + # Fixed paradigm: package "main" with init/fini; --once runs just those + printf 'package "main"\nfunction init() return 0 end\nfunction fini() return 0 end\n' > test.lua + ./bin/flua test.lua --once cd "$GITHUB_WORKSPACE" # Only this repo's sources. Uploading Boost/CPM/usr headers makes Codecov # drop file-level reports because paths cannot be matched to GitHub. diff --git a/.github/workflows/build_with_macos.yml b/.github/workflows/build_with_macos.yml index b380475..926014c 100644 --- a/.github/workflows/build_with_macos.yml +++ b/.github/workflows/build_with_macos.yml @@ -97,7 +97,10 @@ jobs: ccache -s # Run all tests (including MySQL / Redis integration tests) (cd $GITHUB_WORKSPACE/test/lua && $GITHUB_WORKSPACE/build/bin/unit_tests) - ./bin/flua + # Lifecycle contract: init/update/fini call counts and order, + # flua.set_exit semantics, exit code. Entry points are fixed to + # package "main", so no flags are needed. + ./bin/flua "$GITHUB_WORKSPACE/cmd/test.lua" - name: Run benchmarks run: | diff --git a/.github/workflows/build_with_windows.yml b/.github/workflows/build_with_windows.yml index 7feb255..3f9e361 100644 --- a/.github/workflows/build_with_windows.yml +++ b/.github/workflows/build_with_windows.yml @@ -175,7 +175,10 @@ jobs: run: | set -eux cd build/bin - ./flua.exe + # Lifecycle contract: init/update/fini call counts and order, + # flua.set_exit semantics, exit code. Entry points are fixed to + # package "main", so no flags are needed. + ./flua.exe "$GITHUB_WORKSPACE/cmd/test.lua" - name: Run benchmarks shell: msys2 {0} diff --git a/README.md b/README.md index 8c49398..a9d4973 100644 --- a/README.md +++ b/README.md @@ -244,14 +244,38 @@ ctest --test-dir build -V ### CLI Tool `flua` ```bash -./build/bin/flua --entry= --jit_type=<0|1|2> --repeat= +./build/bin/flua [--once] [--jit_type=<0|1|2>] ``` -- `--entry`: Entry function name (default `main`) +Entry points are fixed and always live in package `main` — there are no flags to rename +them, so every app looks the same: + +```lua +package "main" + +function init() ... end -- called once at startup +function update() ... end -- called every frame +function fini() ... end -- called once before exit +``` + +- ``: a single script, or a directory whose `.lua` files are all compiled (sorted by name) +- `--once`: run `init` + `fini` only, without the `update` loop (one-shot script / self-check) - `--jit_type`: `0`=TCC, `1`=GCC, `2`=INTERP (bytecode VM) -- `--repeat`: Repeat call count (for performance measurement) - `--debug`: Enable debug mode (default `false`; when `true`, outputs generated C source / richer diagnostics) +`update` is called back-to-back; a script that wants a fixed cadence paces itself, e.g. +`os.sleep(1000)` at the end of `update` (`os.sleep` takes milliseconds). + +Graceful shutdown (both run `fini` first): return a non-zero code from `update`, or call +`flua.set_exit(code)` from Lua. Note that `os.exit` still exits immediately and skips +`fini`. Signals are not handled by flua -- installing a handler for SIGINT / SIGTERM is +the application's own decision. + +Each lifecycle phase is a separate top-level Call. That matters because fakelua has no GC: +its frame arena is only reset when a top-level Call starts (reentrant count back to zero), +so a `while true` loop inside a single Lua Call would make the arena grow without bound. +Keep `update` to one frame of work (pump IO/timers, dispatch requests). + ### Installation & CMake Integration #### Install to System diff --git a/README.zh.md b/README.zh.md index 795fa4d..0dd078d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -244,14 +244,35 @@ ctest --test-dir build -V ### 命令行工具 `flua` ```bash -./build/bin/flua --entry= --jit_type=<0|1|2> --repeat= +./build/bin/flua [--once] [--jit_type=<0|1|2>] ``` -- `--entry`:入口函数名(默认 `main`) +入口名固定为 package `main` 下的三个函数,不提供改名参数,所有应用写法一致: + +```lua +package "main" + +function init() ... end -- 启动时调用一次 +function update() ... end -- 每帧调用一次 +function fini() ... end -- 退出前调用一次 +``` + +- ``:单个脚本,或一个目录(把目录下所有 `.lua` 按文件名排序编译) +- `--once`:只跑 `init` + `fini`,不进 `update` 循环(一次性脚本 / 自检) - `--jit_type`:`0`=TCC,`1`=GCC,`2`=INTERP(字节码虚拟机) -- `--repeat`:重复调用次数(用于性能测量) - `--debug`:是否启用调试模式(默认 `false`,若为 `true` 则输出生成的 C 源码 / 更详细诊断) +`update` 返回后宿主立刻再调一次;需要固定节奏的脚本自己在 `update` 末尾 `os.sleep(毫秒)` +(fakelua 的 `os.sleep` 单位是毫秒)。 + +优雅退出(两种都会先执行 `fini`):`update` 返回非 0,或 Lua 侧调 `flua.set_exit(code)`。 +注意 `os.exit` 仍是立即退出、跳过 `fini`。信号不由 flua 处理——要不要接管 +SIGINT / SIGTERM 是应用自己的决定。 + +三个阶段各自是一次独立的顶层 Call。这一点很关键:fakelua 没有 GC,帧 arena 只在顶层 Call +入口(reentrant 计数回到 0)重置,若把 `while true` 写在一个 Lua Call 里,arena 会无限增长。 +`update` 里只做一帧的活(泵 IO/定时器、分发请求)。 + ### 安装与 CMake 集成 #### 安装到系统 diff --git a/cmd/main.cpp b/cmd/main.cpp index c089fcf..c73266d 100644 --- a/cmd/main.cpp +++ b/cmd/main.cpp @@ -1,18 +1,202 @@ +// flua -- the FakeLua script host. +// +// Standard lifecycle: init once -> update repeatedly -> fini once before exit. +// +// Why every frame has to be its own top-level Call: fakelua has no GC, so the +// frame arena is only reset when a top-level Call starts (reentrant count back +// to zero). A `while true` loop inside one Lua Call keeps that arena alive +// forever and per-request tables/strings pile up without bound (memory leak). +// update therefore does one frame of work (pump IO/timers, dispatch requests); +// the loop and the frame boundary are driven from here. +// +// Pacing is up to the script: the host adds no sleep and calls update again as +// soon as it returns. A service that wants a fixed cadence calls os.sleep(ms) +// itself (fakelua's os.sleep takes milliseconds). +// +// Graceful exit -- both run fini first: +// - update() returns non-zero: exit with that code; +// - flua.set_exit(code) from Lua: flags the request, takes effect once the +// current update returns. +// os.exit stays a hard exit that skips fini; use the above when cleanup matters. +// Signals are deliberately not handled here: what SIGINT/SIGTERM should do is the +// application's decision, so install a handler if you need one. +// +// Fixed entry points, always package "main" -- there are no flags to rename +// them, so every app is written the same way: +// package "main" +// function init() ... end -- called once at startup +// function update() ... end -- called every frame +// function fini() ... end -- called once before exit +// +// Usage: flua [options] +// --once run init + fini only, without the update loop +// (one-shot script / self-check) +// --jit_type=n 0=tcc 1=gcc 2=interp (default 0) +// --debug enable debug mode +// +// Services whose logic is entirely in Lua can use flua's built-in modules (e.g. +// sqlite.open for lookup tables) and need no C++ host of their own. #include "fakelua.h" #include "gflags/gflags.h" + +#include +#include +#include +#include +#include #include +#include +#include using namespace fakelua; -// 定义命令行参数 +// Command-line flags DEFINE_bool(debug, false, "enable debug mode"); -DEFINE_string(entry, "main", "entry function name, entry must return code(int) and has no parameter"); -DEFINE_int32(repeat, 1, "the repeat run main function times"); +DEFINE_bool(once, false, "run init + fini only, without the update loop (one-shot script)"); DEFINE_int32(jit_type, 0, "jit type: 0=tcc, 1=gcc, 2=interp"); +namespace { + +// Fixed paradigm: the entry points are hard-coded to the three functions of +// package "main"; no renaming flags are accepted. +constexpr const char *kInitEntry = "main.init"; +constexpr const char *kUpdateEntry = "main.update"; +constexpr const char *kFiniEntry = "main.fini"; + +std::atomic g_exit_requested{false}; +std::atomic g_exit_code{0}; +bool ExitRequested() { + return g_exit_requested.load(std::memory_order_relaxed); +} + +// Lua-side exit hook: flua.set_exit([code]). It only flags the request and +// returns; the current update frame runs to completion. +void RegisterExitApi(State *s) { + RegisterNativeFunction(s, "flua.set_exit", 0, true, [](State *state, CVar *args, int n) -> CVar { + const int code = static_cast(GetNativeIntArg(state, args, n, 0, 0)); + g_exit_code.store(code, std::memory_order_relaxed); + g_exit_requested.store(true, std::memory_order_relaxed); + return NativeNil(state); + }); +} + +// The argument is either a single .lua file or a directory (every .lua inside +// is compiled, in filename order). +std::vector CollectLuaFiles(const std::string &path) { + namespace fs = std::filesystem; + std::error_code ec; + if (fs::is_directory(path, ec)) { + std::vector files; + for (const auto &entry: fs::directory_iterator(path, ec)) { + if (entry.is_regular_file() && entry.path().extension() == ".lua") { + files.push_back(entry.path().string()); + } + } + // Cross-file references resolve at runtime, so the order does not + // change semantics; sorting is only for reproducible builds. + std::sort(files.begin(), files.end()); + if (files.empty()) { + std::cerr << "flua: no .lua file under " << path << "\n"; + } + return files; + } + return { path }; +} + +[[noreturn]] void ReportMissingEntry(const char *name) { + std::cerr << "flua: entry '" << name << "' not found.\n" + << " flua always calls init / update / fini from package \"main\"; the script needs:\n" + << " package \"main\"\n" + << " function init() ... end\n" + << " function update() ... end\n" + << " function fini() ... end\n"; + std::exit(1); +} + +// Runs init -> update* -> fini and returns the process exit code. A failed init +// (non-zero return or exception) skips fini. +int RunLifecycle(State *s, JITType jit) { + int exit_code = 0; + + // Fixed paradigm: all three entries are mandatory (except update under + // --once). + if (!HasEntryFunction(s, jit, kInitEntry)) { + ReportMissingEntry(kInitEntry); + } + const bool loop = !FLAGS_once; + if (loop && !HasEntryFunction(s, jit, kUpdateEntry)) { + ReportMissingEntry(kUpdateEntry); + } + if (!HasEntryFunction(s, jit, kFiniEntry)) { + ReportMissingEntry(kFiniEntry); + } + + bool init_ok = false; + try { + int init_code = 0; + Call(s, jit, kInitEntry, init_code); + if (init_code != 0) { + exit_code = init_code; + std::cerr << "flua: init returned " << init_code << ", exit\n"; + } else { + init_ok = true; + } + } catch (const std::exception &e) { + std::cerr << "flua: init failed: " << e.what() << "\n"; + exit_code = 1; + } + + if (init_ok && loop) { + while (!ExitRequested()) { + int update_code = 0; + try { + Call(s, jit, kUpdateEntry, update_code); + } catch (const std::exception &e) { + std::cerr << "flua: update failed: " << e.what() << "\n"; + exit_code = 1; + break; + } + if (update_code != 0) { + exit_code = update_code; + break; + } + } + } + + // init succeeded -> run fini: cleanup is needed on update errors and on + // requested exits alike. + if (init_ok) { + try { + int fini_code = 0; + Call(s, jit, kFiniEntry, fini_code); + } catch (const std::exception &e) { + std::cerr << "flua: fini failed: " << e.what() << "\n"; + exit_code = 1; + } + } + return exit_code; +} + +} // namespace + int main(int argc, char **argv) { - gflags::SetUsageMessage("usage: ./flua --help\n" - "\n"); + gflags::SetUsageMessage("usage: ./flua [options]\n" + "\n" + "Fixed entry points, always package \"main\":\n" + " package \"main\"\n" + " function init() ... end called once at startup\n" + " function update() ... end called every frame\n" + " function fini() ... end called once before exit\n" + "\n" + "update is called back-to-back; the script paces itself if it needs\n" + "to (os.sleep takes milliseconds). --once runs init + fini only.\n" + "\n" + "Graceful exit, runs fini first:\n" + " return a non-zero code from update\n" + " call flua.set_exit(code) from Lua\n" + "\n" + "Signals are not handled by flua; install a handler in the application\n" + "if it needs to react to SIGINT/SIGTERM.\n"); gflags::SetVersionString("0.0.1"); gflags::ParseCommandLineFlags(&argc, (char ***) &argv, true); @@ -24,20 +208,21 @@ int main(int argc, char **argv) { const FakeluaStateGuard guard; const auto s = guard.GetState(); - // Lua 惯例:未捕获的运行时错误打印消息后以退出码 1 结束,而不是让 C++ - // 异常穿透到 libc++abi 触发 SIGABRT(退出码 134,FakeLua Phase16 Bug 4)。 + // Lua convention: an uncaught runtime error prints the message and exits + // with code 1, instead of letting the C++ exception escape into libc++abi + // and abort the process (exit code 134, FakeLua Phase16 Bug 4). + // No signal handling here on purpose: catching SIGINT/SIGTERM and deciding + // what to do with them is the application's business, not the host's. try { + RegisterExitApi(s); + CompileConfig cfg; cfg.debug_mode = FLAGS_debug; - CompileFile(s, argv[1], cfg); - - int code = 0; - for (int i = 0; i < FLAGS_repeat; i++) { - Call(s, static_cast(FLAGS_jit_type), FLAGS_entry, code); + for (const auto &file: CollectLuaFiles(argv[1])) { + CompileFile(s, file, cfg); } - std::cout << "code: " << code << std::endl; - return code; + return RunLifecycle(s, static_cast(FLAGS_jit_type)); } catch (const std::exception &e) { std::cerr << "flua: " << e.what() << std::endl; return 1; diff --git a/cmd/test.lua b/cmd/test.lua new file mode 100644 index 0000000..17f0ed9 --- /dev/null +++ b/cmd/test.lua @@ -0,0 +1,88 @@ +-- Drives flua itself from CI to assert the host's lifecycle contract: +-- init once -> update repeatedly -> fini once before exit. +-- +-- usage: flua cmd/test.lua (entry points are fixed: package "main") +-- +-- What it covers: +-- 1) call counts and order: init exactly once, update repeatedly, fini exactly +-- once and last; +-- 2) cross-frame state: update can be called again and again without losing +-- counters only because every call is its own top-level Call and the frame +-- arena is reset on entry -- the only thing that survives a frame is what +-- lives in container.map (C++ heap). File-level tables are constants, and a +-- temporary value assigned to a global at runtime dangles across frames, +-- which is why this script uses container.map throughout; +-- 3) flua.set_exit semantics: requesting the exit mid-frame lets the rest of +-- the current frame run, the next frame is not entered, and only then fini; +-- 4) the exit code is 0 (a failed assert errors out, so flua exits non-zero); +-- 5) pcall / error still behave across frame boundaries. +-- +-- Expected stdout (apart from fakelua's compile logs): +-- INIT UPDATE-1 UPDATE-2 UPDATE-3 SETEXIT AFTER-SET-EXIT FINI + +package "main" + +local st = container.map() + +local function get(k) + return st:get(k) or 0 +end + +local function bump(k) + local n = get(k) + 1 + st:set(k, n) + return n +end + +function init() + print("INIT") + assert(get("init") == 0, "init must not be called twice") + assert(get("update") == 0, "update must not have run before init finished") + bump("init") + -- pcall still catches errors across the frame boundary: error does not + -- escape into the host. + local ok = pcall(function() + error("this error is expected to be caught by pcall") + end) + assert(ok == false, "pcall should catch the error and return false") + -- Pretend init did some heavy one-off work (loading lookup tables, say) that + -- update then reuses. + st:set("prepared", 42) + return 0 +end + +function update() + local n = bump("update") + print("UPDATE-" .. tostring(n)) + + -- Values stored in container.map last frame are still there: the only + -- correct way to keep state across frames. + assert(st:get("prepared") == 42, "cross-frame state lost") + assert(get("init") == 1, "init must have been called exactly once") + assert(get("fini") == 0, "fini must not run while update is running") + + -- Frame-local values are only valid for this frame; they are reclaimed with + -- the arena when the frame ends. + local frame_only = {} + frame_only[n] = true + assert(frame_only[n] == true, "frame-local table must be usable within its frame") + + if n == 3 then + print("SETEXIT") + flua.set_exit(0) + -- Asking to exit does not abort the frame: this frame runs to the end and + -- only the next frame is skipped. + print("AFTER-SET-EXIT") + assert(frame_only[3] == true) + end + return 0 +end + +function fini() + print("FINI") + assert(get("init") == 1, "init should have run exactly once, got " .. tostring(get("init"))) + assert(get("update") == 3, "update should have run exactly 3 times, got " .. tostring(get("update"))) + assert(get("fini") == 0, "fini must not be called twice") + bump("fini") + return 0 +end diff --git a/include/fakelua.h b/include/fakelua.h index d5d5a09..6660db0 100644 --- a/include/fakelua.h +++ b/include/fakelua.h @@ -841,6 +841,25 @@ class NativeObject { friend CVar inter::NativeToFakeluaNativeObject(State *s, const NativeObject *obj); }; +// --------------------------------------------------------------------------- +// Host-side helpers for writing native callbacks and probing script entries. +// +// The kernel does all three through inter::, but that is an implementation detail +// of the interpreter and the JIT: hosts (flua, embedders) should not depend on it. +// --------------------------------------------------------------------------- + +// Argument idx read as an integer: int/float/bool/numeric string all work; nil or +// a non-convertible value yields default_val. +int64_t GetNativeIntArg(State *s, CVar *args, int n, int idx, int64_t default_val = 0); + +// The nil result to return from a native callback. +CVar NativeNil(State *s); + +// Whether the script defines an entry function called name: GetFuncAddr returns +// nullptr when it is missing (only Call turns that into an error), so this is a +// plain yes/no probe a host can run before invoking. +[[nodiscard]] bool HasEntryFunction(State *s, JITType type, const std::string_view &name); + // RegisterNativeFunction — 注册 C++ 函数供 lua 脚本调用 using NativeFuncCallback = std::function; using NativeVarFuncCallback = std::function &)>; diff --git a/src/state/fakelua.cpp b/src/state/fakelua.cpp index 00a5c8d..7ecf24f 100644 --- a/src/state/fakelua.cpp +++ b/src/state/fakelua.cpp @@ -665,4 +665,18 @@ CVar GetNativeArg(State *s, CVar *args, int n, int idx) { }// namespace inter +int64_t GetNativeIntArg(State *s, CVar *args, int n, int idx, int64_t default_val) { + return inter::CVarToInteger(inter::GetNativeArg(s, args, n, idx), default_val); +} + +CVar NativeNil(State *s) { + return inter::NativeToFakeluaNil(s); +} + +bool HasEntryFunction(State *s, JITType type, const std::string_view &name) { + int argc = 0; + bool is_vararg = false; + return inter::GetFuncAddr(s, type, name, argc, is_vararg) != nullptr; +} + }// namespace fakelua