Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions .agents/docs/2026-06-03-gl-runtime-closure-plan.md
Original file line number Diff line number Diff line change
@@ -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=<built-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.
16 changes: 16 additions & 0 deletions src/build/plan.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ struct BuildPlan {

std::vector<CompileUnit> compileUnits; // topologically sorted
std::vector<LinkUnit> linkUnits;
std::vector<std::filesystem::path> runtimeLibraryDirs;
};

// Build a BuildPlan from already-validated inputs.
Expand Down Expand Up @@ -166,6 +167,14 @@ local_include_dirs_for_manifest(const std::filesystem::path& root,
return dirs;
}

void append_unique_path(std::vector<std::filesystem::path>& 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,
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/cli.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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<mcpp::platform::env::ScopedEnv> 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;
}
Expand Down Expand Up @@ -4031,6 +4040,15 @@ int cmd_test(const mcpplibs::cmdline::ParsedArgs& /*parsed*/,
int passed = 0;
int failed = 0;
std::vector<std::string> failures;

std::optional<mcpp::platform::env::ScopedEnv> 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;
Expand Down
72 changes: 72 additions & 0 deletions src/manifest.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ struct BuildConfig {
std::string cStandard;
};

// `[runtime]` — requirements needed when launching built binaries.
struct RuntimeConfig {
std::vector<std::filesystem::path> libraryDirs; // relative to package root
std::vector<std::string> dlopenLibs; // runtime-loaded sonames
std::vector<std::string> capabilities; // host/system capabilities
};

// `[target.<triple>]` — per-target overrides.
// Picked up when caller passes --target <triple> to build/run/test.
struct TargetEntry {
Expand Down Expand Up @@ -182,6 +189,7 @@ struct Manifest {

Toolchain toolchain; // optional; empty == fallback
BuildConfig buildConfig;
RuntimeConfig runtimeConfig;

// [target.<triple>] tables — empty if user didn't declare any.
std::map<std::string, TargetEntry> targetOverrides;
Expand Down Expand Up @@ -779,6 +787,15 @@ std::expected<Manifest, ManifestError> 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;
Expand Down Expand Up @@ -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<std::string>& out)
-> std::expected<void, ManifestError>
{
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<std::string> 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();
Expand Down
99 changes: 99 additions & 0 deletions tests/e2e/62_runtime_library_dirs.sh
Original file line number Diff line number Diff line change
@@ -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 <dlfcn.h>

using answer_fn = int (*)();

int main() {
void* handle = dlopen("libruntime_plugin.so", RTLD_NOW);
if (!handle) {
return 10;
}
auto answer = reinterpret_cast<answer_fn>(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 <dlfcn.h>

using answer_fn = int (*)();

int main() {
void* handle = dlopen("libruntime_plugin.so", RTLD_NOW);
if (!handle) {
return 20;
}
auto answer = reinterpret_cast<answer_fn>(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"
Loading
Loading