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