diff --git a/.agents/docs/2026-06-03-gl-runtime-closure-plan.md b/.agents/docs/2026-06-03-gl-runtime-closure-plan.md new file mode 100644 index 0000000..34f133a --- /dev/null +++ b/.agents/docs/2026-06-03-gl-runtime-closure-plan.md @@ -0,0 +1,123 @@ +# mcpp: GL Runtime Closure Plan + +> 状态: active +> 分支: `codex/gl-runtime-closure-mcpp` +> PR: pending +> Last updated: 2026-06-03 +> 目标: 让 mcpp 以标准工具链方式表达、解析、诊断并注入运行时闭包,使 GLFW/OpenGL 这类通过 `dlopen` 加载的运行库不再依赖用户手写环境变量。 + +## Scope + +This repository owns the tool behavior. It should not hard-code one OpenGL +vendor or one package index workaround. The expected model is closer to +Conan/vcpkg run environments plus Nix-style runtime closure diagnostics: + +- package metadata can declare runtime library directories, `dlopen` library + names, and required system capabilities; +- dependency resolution carries runtime requirements separately from compile + includes and link flags; +- `mcpp run`, `mcpp test`, `mcpp doctor`, and `mcpp pack` consume the same + runtime model; +- missing system capabilities produce actionable errors before a user only sees + a failed GUI window. + +## Current Problem + +- `mcpp run` builds the selected binary and executes it directly. +- Build/link propagation covers link-time shared libraries, but `dlopen` + libraries such as `libGLX.so.0` and `libGL.so.1` do not appear in `DT_NEEDED`. +- `mcpp pack` already has runtime closure logic, but it is oriented around + loader-visible ELF dependencies. GLX/EGL/Mesa/vendor-driver cases need an + explicit runtime metadata path rather than guessing from one executable. + +## Proposed Manifest Surface + +Keep compile, link, and runtime requirements separate. Initial names can be +adjusted during implementation, but the semantics should remain stable: + +```toml +[runtime] +library_dirs = ["relative/or/generated/runtime/lib"] +dlopen_libs = ["libGLX.so.0", "libGL.so.1", "libGL.so"] +capabilities = ["x11.display", "opengl.glx.driver"] +``` + +For package descriptors coming from an index, the same data should be accepted +from the package `mcpp` table: + +```lua +mcpp = { + runtime = { + library_dirs = {"mcpp_generated/runtime/lib"}, + dlopen_libs = {"libGLX.so.0", "libGL.so.1", "libGL.so"}, + capabilities = {"x11.display", "opengl.glx.driver"}, + }, +} +``` + +Compatibility rule: packages that do not declare runtime metadata keep current +behavior. + +## Implementation Plan + +- [x] Create this repository-level plan checkpoint. +- [x] Add manifest/runtime metadata parsing and validation. + - Candidate files: `src/manifest.cppm`, manifest tests. + - Invalid entries should fail early: empty library name, absolute path in + package metadata unless explicitly allowed, duplicate capability strings. +- [x] Carry runtime requirements through the resolved package graph. + - Candidate files: dependency resolution and `PackageRoot`/graph structures. + - Runtime requirements must not be mixed into public include usage. +- [x] Teach `mcpp run` and `mcpp test` to build a run environment. + - Candidate file: `src/cli.cppm`. + - Done: `mcpp run` consumes resolved runtime library directories. + - Done: `mcpp test` uses the same runtime environment for test binaries. + - Linux: prepend resolved runtime directories to `LD_LIBRARY_PATH`. + - macOS: use `DYLD_LIBRARY_PATH` only for local tool execution where allowed, + otherwise prefer rpath/install-name behavior. + - Windows: prepend resolved runtime directories to `PATH`. +- [ ] Add runtime diagnostics. + - Candidate commands: `mcpp self doctor`, or a new target-aware runtime + doctor path if the existing command shape supports it. + - Diagnostics should list the target, the package that required the runtime + item, unresolved `dlopen` names, and missing capabilities. +- [ ] Extend `mcpp pack` to consume runtime metadata. + - Candidate file: `src/pack/pack.cppm`. + - `pack` should include declared runtime directories/files when the mode + requests a runnable bundle. + - Keep system capabilities explicit; do not silently bundle host GPU drivers + unless a package declares a redistributable runtime. +- [x] Add regression coverage with a small `dlopen` fixture. + - Test should prove that a library loaded only via `dlopen` is found through + mcpp runtime metadata during `mcpp run`. + - A second pack-oriented test should prove runtime metadata is represented in + the bundled executable environment. +- [ ] Update docs. + - Candidate files: `docs/02-pack-and-release.md`, + `docs/05-mcpp-toml.md`, README snippets if needed. + +## Verification + +- [x] `mcpp build` +- [x] `mcpp run -- --version` +- [x] `mcpp test` +- [ ] `MCPP= bash tests/e2e/run_all.sh` +- [x] Focused runtime metadata e2e for `dlopen` resolution +- [ ] Focused pack e2e for runtime metadata inclusion + +## PR / CI / Merge Notes + +- [x] Commit this plan as the first checkpoint. +- [ ] Open a PR with sanitized paths and no local machine details. +- [ ] Include a test plan in the PR body. +- [ ] Wait for Linux/macOS/Windows CI. +- [ ] Squash merge after required checks pass. + +## Cross-Repository Dependencies + +- `mcpp-index` can only fully validate `compat.glfw` GLX runtime metadata after + this repository supports runtime requirements in `mcpp run`. +- `imgui-m` should not own tool runtime behavior; it only consumes the fixed + behavior through its minimal window example. +- `xim-pkgindex` participates only after a released mcpp version is needed by + xlings or users. diff --git a/src/build/plan.cppm b/src/build/plan.cppm index b44fe3d..5d2adde 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -53,6 +53,7 @@ struct BuildPlan { std::vector compileUnits; // topologically sorted std::vector linkUnits; + std::vector runtimeLibraryDirs; }; // Build a BuildPlan from already-validated inputs. @@ -166,6 +167,14 @@ local_include_dirs_for_manifest(const std::filesystem::path& root, return dirs; } +void append_unique_path(std::vector& out, + std::filesystem::path path) +{ + if (path.empty()) return; + if (std::find(out.begin(), out.end(), path) == out.end()) + out.push_back(std::move(path)); +} + } // namespace BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, @@ -192,6 +201,13 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, plan.stdBmiPath = stdBmiPath; plan.stdObjectPath = stdObjectPath; + for (auto const& package : packages) { + for (auto const& dir : package.manifest.runtimeConfig.libraryDirs) { + append_unique_path(plan.runtimeLibraryDirs, + dir.is_absolute() ? dir : package.root / dir); + } + } + // 1a. Detect basename collisions (both cross-package AND intra-package: // ftxui ships dom/color.cpp + screen/color.cpp, for instance). // For colliding files the object path gets a per-unit prefix diff --git a/src/cli.cppm b/src/cli.cppm index ec83ad0..d7200a9 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -3496,6 +3496,15 @@ int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed, std::fflush(stdout); std::string cmd = mcpp::platform::shell::quote(exe.string()); for (auto& a : passthrough) cmd += " " + mcpp::platform::shell::quote(a); + + std::optional runtimeEnv; + auto runtimeEnvKey = mcpp::platform::env::runtime_library_path_key(); + auto runtimeEnvValue = mcpp::platform::env::prepend_path_list( + runtimeEnvKey, ctx->plan.runtimeLibraryDirs); + if (!runtimeEnvKey.empty() && !runtimeEnvValue.empty()) { + runtimeEnv.emplace(runtimeEnvKey, runtimeEnvValue); + } + int rc = std::system(cmd.c_str()); return mcpp::platform::process::extract_exit_code(rc) == 0 ? 0 : 1; } @@ -4031,6 +4040,15 @@ int cmd_test(const mcpplibs::cmdline::ParsedArgs& /*parsed*/, int passed = 0; int failed = 0; std::vector failures; + + std::optional runtimeEnv; + auto runtimeEnvKey = mcpp::platform::env::runtime_library_path_key(); + auto runtimeEnvValue = mcpp::platform::env::prepend_path_list( + runtimeEnvKey, ctx->plan.runtimeLibraryDirs); + if (!runtimeEnvKey.empty() && !runtimeEnvValue.empty()) { + runtimeEnv.emplace(runtimeEnvKey, runtimeEnvValue); + } + for (auto& lu : ctx->plan.linkUnits) { if (lu.kind != mcpp::build::LinkUnit::TestBinary) continue; auto exe = ctx->outputDir / lu.output; diff --git a/src/manifest.cppm b/src/manifest.cppm index 86424d7..85af6d9 100644 --- a/src/manifest.cppm +++ b/src/manifest.cppm @@ -104,6 +104,13 @@ struct BuildConfig { std::string cStandard; }; +// `[runtime]` — requirements needed when launching built binaries. +struct RuntimeConfig { + std::vector libraryDirs; // relative to package root + std::vector dlopenLibs; // runtime-loaded sonames + std::vector capabilities; // host/system capabilities +}; + // `[target.]` — per-target overrides. // Picked up when caller passes --target to build/run/test. struct TargetEntry { @@ -182,6 +189,7 @@ struct Manifest { Toolchain toolchain; // optional; empty == fallback BuildConfig buildConfig; + RuntimeConfig runtimeConfig; // [target.] tables — empty if user didn't declare any. std::map targetOverrides; @@ -779,6 +787,15 @@ std::expected parse_string(std::string_view content, } } + // [runtime] — launch-time requirements. + if (auto v = doc->get_string_array("runtime.library_dirs")) { + for (auto& s : *v) m.runtimeConfig.libraryDirs.emplace_back(s); + } + if (auto v = doc->get_string_array("runtime.dlopen_libs")) + m.runtimeConfig.dlopenLibs = *v; + if (auto v = doc->get_string_array("runtime.capabilities")) + m.runtimeConfig.capabilities = *v; + // [lib] — library root convention (cargo-style). if (auto v = doc->get_string("lib.path")) { m.lib.path = *v; @@ -1683,6 +1700,61 @@ synthesize_from_xpkg_lua(std::string_view luaContent, auto v = cur.read_string(); if (!v.empty()) m.buildConfig.cStandard = v; } + else if (key == "runtime") { + auto runtimeBody = cur.read_table_body(); + LuaCursor rc { runtimeBody }; + rc.skip_ws_and_comments(); + while (!rc.eof()) { + auto sub = rc.read_key(); + if (sub.empty()) { + rc.skip_ws_and_comments(); + if (rc.eof()) break; + ++rc.pos; + continue; + } + rc.skip_ws_and_comments(); + if (!rc.consume('=')) { + return std::unexpected(ManifestError{ + std::format("malformed runtime segment near key '{}'", sub), + m.sourcePath, 0, 0}); + } + rc.skip_ws_and_comments(); + auto read_string_list = [&](std::vector& out) + -> std::expected + { + if (!rc.consume('{')) { + return std::unexpected(ManifestError{ + std::format("expected '{{' after `runtime.{} =`", sub), + m.sourcePath, 0, 0}); + } + rc.skip_ws_and_comments(); + while (!rc.eof() && rc.peek() != '}') { + auto s = rc.read_string(); + if (!s.empty()) out.push_back(std::move(s)); + rc.skip_ws_and_comments(); + } + rc.consume('}'); + return {}; + }; + if (sub == "library_dirs") { + std::vector dirs; + if (auto r = read_string_list(dirs); !r) return std::unexpected(r.error()); + for (auto& d : dirs) m.runtimeConfig.libraryDirs.emplace_back(std::move(d)); + } else if (sub == "dlopen_libs") { + if (auto r = read_string_list(m.runtimeConfig.dlopenLibs); !r) + return std::unexpected(r.error()); + } else if (sub == "capabilities") { + if (auto r = read_string_list(m.runtimeConfig.capabilities); !r) + return std::unexpected(r.error()); + } else { + rc.skip_ws_and_comments(); + if (rc.peek() == '"' || rc.peek() == '\'') (void)rc.read_string(); + else if (rc.peek() == '{') rc.skip_table(); + else (void)rc.read_bareword(); + } + rc.skip_ws_and_comments(); + } + } else { // Unknown key — skip the value (string / bareword / table). cur.skip_ws_and_comments(); diff --git a/tests/e2e/62_runtime_library_dirs.sh b/tests/e2e/62_runtime_library_dirs.sh new file mode 100644 index 0000000..7a89c6b --- /dev/null +++ b/tests/e2e/62_runtime_library_dirs.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# requires: gcc unix-shell +# Runtime library directories declared in mcpp.toml must be visible to +# libraries loaded only through dlopen(), not just DT_NEEDED link deps. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +mkdir -p app/src app/tests app/runtime + +cat > app/runtime/plugin.c <<'EOF' +int runtime_plugin_answer(void) { + return 42; +} +EOF + +gcc -shared -fPIC app/runtime/plugin.c -o app/runtime/libruntime_plugin.so + +cat > app/src/main.cpp <<'EOF' +#include + +using answer_fn = int (*)(); + +int main() { + void* handle = dlopen("libruntime_plugin.so", RTLD_NOW); + if (!handle) { + return 10; + } + auto answer = reinterpret_cast(dlsym(handle, "runtime_plugin_answer")); + if (!answer) { + dlclose(handle); + return 11; + } + int result = answer(); + dlclose(handle); + return result == 42 ? 0 : 12; +} +EOF + +cat > app/tests/test_runtime_plugin.cpp <<'EOF' +#include + +using answer_fn = int (*)(); + +int main() { + void* handle = dlopen("libruntime_plugin.so", RTLD_NOW); + if (!handle) { + return 20; + } + auto answer = reinterpret_cast(dlsym(handle, "runtime_plugin_answer")); + if (!answer) { + dlclose(handle); + return 21; + } + int result = answer(); + dlclose(handle); + return result == 42 ? 0 : 22; +} +EOF + +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[build] +sources = ["src/*.cpp"] +ldflags = ["-ldl"] + +[runtime] +library_dirs = ["runtime"] + +[targets.app] +kind = "bin" +main = "src/main.cpp" +EOF + +cd app +"$MCPP" build > build.log 2>&1 || { + cat build.log + echo "build failed" + exit 1 +} + +"$MCPP" run > run.log 2>&1 || { + cat run.log + echo "run failed" + exit 1 +} + +"$MCPP" test > test.log 2>&1 || { + cat test.log + echo "test failed" + exit 1 +} + +echo "OK" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 0734f96..4025fb7 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -261,6 +261,29 @@ kind = "lib" EXPECT_EQ(m->buildConfig.cStandard, "c11"); } +TEST(Manifest, RuntimeConfig) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[runtime] +library_dirs = ["runtime/lib", "plugins"] +dlopen_libs = ["libGLX.so.0", "libGL.so.1"] +capabilities = ["x11.display", "opengl.glx.driver"] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->runtimeConfig.libraryDirs.size(), 2u); + EXPECT_EQ(m->runtimeConfig.libraryDirs[0], "runtime/lib"); + EXPECT_EQ(m->runtimeConfig.libraryDirs[1], "plugins"); + ASSERT_EQ(m->runtimeConfig.dlopenLibs.size(), 2u); + EXPECT_EQ(m->runtimeConfig.dlopenLibs[0], "libGLX.so.0"); + EXPECT_EQ(m->runtimeConfig.dlopenLibs[1], "libGL.so.1"); + ASSERT_EQ(m->runtimeConfig.capabilities.size(), 2u); + EXPECT_EQ(m->runtimeConfig.capabilities[0], "x11.display"); + EXPECT_EQ(m->runtimeConfig.capabilities[1], "opengl.glx.driver"); +} + TEST(SynthesizeFromXpkgLua, CflagsCxxflagsLdflagsAndCStandard) { constexpr auto src = R"( package = { @@ -291,6 +314,35 @@ package = { EXPECT_EQ(m->modules.sources[0], "*/src/*.c"); } +TEST(SynthesizeFromXpkgLua, RuntimeConfig) { + constexpr auto src = R"( +package = { + spec = "1", + name = "glfw", + xpm = { linux = { ["3.4"] = { url = "u", sha256 = "h" } } }, + mcpp = { + sources = { "*/src/context.c" }, + runtime = { + library_dirs = { "mcpp_generated/runtime/lib" }, + dlopen_libs = { "libGLX.so.0", "libGL.so.1" }, + capabilities = { "x11.display", "opengl.glx.driver" }, + }, + targets = { ["glfw"] = { kind = "lib" } }, + }, +} +)"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "glfw", "3.4"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->runtimeConfig.libraryDirs.size(), 1u); + EXPECT_EQ(m->runtimeConfig.libraryDirs[0], "mcpp_generated/runtime/lib"); + ASSERT_EQ(m->runtimeConfig.dlopenLibs.size(), 2u); + EXPECT_EQ(m->runtimeConfig.dlopenLibs[0], "libGLX.so.0"); + EXPECT_EQ(m->runtimeConfig.dlopenLibs[1], "libGL.so.1"); + ASSERT_EQ(m->runtimeConfig.capabilities.size(), 2u); + EXPECT_EQ(m->runtimeConfig.capabilities[0], "x11.display"); + EXPECT_EQ(m->runtimeConfig.capabilities[1], "opengl.glx.driver"); +} + TEST(SynthesizeFromXpkgLua, AppliesCurrentPlatformMcppOverlay) { constexpr auto src = R"( package = {