From 629e149a78006c8ccd05c7546630b0f399846618 Mon Sep 17 00:00:00 2001 From: xlings-ci Date: Mon, 5 Oct 2026 13:11:24 +0900 Subject: [PATCH 1/2] 2026.10.5.1: mcpp run hands the terminal to the program, and the follow-ups of #766 mcpp run, mcpp run -q --release and the named runners started the program in a process group of its own, a background group on a terminal: its first read stopped it with SIGTTIN, and Ctrl-C killed it instead of reaching its handler. On POSIX mcpp now replaces itself with the program; on Windows the program runs in mcpp's console and process group while mcpp ignores Ctrl-C. Closing notices are printed before the Running line. The follow-ups of #761, #763 and #765 recorded in #766: - A workspace member's executable and an artifact link the shared dependencies of the statics placed in their own image, and do not link the objects of a static placed in another package's image. - exports narrows the discovered symbols on the MSVC ABI; beside source declarations it is a warning; an empty export surface that a program of the build links fails at the .def step. - auto_export is renamed windows_auto_export before its first release, under the new SPEC-004 section 5.3; with exports it is refused on MSVC-ABI rows. - Build-program glob inputs use the walk of sources globs, report a pattern no walk can enter, match absolute patterns, and refuse a pattern that leaves a registry or git dependency. - run_all.sh bounds each test where GNU timeout is absent. SPEC-004 v1.11, SPEC-009 v0.2, docs 04, 09, 12 and 30 in both languages, and the design record .agents/docs/2026-10-05-run-terminal-handoff-and-766-follow-ups-design.md. --- ...minal-handoff-and-766-follow-ups-design.md | 426 ++++++++++++++++++ .agents/docs/README.md | 4 +- CHANGELOG.md | 71 +++ docs/04-mcpp-toml.md | 61 ++- docs/09-commands-by-scenario.md | 28 ++ docs/12-binary-distribution.md | 6 +- docs/30-build-mcpp.md | 26 +- docs/README.md | 2 +- docs/specs/README.md | 4 +- docs/specs/manifest-semantics.md | 25 +- docs/specs/toolchain-maintenance.md | 10 +- docs/zh/04-mcpp-toml.md | 44 +- docs/zh/09-commands-by-scenario.md | 18 + docs/zh/12-binary-distribution.md | 4 +- docs/zh/30-build-mcpp.md | 21 +- docs/zh/README.md | 2 +- mcpp.toml | 2 +- modules/buildmcpp/src/directives.cppm | 131 ++++-- modules/buildmcpp/tests/test_glob_inputs.cpp | 44 +- modules/manifest/src/glob.cppm | 114 +++++ modules/manifest/src/toml.cppm | 8 +- modules/manifest/src/types.cppm | 2 +- modules/manifest/src/xpkg.cppm | 6 +- modules/platform/src/process.cppm | 93 ++++ .../platform/src/unix/bounded_process.cppm | 12 + .../platform/src/windows/bounded_process.cppm | 69 +++ modules/versioning/src/version.cppm | 2 +- src/build/build_program.cppm | 22 + src/build/execute.cppm | 21 +- src/build/ninja_backend.cppm | 46 +- src/build/pe_exports.cppm | 166 +++++-- src/build/plan.cppm | 62 ++- src/build/prepare/features.cpp | 14 + src/cli.cppm | 2 + src/cli/cmd_build.cppm | 45 +- src/modgraph/scanner.cppm | 153 +------ ...executable_keeps_its_own_implementation.sh | 51 +++ ...881_pe_auto_exports_accept_llvm_bitcode.sh | 75 ++- tests/e2e/882_parent_directory_glob_inputs.sh | 64 +++ ...3_run_hands_the_terminal_to_the_program.sh | 176 ++++++++ tests/e2e/_timeout.py | 43 ++ tests/e2e/run_all.sh | 24 +- tests/unit/test_manifest.cpp | 16 +- tests/unit/test_ninja_backend.cpp | 38 +- tests/unit/test_pe_exports.cpp | 56 ++- 45 files changed, 1975 insertions(+), 334 deletions(-) create mode 100644 .agents/docs/2026-10-05-run-terminal-handoff-and-766-follow-ups-design.md create mode 100755 tests/e2e/883_run_hands_the_terminal_to_the_program.sh create mode 100755 tests/e2e/_timeout.py diff --git a/.agents/docs/2026-10-05-run-terminal-handoff-and-766-follow-ups-design.md b/.agents/docs/2026-10-05-run-terminal-handoff-and-766-follow-ups-design.md new file mode 100644 index 00000000..7b18b16d --- /dev/null +++ b/.agents/docs/2026-10-05-run-terminal-handoff-and-766-follow-ups-design.md @@ -0,0 +1,426 @@ +--- +subject: design +status: landed +--- + +# `mcpp run` hands the terminal to the program, and the follow-ups of #761, #763 and #765 (#766) + +- Date: 2026-10-05. Status: revision 3, implemented in mcpp 2026.10.5.1 (one pull request, §0.4). +- Base: `main` at `68e73108`, with #765 (`acd9578a`), #761 (`c313c024`) and #763 (`68e73108`) merged. +- Inputs: + - A report that an interactive program cannot read its input under `mcpp run` or `mcpp run -q --release`. + - Issue #766, which records the problems a combined review of #761, #763 and #765 found. + - Measurements on Linux x86_64 with a binary built from the three PRs merged together (`16366aa3`). + The Windows statements in Part I are inferred from the code and are marked as such. + +**Revision 2.** +- Review on 2026-10-05 answered revision 1's four open questions (§0.1). +- A self-review against the code then corrected several points in this record. §0.3 lists them. + +Reading order: +- Part I is the main design: `mcpp run` and the terminal. +- Part II designs each item of #766. +- Part III orders the work into pull requests. +- Part IV asks the questions that remain. + +## 0. Decisions + +### 0.1 Settled in review (2026-10-05) + +| # | Question | Decision | +|---|---|---| +| Q1 | After an exec, mcpp can print nothing once the program ends. Is that acceptable? | Yes. | +| Q2 | Does `mcpp run` exec without a terminal too (CI, pipes)? | Yes: one path for both. | +| Q3 | `exports` beside source annotations on PE | A warning, not a refusal. | +| Q4 | A registry or git dependency's glob that leaves its package | Refused. | + +### 0.2 Proposed, awaiting review + +| # | Question | Proposal | +|---|---|---| +| D1 | How does `mcpp run` start the program on POSIX? | Replace mcpp with the program (`execve`) once the build is done (§1.6). Q1 and Q2 accept its consequences. | +| D2 | How does `mcpp run` start the program on Windows? | Start it without a new process group and, except for batch files, without `cmd.exe`. Keep it in a kill-on-close job, and let mcpp ignore Ctrl-C while it waits (§1.6). | +| D3 | The name of #763's key | `windows_auto_export`, and a SPEC-004 rule for platform-scoped keys (§2.1). | +| D4 | `exports` on PE | Patterns filter the discovered candidates. Beside annotations, a warning (Q3). Beside `windows_auto_export = false`, refused when planning an MSVC-ABI row. An empty export surface is an error only when a consumer in the plan links the DLL (§2.2). | +| D5 | Glob inputs | One directory walk shared with the source scanner: directory symlinks followed with a cycle guard, and the scanner's exclusions. A glob leaving its package (`../` or absolute) is honoured for the root, path dependencies and workspace members, and refused for registry and git dependencies (Q4). §2.5. | + +### 0.3 What the self-review changed + +| Where | Revision 1 | Revision 2, and why | +|---|---|---| +| §1.6 closing notices | not considered | `mcpp::ui::print_closing_notices()` runs after every command (`src/cli.cppm`, end of `run`). After an exec it would never run, so the notices are printed before "Running". | +| §1.6 locks | "audit `FileLock`" | Audited: `FileLock` opens with `O_CLOEXEC` and both users (`xlings.cppm`, `bmi_cache.cppm`) hold it in a local scope. Nothing leaks across an exec. | +| §1.6 threads and children | not stated | The build's threads (live progress, `--play-game`) are joined and `KeyInput` is destroyed before the exec. The group guard registry must be empty, so no child of mcpp survives into the program. | +| §1.3 R2 | "the status is the program's own" | Also a behaviour change: today a signal death becomes exit status `128+n` from mcpp. After the exec, a caller sees the signal itself; for example, Python's `subprocess` reports `-n`. That is what the direct run gives, and the release notes say so. | +| §1.6 Windows | `CreateProcessW` without `cmd.exe` | A runner that is a `.bat` / `.cmd` file still needs `cmd.exe`, with the existing quoting rule. Starting a batch file through `CreateProcess` directly is the "BatBadBut" argument-injection shape. The environment travels as an environment block, not a `set` prefix. The job's Ctrl-C guard is not registered for this launch. | +| §2.2 rule 4 | refused at manifest load | A manifest is cross-platform, and `exports` beside `windows_auto_export = false` is valid on ELF and Mach-O. The refusal moves to planning an MSVC-ABI row. | +| §2.2 rule 5 | an empty PE export surface is always an error | Resource-only DLLs and DLLs loaded only for `DllMain` are legitimate. The error applies only when a consumer in the plan links the DLL. | +| §2.5 symlinks | warn when the prefix crosses a symlink | The source scanner *follows* directory symlinks with a canonical-path cycle guard (`walk_tree`, `src/modgraph/scanner.cppm`). The comment in `glob_fingerprint` that claims "the same rule the source scan uses" is false today. The fingerprint adopts the scanner's walk instead of warning. | +| §2.5 exclusion | exclude the output directory by path | The scanner also excludes by *name* (`is_excluded_walk_dir`: `.mcpp`, `.git`, `target`, and submodule paths). Parity means adopting that rule, so `../assets/target/**` stays excluded. Changing it would change `sources` globs too, and is not proposed. | +| §2.5 absolute patterns | refused | `rerun_if_changed` (one file) already accepts absolute paths. Absolute globs follow the same rule as `../` instead: matched as absolute paths where escaping is allowed, refused where it is not. | + +### 0.4 Revision 3: settled questions, and where the implementation departs + +Review on 2026-10-05 accepted D1 to D5 and answered Part IV: closing notices +are printed before `Running` (IV.1), the change from `128+n` to a visible signal +death ships without a switch (IV.2), and glob inputs follow directory symlinks +(IV.3). The implementation then departed from revision 2 where the code showed +a smaller or a correct alternative: + +| Where | Revision 2 | Implemented | +|---|---|---| +| §1.6 Windows | `CreateProcessW` on the program, an environment block, `cmd.exe` only for batch runners | The existing `cmd.exe` command line is kept: it is the one derivation of the quoting and the environment prefix every Windows launch uses, and the defect is the process group, not the shell. `winproc::run_foreground` starts it without `CREATE_NEW_PROCESS_GROUP`, in a kill-on-close job, and mcpp ignores Ctrl-C and Ctrl-Break through a handler of its own, which the child does not inherit. Replacing the shell remains possible and is not required by any requirement of §1.3. | +| §1.6 POSIX | resets every handled or ignored signal | mcpp ignores no signal (audited: no `SIG_IGN`, no signal mask), and `execve` resets handled ones, so the launcher restores the terminal guard and clears the group guard; a guarded group at that point is an internal error (exit 125). PATH is searched with mcpp's own PATH, as `posix_spawnp` did. | +| §1.6 docs | docs/08 states that a test does not read the terminal | Not stated: the test launcher inherits standard input, so the statement would be false. The test launcher is unchanged. | +| §2.2 rule 5 | `--required` when a unit of the plan consumes the import library | As designed; the backend decides it from the plan's units, so the plan carries no new field. | +| §2.3, §2.4 | two predicates | One predicate, `links_objects_of(package, owner)`, decides both a unit's objects and the packages whose shared dependencies it links, in the artifact and the member paths. | +| §2.5 | the fingerprint adopts the scanner's walk | The walk moved to `mcpp.modgraph.glob` (`walk_glob_tree`, `is_excluded_walk_dir`), which both the scanner and the fingerprint call; the submodule cache gained a lock, since build programs fingerprint on several threads. The build's output directory is excluded by name in addition. | +| §2.7 `coff-def` | one `llvm-nm` for all bitcode inputs | Each object's compiler and `llvm-nm` run concurrently, up to eight at a time, which keeps `read_nm_exports`' single-file grammar. | +| §2.7 PE executables | verify `.lib` / `.exp` on Windows | Settled from the code: `mcpp pack` stages the program by name and its runtime closure, never the contents of `bin/`, so such a file does not reach a package. The executable's own export table is documented in docs/04 as the program linking its package's objects. | +| §2.7 E2E 721 | a per-test bound in `run_all.sh` | The bound existed and needed GNU `timeout`, which the macOS runners lack; `tests/e2e/_timeout.py` supplies it there and ends the test's process tree. | + +Tests: E2E 883 (new) drives `mcpp run` through a pseudo-terminal and fails +against a binary without the change (`STATUS hung`). E2E 880 gained the placed +static variant, which fails before the change with the undefined reference. E2E +881 gained cases I to L for `exports` on PE, and E2E 882 the absolute pattern, +the unwatchable warning and the refusal in a git dependency. + +--- + +## Part I. `mcpp run` and the terminal + +### 1.1 Symptom + +```cpp +import std; +int main() { + std::string line; + while (std::cout << "> " << std::flush, std::getline(std::cin, line)) { + if (line == "quit") break; + std::cout << "read: " << line << std::endl; + } +} +``` + +Measured on Linux, with a pseudo-terminal driving the program the way a user's terminal does: + +| Invocation | Piped stdin | Terminal stdin | Ctrl-C (program with a `SIGINT` handler that exits 3) | +|---|---|---|---| +| the program directly | works | works | handler runs, prints, exit status 3 | +| `mcpp run` | works | **hangs** after `> `; typed text is echoed but never read | **handler never runs**; mcpp dies of `SIGINT` | +| `mcpp run -q --release` | works | **hangs** | same as above | + +Piped stdin works, which is why no E2E has seen this: every E2E runs without a terminal. + +### 1.2 Cause + +`process::run_exec` (`modules/platform/src/process.cppm`) starts every child in a process group of +its own (`POSIX_SPAWN_SETPGROUP`). The rule came with #555 (`9da7163f`, 2026.9.4.3), so that a +`timeout`-terminated `mcpp` does not leave ninja and its compilers running. Both `mcpp run` paths +start the user's program through the same function: the fast path at `src/build/execute.cppm:2212` +and the prepared path at `:2404`. + +On a terminal, the new group is a background group. `ps` while the program waits for input: + +``` + PID PPID PGID SID TPGID STAT COMMAND +36047 36045 36047 36047 36047 Ss+ mcpp +36051 36047 36051 36047 36047 T stdinrun +``` + +The program's group (36051) is not the terminal's foreground group (36047). Its first read is +answered with `SIGTTIN`, and it stops (`T`). Ctrl-C is delivered to the foreground group, that is to +mcpp alone. mcpp's signal guard then `SIGKILL`s the program's group, so the program's own handler +never runs. + +The repository already states the constraint. `capture_with_deadline`'s `ownGroup` is opt-in +"because a child in a background group that reads the terminal is stopped by SIGTTIN, and the +uncaptured callers of this function hand the terminal to their child" +(`modules/platform/src/unix/bounded_process.cppm`). `run_exec` applies the group unconditionally, +and `mcpp run` is exactly such an uncaptured caller. + +**Windows (inferred, not measured).** `run_exec` starts the child through `cmd.exe /d /s /c` with +`CREATE_NEW_PROCESS_GROUP` in a kill-on-close job. Console input is shared, so reading stdin +probably works. A process in a new group does not receive the console's Ctrl-C, however, so the same +"the program's handler never runs" applies. The implementation PR measures it (§1.7). + +### 1.3 Requirements + +| # | Requirement | +|---|---| +| R1 | The program owns the terminal as it would if started by the shell: line input, Ctrl-C, Ctrl-\\, and Ctrl-Z / `fg`. | +| R2 | The status a caller sees is the program's own, including death by a signal. This replaces today's `128+n` from mcpp (§0.3). | +| R3 | No process outlives mcpp (#555): `timeout mcpp run`, a closed terminal or a killed mcpp leaves nothing running. | +| R4 | A refused spawn is still classified and reported in the 125-127 band, with the ENOEXEC advice (#544). | +| R5 | Without a terminal (CI, pipes, `timeout`), output and exit status are unchanged, apart from R2. | + +### 1.4 What other tools do + +| Tool | How the program is started | +|---|---| +| a POSIX shell | `fork`; the child joins a new group and becomes the foreground group (`tcsetpgrp`); the shell waits with `WUNTRACED` and takes the terminal back. Full job control. | +| `cargo run` | POSIX: replaces itself with the program (`exec_replace`). Windows: spawns it in the same console and installs a Ctrl-C handler that ignores the event, then exits with the child's code. | +| `go run`, `npm run`, `system(3)` | spawn in the same process group; the parent ignores or relays `SIGINT` / `SIGQUIT` while waiting. | + +None of them puts the program in a background group. + +### 1.5 Options + +| | A. Replace mcpp (`execve`) | B. Same group, parent relays signals | C. Own group, terminal handed over | +|---|---|---|---| +| R1 terminal, Ctrl-C | yes, by construction | yes; Ctrl-Z stops both | yes, if Ctrl-Z / `fg` is reimplemented | +| R2 status | yes, the caller waits on the program itself | needs re-raising the signal in mcpp | needs re-raising | +| R3 nothing outlives | yes: there is no parent left to kill | only if mcpp relays `SIGTERM` / `SIGHUP` to the child | yes, group guard kept | +| R4 spawn failure | yes: `execve` fails in mcpp, which can still classify the errno | yes | yes | +| Implementation | small, but mcpp must leave a clean process (§1.6) | small | `posix_spawn` cannot hand the terminal over in the child: glibc 2.35+ has `posix_spawn_file_actions_addtcsetpgrp_np`, macOS has nothing, so it needs `fork`; plus `WUNTRACED` stop/continue handling. It amounts to writing a job-control shell. | + +C is rejected. B remains the fallback for a future path that must run code after the program, for +example a runner that needs cleanup. A is the design, as it is cargo's, and Q1 and Q2 accept its +consequences. + +### 1.6 Design + +**POSIX: `process::exec_program(argv, env) -> errno`.** It returns only when `execve` fails. Before +the call, mcpp leaves a process the program can own, in this order: + +1. **Finish mcpp's own work.** Join the build's threads: the live progress line and `--play-game`. + Destroy `KeyInput`. Every build-scoped child (ninja, `[hooks] during_build`) has been reaped; the + group guard registry must be empty, and a non-empty registry is an internal error rather than a + silent orphan. +2. **Print what would have come after.** Call `mcpp::ui::print_closing_notices()`. The notices then + appear before "Running `…`" instead of after the program's output. +3. **Restore the terminal** (`unguard_terminal_mode`) and flush `stdout` / `stderr`. +4. **Reset signal state.** Restore `SIG_DFL` for every signal mcpp handled or ignored; `exec` + resets handled signals but keeps ignored ones. Empty the signal mask. +5. **Descriptors.** Nothing to do: `FileLock` and every internal pipe are close-on-exec (§0.3). + The implementation adds a debug assertion that only descriptors 0-2 survive. +6. **`execve`** with the merged environment (`merged_environ`). When a runner is selected, its argv + is exec'd the same way. + +On failure, the errno goes to the existing classification (`runner_lookup::classify`, +`unrunnable_message`, `launcher_status`), so R4 holds. Destructors and `atexit` handlers do not run +after a successful exec. Steps 1 and 2 are where anything mcpp does at exit must move, and the +implementation PR lists what it moved. + +**Windows: `process::run_foreground(argv, env)`.** +- `CreateProcessW` on the program itself, with the merged environment as an environment block. + A `.bat` / `.cmd` runner keeps going through `cmd.exe` with the existing quoting rule. Passing a + batch file to `CreateProcess` directly is the "BatBadBut" argument-injection shape. +- No `CREATE_NEW_PROCESS_GROUP`, so the program receives Ctrl-C and Ctrl-Break. +- Keep the kill-on-close job: it makes R3 hold even when mcpp is killed outright. Do not register + the job's Ctrl-C guard for this launch, because it would kill the program on the Ctrl-C the + program is meant to handle. +- While waiting, mcpp's console control handler returns `TRUE` for Ctrl-C and Ctrl-Break, so mcpp + survives to report the program's status. This is cargo's Windows behaviour. +- Closing notices are printed before the launch, as on POSIX, so that output order does not depend + on the platform. +- Exit with the program's code. + +**Scope.** Only the two `mcpp run` call sites change. `run_exec` keeps its group for every other +caller: the analyser actions in `src/cli.cppm:1257` run under ninja and do not own a terminal. +`mcpp test` keeps its deadline launcher; a test does not read the terminal, and docs/08 says so. + +**Docs.** docs/09 (commands by scenario) states that `mcpp run` replaces itself with the program on +POSIX. It also states the R2 change: a signal death is visible as such. The release notes repeat +both. + +### 1.7 Tests + +A new E2E, `# requires: python3`, POSIX hosts only, drives `mcpp run` through `pty`. The pty driver +already written for this record is the template. + +| Case | Expectation | +|---|---| +| line input under a terminal, fast path and prepared path (`run`, `run -q --release`) | `read: hello` then exit 0 | +| Ctrl-C with a program `SIGINT` handler | handler output, status 3 | +| a program killed by `SIGABRT` | the caller-visible status equals the direct run's | +| `timeout -s TERM 2 mcpp run` on a program that never exits | no process of the run remains | +| piped stdin, no terminal | unchanged | +| a runner (`[runners]` template) | the runner owns the terminal the same way | +| a closing notice raised during the build | printed before "Running" | + +Windows: an E2E for piped stdin, the exit code and a `.cmd` runner with an argument containing +`&` and `"`. Ctrl-C is measured by hand and recorded in the PR, because a test cannot generate a +console Ctrl-C for a process in its own console without also interrupting itself. + +--- + +## Part II. The follow-ups of #766 + +### 2.1 P0: `auto_export` becomes `windows_auto_export`, and the rule is written down + +Every target key whose effect exists on one platform only already carries that platform's name: +`windows_subsystem`, `windows_entry`, `windows_code_page`. `auto_export`, added by #763, has an +effect only on MSVC-ABI PE shared libraries, but its name reads as cross-platform. SPEC-004 §5.2 +forbids renaming a key once it is in a released descriptor, so the name is decided before the next +release. + +- Add SPEC-004 §5.3: *A key whose effect exists on one platform or ABI carries that platform's + prefix (`windows_`, …). A neutral name is reserved for a key with a meaning on every row; such a + key either renders on every row or is refused where it cannot.* +- Rename in the TOML and xpkg readers, the unit tests, E2E 881 and docs/04 (en/zh). Because the key + is unreleased, no alias is kept. +- Replace "(unreleased)" with the release's `(mcpp X.Y.Z+)`. + +A cross-platform meaning, for example "`auto_export = false` means default-hidden visibility +everywhere", is a different feature with a compile-side effect. It is not proposed here. + +### 2.2 P0: `exports` on PE, and the precedence of the export sources + +Today `exports` has no effect on PE: +- `exports_flag` returns `""` for Windows. +- `coff-def` receives no patterns. +- The generated `obj/.exports.gen` for Windows even contains an ELF version script that nothing + reads. + +The docs, before and after #763, say otherwise. + +The rule for an MSVC-ABI DLL, which docs/04 states and SPEC-004 records: + +1. **Annotations are authoritative.** If any input declares exports (`dllexport`, `/EXPORT:` + directives, linker-option metadata), the DLL publishes exactly those. The `.def` is empty, as + today. `exports` beside annotations cannot narrow them, because the linker reads object + directives regardless of the `.def`. mcpp reports the combination as a **warning** that names an + annotated object (Q3). +2. **Otherwise `exports` narrows discovery.** The candidates (COFF reader, or `llvm-nm` for bitcode) + are filtered by the patterns with the ELF version script's glob semantics (`*`, `?`, `[…]`). The + filtered list is the `.def`. + - Patterns match the linker-level name: undecorated on i386, as `export_name` already spells it. + - C names (`vk_icd*`) are portable across platforms. C++ patterns are not, because MSVC and + Itanium mangle differently. docs/04 says so. +3. **Otherwise everything discovered is published**, as today. +4. **`windows_auto_export = false` with `exports` is refused when planning an MSVC-ABI row**, since + there are no candidates to narrow. The message points to annotations. The manifest itself is + valid: the same pair is meaningful on ELF and Mach-O. +5. **An empty export surface is an error only when a consumer in the plan links the DLL.** + - An empty surface produces no import library, and a consumer would fail far from the cause. + - The plan passes `--required` to that DLL's `coff-def` edge when a link unit of the plan + consumes it. + - `coff-def` then fails on an empty surface with no annotations. The message names the target + and the three ways to export: annotations, `exports`, discovery. + - Without a consumer, a resource-only or `DllMain`-only DLL stays valid. + +Implementation: +- Write `obj/.exports.gen` for Windows as one pattern per line, and pass it to `coff-def` as + `--exports-file`. +- Add a Windows E2E that covers rules 1 (warning), 2, 4 and 5 (with and without a consumer). + +### 2.3 P1: the shared dependencies of statics placed in the owner's own image (#761 follow-up) + +Both #761 paths link the objects of static packages that `place_static_packages` assigned to the +owner's own image, but collect shared links only from packages outside `placedInImage`: +- workspace members: `src/build/plan.cppm` near line 3179; +- artifacts: near line 2937. + +A static dependency's own shared dependency is then missing. This is measured: E2E 880 with +`common → support` instead of `dual → support` fails with `undefined reference to delta@t880_support()`. + +Fix: in both loops, treat `staticsByImagePackage[owner]` as the owner: + +```cpp +const auto ownImage = [&](std::size_t i) { + auto it = staticsByImagePackage.find(ownerIndex); + return it != staticsByImagePackage.end() && std::ranges::contains(it->second, i); +}; +if (i == ownerIndex || ownImage(i) || (!sharedDepPackages.contains(q) && !placedInImage.contains(q))) + append_direct_shared_deps(lu, i); +``` + +Then extend E2E 880 with the variant. + +### 2.4 P1: consumers link statics that belong to another image + +In the same variant, `client` links `common`'s objects directly and also `libdual_dll`, which +already contains them. This was measured as failing on `main` and with #761. The member and +artifact object loops must skip a `placedInImage` package unless its image is the unit's own (§2.3). +The root's loops already do. The image's shared library reaches the link through +`append_direct_shared_deps` of the package that depends on it. Placement is computed once for the +whole graph, from the plan's root, whose edges include every member. A static package that a member +also reaches directly is therefore a placement conflict, never placed, so skipping placed packages +cannot drop a direct dependency. + +Test: a consumer of a shared package whose static dependency has an external shared dependency, +built and run on all three binary formats. + +### 2.5 P1/P2: glob inputs: one walk, and a boundary (#765 follow-up) + +**One walk shared with the source scanner.** +- `glob_fingerprint` (`modules/buildmcpp/src/directives.cppm`) walks with its own rules: it does not + follow directory symlinks, and it excludes any directory *named* like the output directory. +- The source scanner (`walk_tree`, `src/modgraph/scanner.cppm`) follows directory symlinks + ("vendored trees are often symlink farms") with a canonical-path cycle guard. It excludes + directories named `.mcpp`, `.git` or `target`, and the repository's submodule paths + (`is_excluded_walk_dir`). +- The fingerprint's comment claims the two are the same rule. They are not, and #765 carried the + difference into its new prefix check. +- The fingerprint therefore adopts the scanner's walk. Directory symlinks, in the prefix and below + it, are followed with the cycle guard. The scanner's exclusions apply unchanged. +- One known limitation is shared, not fixed: a directory named `target` is excluded wherever it + is, so `../assets/target/**` matches nothing. The full-exclusion warning below makes that visible. +- #765's unit case `DirectorySymlinksAreNotFollowedThroughTheLiteralPrefix` is inverted accordingly. + +**A glob that is excluded in full is reported.** If a component of the literal prefix is an +excluded directory (`target`, `.git`, `.mcpp`, a submodule), the glob can never match. mcpp warns once per build, naming the +pattern. For `.git/HEAD`-style needs, the warning names `rerun_if_changed`, which watches one file's +contents. + +**Boundary (Q4).** +- A pattern that leaves its package, either `../…` or absolute, is honoured when the package is the + root, a path dependency or a workspace member. Their surroundings are the user's tree. +- An absolute pattern is matched against absolute paths. Today it is walked but can never match, + because matching is relative to the package root. +- In a registry or git dependency, the same pattern is **refused** when the directive is read, and + the message names the package and the pattern. There, `../` resolves into the package store, whose + contents depend on what else is installed. +- `mcpp pack` and publish warn about a pattern that leaves the package, since it would be refused + once the package is consumed from a registry. +- docs/30 states the boundary, and advises a specific literal prefix: `../../**` walks a large tree + on every fast-path check. + +### 2.6 P2: LLVM IR text as an interface (#763) + +Bitcode export intent is read from `clang -S -emit-llvm`. If the text format changes, annotations +stop being recognised and discovery publishes more than intended. Record in SPEC-009 (toolchain +specification) that a change of the Windows LLVM pin requires E2E 881 green on that pin. No code +change. + +### 2.7 P3 + +- **`coff-def` with LTO.** One `llvm-nm` for all bitcode inputs (it accepts many files and prints a + header per file), and the intent check run concurrently, bounded by the job count. +- **docs/04 or docs/07: an executable and its own DLL.** The executable links its package's objects + (compile-once model, as Cargo's bin links the rlib). A host that must load its own DLL is two + workspace members. A program that also loads a plugin built against that DLL holds two copies of + its state. +- **A PE executable that exports annotated symbols.** Verify on Windows whether linking `dllexport` + objects into an EXE writes `.lib` / `.exp` into `bin/`. If it does, `mcpp pack` stages only + declared products, and docs/04 notes it. +- **SPEC-004 §1 and §6** cite "docs/05 Appendix A (Schema Ownership Principle)", which no longer + exists. Point them to the current location or inline the rule. +- **E2E 721 on macOS** hung until the 20-minute step limit. Give it a per-test bound in `run_all.sh` + so that one hang fails one test, not the shard and the coverage job behind it. + +--- + +## Part III. Order of work + +| PR | Content | Why this order | +|---|---|---| +| 1 | Part I: `mcpp run` owns the terminal (POSIX exec, Windows foreground), E2E, docs/09 | User-visible, independent, and the cause is known | +| 2 | §2.1: `windows_auto_export` and SPEC-004 §5.3 | Must land before the next release (§5.2) | +| 3 | §2.2: `exports` on PE, precedence, warning, refusal, `--required`, Windows E2E | Builds on 2 | +| 4 | §2.3 and §2.4: placement-aware member and artifact links, E2E 880 variants | Same loops; one review | +| 5 | §2.5: shared walk, full-exclusion warning, boundary and refusal, docs/30 | Independent | +| 6 | §2.6 and §2.7: spec notes, `coff-def` batching, docs, E2E 721 bound | Independent; can be split | + +PRs 1, 2, 4 and 5 can proceed in parallel. Each closes its items in #766. + +## Part IV. Open questions + +1. §1.6 step 2: closing notices before "Running" (proposed), or dropped for `mcpp run`? +2. §0.3, R2: is the change from `128+n` to a visible signal death acceptable without a + compatibility switch? The proposal: yes, because it matches the direct run, and the change is + announced. +3. §2.5: following directory symlinks in glob inputs also changes which files existing patterns + inside the package match. The proposal accepts that, for parity with `sources` globs. The + alternative is to keep not following them and correct the false comment instead. diff --git a/.agents/docs/README.md b/.agents/docs/README.md index f9ace8e8..53c1c7b7 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -324 records. +325 records. ## By subject @@ -30,6 +30,7 @@ Records that declare one. Everything else is listed by date below. ### design +- [`mcpp run` hands the terminal to the program, and the follow-ups of #761, #763 and #765 (#766)](2026-10-05-run-terminal-handoff-and-766-follow-ups-design.md) — landed - [PR CI acceleration and the toolchain specification (#756, #757, #669)](2026-10-02-pr-ci-acceleration-and-the-toolchain-specification-design.md) — active - [工具与工具链的来源:声明、编程决定、可观察](2026-10-01-tool-and-toolchain-sources-design.md) — landed - [A pack's build reported as a build, and a unit's compile independent of the member selection: triage and design (#753, #751)](2026-10-01-pack-drive-and-selection-independent-compile-design.md) — landed @@ -115,6 +116,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-10 +- [`mcpp run` hands the terminal to the program, and the follow-ups of #761, #763 and #765 (#766)](2026-10-05-run-terminal-handoff-and-766-follow-ups-design.md) — landed - [PR CI acceleration and the toolchain specification (#756, #757, #669)](2026-10-02-pr-ci-acceleration-and-the-toolchain-specification-design.md) — active - [工具与工具链的来源:声明、编程决定、可观察](2026-10-01-tool-and-toolchain-sources-design.md) — landed - [A pack's build reported as a build, and a unit's compile independent of the member selection: triage and design (#753, #751)](2026-10-01-pack-drive-and-selection-independent-compile-design.md) — landed diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f8ba2bf..33d6a8d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,77 @@ > Each `## []` section is that release's notes. Entries are written in English > from 2026.9.28.3 on; earlier entries remain as written. +## [2026.10.5.1] - 2026-10-05 + +This release gives the program that `mcpp run` starts the terminal, and closes +the follow-ups of 2026.10.3.1's three fixes (#761, #763, #765) recorded in +mcpp#766: the shared dependencies of statics placed in a program's own image, +`exports` on the MSVC ABI, and the walk and boundary of build-program glob +inputs. The key added by #763 is renamed before its first release +(`windows_auto_export`). No default toolchain changes. + +### Fixed + +- **`mcpp run` hands the terminal to the program.** The program started by + `mcpp run`, `mcpp run -q --release` or a named runner ran in a process group + of its own, which on a terminal is a background group: its first read of the + terminal stopped it with `SIGTTIN`, and Ctrl-C reached mcpp, which killed the + program instead of letting its handler run. On Linux and macOS mcpp now + replaces itself with the program once the build is done (`execve`). The + program reads the terminal, receives Ctrl-C, Ctrl-\ and Ctrl-Z, and runs with + the process id the shell started, so a `timeout` or a closed terminal reaches + it directly and nothing of mcpp remains. On Windows the program runs in mcpp's + console and process group, and mcpp ignores Ctrl-C while it waits. A program + that ends by a signal is seen by the caller as ending by that signal, as in a + direct run; before, mcpp exited with `128+n`. Notices concerning the whole + command (`tip:` lines) are printed before the `Running` line. E2E 883 drives + `mcpp run` through a pseudo-terminal. +- **A program links the shared dependencies of the statics placed in its own + image.** A workspace member's executable, or an artifact, links the objects of + a static package that is placed in its own package's shared image, and now + also links that package's shared dependencies; before, the link failed with an + undefined reference. A program does not link the objects of a static placed in + another package's image, which that image's library supplies. +- **`exports` takes effect on the MSVC ABI.** The `.def` of a DLL holds the + discovered symbols that match a pattern; before, the patterns were ignored on + PE. Source declarations (`__declspec(dllexport)`, `/EXPORT:`, the same in + LLVM bitcode) still decide the export set when present, and `exports` beside + them is reported as a warning. A DLL that exports nothing and is linked by a + program of the same build fails at its `.def` step with a message naming it, + rather than at the consumer as a missing import library. +- **Build-program glob inputs use the walk of `sources` globs.** Directory + symlinks are followed with the cycle guard, and the same directories are + excluded. A pattern whose literal prefix passes through an excluded directory + is reported as a warning, since it can never re-run the program. + +### Changed + +- **`[targets.] auto_export` is renamed `windows_auto_export`.** The key + added by #763 affects only MSVC-ABI DLLs, and SPEC-004 §5.3 now states that a + key with an effect on one platform carries that platform's prefix. The key was + not in a release, so no alias is kept. `windows_auto_export = false` together + with `exports` is refused when an MSVC-ABI target is planned. +- **Glob inputs that leave the package.** An absolute `rerun_if_changed_glob` + pattern is matched against absolute paths. A pattern that leaves the package, + by `..` or as an absolute path, is an error in a registry or git dependency, + whose tree is the package store's; it is honoured for the project, a path + dependency and a workspace member. +- **The `.def` step runs its LLVM tools concurrently**, one compiler and one + `llvm-nm` invocation per bitcode object, up to eight at a time. + +### Specifications + +- SPEC-004 v1.11: §5.3, platform-scoped keys carry the platform's prefix. The + field admission criteria are referred to docs/90. +- SPEC-009 v0.2: §10.5 gate G7, E2E 881 passes with a candidate LLVM release on + the MSVC-ABI rows. + +### Repository CI + +- `tests/e2e/run_all.sh` bounds each test on hosts without GNU `timeout` + (the macOS runners) with `tests/e2e/_timeout.py`, which ends the test's whole + process tree. One hung test no longer consumes the shard's step budget. + ## [2026.10.3.1] - 2026-10-03 This release is identical to 2026.10.2.1 in code; the bump exists to publish a diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index 8cfe6a14..e5be0390 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -210,6 +210,12 @@ the loader opens and the import library the linker consumes, with the export list generated from the objects on the MSVC ABI (which exports nothing without `__declspec(dllexport)` or a `.def`). See `tests/e2e/08`, `257` and `259`. +A package with a `shared` target and `bin` targets links each executable from +its own objects, as a separate program: the executable does not load the +package's own shared library. A program that loads a package's shared library +is a program of another package, for example another member of the same +workspace ([07 — Workspaces](07-workspace.md)). + #### `kind = "app"` — the thing a user launches (mcpp 2026.9.12.3+) ```toml @@ -265,8 +271,8 @@ exports = "abi/mydriver.exports" # or inline: exports = ["vk_icd*"] Omitting `exports` leaves ELF and Mach-O's native visibility rules in effect. On the MSVC ABI, mcpp discovers exportable external definitions unless an input -already declares exports or `auto_export = false` disables discovery. `exports` -narrows the linker's published set. +already declares exports or `windows_auto_export = false` disables discovery. +`exports` narrows the linker's published set. Two projects need the narrowing. A **runtime with a stable ABI** publishes a reviewed set and nothing else, so that what is not in the set stays free to @@ -284,7 +290,7 @@ One statement, three renderings: |---|---| | ELF | a version script, `-Wl,--version-script=` | | Mach-O | `-Wl,-exported_symbols_list` (the leading underscore is supplied by the engine) | -| PE | the `.def`, replacing the auto-generated all-exports one | +| PE (MSVC ABI) | the `.def`: the discovered symbols that match a pattern | **It does not change compile-time visibility, and that is deliberate.** The narrowing is a link-time property on all three formats, so one key has one @@ -301,30 +307,45 @@ A `soname` is meaningful on `kind = "lib"` too — see [`dependency_linkage`](#dependency_linkage--static-or-shared-is-the-consumers-decision) below, where the form a library takes becomes the consumer's decision. -#### `auto_export` — native export control on the MSVC ABI (unreleased) +On the MSVC ABI the three sources of a DLL's export set apply in this order: + +1. **Declarations in the sources.** When any object declares exports + (`__declspec(dllexport)`, `#pragma comment(linker, "/EXPORT:...")`, or the + same in LLVM bitcode), the DLL publishes exactly those. `exports` beside such + a declaration has no effect, and the build states this as a warning that + names the object. +2. **`exports`.** Otherwise the discovered symbols whose name matches a pattern + are published. A pattern matches the linker's name of the symbol: + undecorated on 32-bit x86, and MSVC-mangled for C++. A pattern written for a + C name is therefore portable across the three formats; one written for a + mangled C++ name is not. +3. **Discovery.** Otherwise every exportable definition is published. + +A DLL that publishes nothing has no import library. When a program of the same +build links such a DLL, its `.def` step fails and names the DLL; a DLL that no +program links, such as a resource-only DLL, builds. -The key and LLVM bitcode discovery require an unreleased source build; they are -not available in mcpp 2026.10.3.1. A target that supplies its own export control -can omit the automatic export-discovery step: +#### `windows_auto_export` — export discovery on the MSVC ABI (mcpp 2026.10.5.1+) ```toml [targets.plugin] kind = "shared" -auto_export = false +windows_auto_export = false ``` -The boolean defaults to `true` and applies only to PE shared libraries on the -MSVC ABI, including clang and clang-cl. It applies when a library target is -built as a dependency too. It has no effect on static libraries, executables, -ELF, Mach-O or MinGW. Native `__declspec(dllexport)`, linker flags and explicit -`exports` lists remain effective when discovery is disabled. - -With discovery enabled, any input's explicit export intent suppresses automatic -exports for the whole DLL. COFF directives are checked first. LLVM bitcode is -inspected with the selected LLVM compiler, including `dllexport` declarations -and linker-option metadata. Only an unannotated DLL needs candidate enumeration; -bitcode candidates come from `llvm-nm` beside that compiler. Both FullLTO and -ThinLTO inputs can be mixed with ordinary COFF objects. +The boolean defaults to `true`. `false` removes the discovery step, and the +DLL publishes what its sources and `[build] ldflags` declare. It applies to PE +shared libraries on the MSVC ABI, including clang and clang-cl, also when the +target is built as a dependency, and renders nothing on static libraries, +executables, ELF, Mach-O and MinGW. + +`windows_auto_export = false` together with `exports` is refused when an +MSVC-ABI target is planned, since `exports` narrows the discovered symbols; the +same manifest builds on ELF and Mach-O. + +Discovery reads COFF objects directly and LLVM bitcode (FullLTO and ThinLTO, +alone or mixed with COFF objects) with the selected LLVM compiler and the +`llvm-nm` beside it. #### `windows_subsystem` and `windows_entry` — a Windows GUI executable (mcpp 2026.9.12.2+) diff --git a/docs/09-commands-by-scenario.md b/docs/09-commands-by-scenario.md index 6b64d59f..282587d3 100644 --- a/docs/09-commands-by-scenario.md +++ b/docs/09-commands-by-scenario.md @@ -426,6 +426,34 @@ test` and `mcpp pack` keep their statuses. A program, or a runner failed build; [50 — Machine-Readable Output](50-machine-output.md) §6 gives the bands. +### The program and the terminal *(2026.10.5.1+)* + +The program `mcpp run` starts owns the terminal as it would if the shell had +started it: it reads the terminal's input, and Ctrl-C, Ctrl-\ and Ctrl-Z reach +the program, which decides what they mean. The same holds for a runner and for +the named runners (`--runner`). + +- On Linux and macOS, `mcpp` is replaced by the program once the build is done. + The program runs with the process id the shell started, and nothing of `mcpp` + remains: a `timeout` or a closed terminal reaches the program directly. +- A program that ends by a signal is seen by the caller as ending by that + signal, as in a direct run: a shell reports `128+n`, and Python's + `subprocess` reports `-n`. +- On Windows, the program runs in the same console and process group, and + `mcpp` returns its exit code. +- A notice concerning the whole command (a `tip:` line, for example an index + that requires a newer mcpp) is printed before the `Running` line, since no + output of `mcpp` follows the program. + +```console +$ mcpp run -q +> hello +read: hello +> ^C # the program's SIGINT handler runs +$ echo $? +3 # the status the handler returned +``` + ## Validating a descriptor before publishing `mcpp xpkg parse` reads a descriptor with the resolver's own grammar, so what diff --git a/docs/12-binary-distribution.md b/docs/12-binary-distribution.md index cd01f31c..f6ea80c6 100644 --- a/docs/12-binary-distribution.md +++ b/docs/12-binary-distribution.md @@ -390,8 +390,10 @@ makes mcpp write an empty `EXPORTS` section. Adding a list on top would export t same names twice (`LNK4197`) and export everything else besides, replacing a chosen public surface with all of it. Bitcode's `dllexport` storage class and linker-option metadata express the same intent. Export intent is checked before -candidate enumeration. The per-target [`auto_export`](04-mcpp-toml.md#auto_export--native-export-control-on-the-msvc-abi-unreleased) -key disables discovery entirely (unreleased source builds). +candidate enumeration. The per-target +[`windows_auto_export`](04-mcpp-toml.md#windows_auto_export--export-discovery-on-the-msvc-abi-mcpp-20261051) +key disables discovery, and [`exports`](04-mcpp-toml.md#exports--the-artifacts-published-symbol-set-mcpp-2026965) +narrows what it finds. Past 65535 exportable symbols mcpp refuses rather than truncating. A truncated export table links cleanly and then fails at whichever consumer needed the symbol diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index 10a92538..dd1caffe 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -560,10 +560,10 @@ int main() { ``` The pattern is relative to the manifest directory and uses the same `*` / `**` -grammar as `sources = [...]`. A literal directory prefix can leave the package: -`../inputs/**/*.in` watches a sibling directory, including its creation after -the first build. Output and `.git` directories remain excluded, and directory -symlinks are not followed. Its fingerprint is the **sorted set of matching +grammar as `sources = [...]`, over the same walk: directory symlinks are +followed, a symlink cycle is entered once, and directories named `.git`, +`.mcpp` or `target`, the build's output directory and registered submodules +are not entered (2026.10.5.1+). Its fingerprint is the **sorted set of matching paths** and nothing else: - **not contents** — a file whose bytes matter is an ordinary @@ -572,7 +572,23 @@ paths** and nothing else: builds and `rsync`, and size is a weaker signal than the hash above. The build output tree and `.git` are never part of the set, so a wide pattern -cannot make the program re-run forever against its own outputs. +cannot make the program re-run forever against its own outputs. A pattern +whose literal prefix passes through an excluded directory (`target/**/*.in`, +`.git/HEAD`) can never match, and the build states this as a warning naming +the pattern; one file's contents are watched with `rerun_if_changed`. + +**Inputs outside the package** (2026.10.5.1+). A pattern may leave the package +when the package is the project, a path dependency or a workspace member: + +| Pattern | Watches | +|---|---| +| `../inputs/**/*.in` | a sibling directory, including its creation after the first build | +| `/srv/data/**/*.csv` | an absolute directory, matched against absolute paths | + +In a registry or git dependency, a pattern that leaves the package, by `..` or +as an absolute path, is an error naming the package and the pattern. A wide +literal prefix (`../../**`) walks its whole tree on every fast-path check, so a +specific prefix keeps the check short. **Every declared input is compared on the fast path too** (2026.9.5.4+). A project whose sources are all older than `build.ninja` takes a fast path that diff --git a/docs/README.md b/docs/README.md index 10ba8fd3..f0e6d3ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -118,7 +118,7 @@ token in front of a reader to the chapter that owns it. | | chapter | | chapter | |---|---|---|---| | `[package]`, `[targets.]`, `[build]`, `[lib]` | [04](04-mcpp-toml.md) | `[profile.]`, `[resources]`, `[runtime]` | [04](04-mcpp-toml.md) | -| `[targets.] auto_export` | [04](04-mcpp-toml.md) | `[targets.] exports` | [04](04-mcpp-toml.md) | +| `[targets.] windows_auto_export` | [04](04-mcpp-toml.md) | `[targets.] exports` | [04](04-mcpp-toml.md) | | `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]` | [05](05-dependencies.md) | `scan_overrides`, `module_extensions` | [04](04-mcpp-toml.md) | | `[features]`, `[feature-deps.]`, `provides` / `requires` | [06](06-features-and-capabilities.md) | `[workspace]` | [07](07-workspace.md) | | `[toolchain]`, `cxx_runtime` | [20](20-toolchains.md) | `[target.]`, `cfg(…)` | [22](22-target-side.md) | diff --git a/docs/specs/README.md b/docs/specs/README.md index 7e5e77be..d86f907c 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -33,12 +33,12 @@ | [SPEC-001](package-identity.md) | 包身份(`package.namespace` / `package.name`)、`[dependencies]` 选择器与匹配机制 | 评审中 v1.1 | 2026-08-03 | mcpp >= 0.0.106 | | [SPEC-002](target-side.md) | 目标侧模型与能力声明(`mcpp:` 保留命名空间、五层、三条规则) | 评审中 v1.0 | 2026-08-24 | mcpp >= 2026.8.24.2 | | [SPEC-003](exit-codes.md) | 退出码契约(分类、语义、稳定性承诺) | 评审中 v1.0 | 2026-09-01 | mcpp >= 2026.9.1.1 | -| [SPEC-004](manifest-semantics.md) | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | 草案 v1.10 | 2026-09-28 | 条件化形状 mcpp >= 2026.8.29.1;目标轴 mcpp >= 2026.9.6.4;`linkage` 默认值 mcpp >= 2026.9.15.2;链接 flag 的词读法 mcpp >= 2026.9.26.2;条件化的 `dialect_cxxflags` 与 `-p` 的包身份 mcpp >= 2026.9.28.1;条件表按具体程度生效 mcpp >= 2026.9.28.2 | +| [SPEC-004](manifest-semantics.md) | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | 草案 v1.11 | 2026-10-05 | 条件化形状 mcpp >= 2026.8.29.1;目标轴 mcpp >= 2026.9.6.4;`linkage` 默认值 mcpp >= 2026.9.15.2;链接 flag 的词读法 mcpp >= 2026.9.26.2;条件化的 `dialect_cxxflags` 与 `-p` 的包身份 mcpp >= 2026.9.28.1;条件表按具体程度生效 mcpp >= 2026.9.28.2;平台前缀规约 mcpp >= 2026.10.5.1 | | [SPEC-005](build-database.md) | 构建数据库:`mcpp emit build-database` 的内容、取值规则与不写工程目录的保证 | 评审中 v1.6 | 2026-09-29 | mcpp >= 2026.9.15.1;v1.3 条款 mcpp >= 2026.9.26.2;v1.4 条款 mcpp >= 2026.9.27.1;v1.5 条款 mcpp >= 2026.9.28.1;v1.6 条款 mcpp >= 2026.9.29.5 | | [SPEC-006](toolchain-management.md) | 工具链管理:身份、来源、选择与载荷契约 | 草案 v0.6 | 2026-10-02 | 逐条标注;已实现条款 mcpp >= 2026.9.24.1;§3.7 mcpp >= 2026.9.28.1;§3.7.1 mcpp >= 2026.9.28.2;§2.2.1 与 §3.3 的非缺省来源 mcpp >= 2026.10.1.3 | | [SPEC-007](build-plugins.md) | 构建插件:配置、施工与校验的分工,运行时与规划期的义务 | 草案 v0.6 | 2026-09-28 | 逐条标注;mcpp >= 2026.9.26.2;v0.3 条款 mcpp >= 2026.9.27.1;v0.4 条款 mcpp >= 2026.9.28.1;v0.5 条款 mcpp >= 2026.9.28.2;v0.6(§9)mcpp >= 2026.9.28.3 | | [SPEC-008](library-interface.md) | 库的接口:公开模块、发布闭包与两种形态的一致 | 草案 v0.1 | 2026-09-28 | 第一阶段(只警告)mcpp >= 2026.9.28.3 | -| [SPEC-009](toolchain-maintenance.md) | 工具链的支持与维护:版本线、默认值、来源、移动与退役 | 草案 v0.1 | 2026-10-02 | 逐条标注;本版只有规范,多数条款未实现 | +| [SPEC-009](toolchain-maintenance.md) | 工具链的支持与维护:版本线、默认值、来源、移动与退役 | 草案 v0.2 | 2026-10-05 | 逐条标注;本版只有规范,多数条款未实现 | ## 文档约定 diff --git a/docs/specs/manifest-semantics.md b/docs/specs/manifest-semantics.md index 785af838..1476eda6 100644 --- a/docs/specs/manifest-semantics.md +++ b/docs/specs/manifest-semantics.md @@ -5,8 +5,8 @@ | **规范编号** | SPEC-004 | | **标题** | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | | **状态** | **草案(Draft)** | -| **版本** | 1.10 | -| **最后修改** | 2026-09-28 | +| **版本** | 1.11 | +| **最后修改** | 2026-10-05 | | **最低实现版本** | 条件化形状:mcpp **2026.8.29.1**(`[target..build-dependencies]` 起齐备);目标轴:mcpp **2026.9.6.4** | | **作者/维护** | mcpp-community | | **相关设计文档** | `.agents/docs/2026-09-07-mcpp-toml-unified-semantics-design.md`
`.agents/docs/2026-06-04-manifest-schema-ownership.md`
`.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md`
`.agents/docs/2026-09-25-issue-690-workspace-build-inheritance-consistency.md`
`.agents/docs/2026-09-27-eight-reports-by-home-and-one-optimisation-plan.md` | @@ -30,13 +30,13 @@ ## 1. 范围 本规范陈述 `mcpp.toml` 的**结构语义**:一个 section 属于哪个平面、条件写在哪里、 -一个条目按什么解析、键怎么命名。它不列举字段——字段在 docs/05。 +一个条目按什么解析、键怎么命名。它不列举字段——字段在 docs/04。 它回答的是一个新字段或新 section 该长什么样,以及一份 manifest 为什么这样组织。 §8 另陈述编译 flag 列表中一个元素代表哪些参数,§9 陈述工作空间继承与构建需求的作用域。 -**边界。** 本规范不覆盖字段的准入条件,那由 docs/05 附录 A(Schema Ownership -Principle)规定,本规范不重复它,只在 §6 引用并补充一条。 +**边界。** 本规范不覆盖字段的准入条件,那由 docs/90「新增一个 manifest 字段:准入标准」 +规定,本规范不重复它,只在 §6 引用并补充一条。 ## 2. 平面 @@ -353,9 +353,21 @@ feature-deps feature-xlings ← 限定词是门 **状态:已实现。** +### 5.3 只在一个平台生效的键带平台前缀 + +一个键的效果只存在于一个平台或 ABI 时,它的名字**必须**带该平台的前缀:`windows_` +(PE),今天的实例是 `[targets.]` 下的 `windows_subsystem`、`windows_entry`、 +`windows_code_page` 与 `windows_auto_export`。不带前缀的中立名字留给在每一行都有含义的 +键:这样的键**必须**在每一行都产生效果,或在不能产生效果的行上被拒绝。 + +带前缀的键在其它平台上不产生任何内容,也不被拒绝,因此同一份 manifest 在各个平台上都可用; +它的条件化仍按 §3.1 写成 `[target..targets.]`。 + +**状态:已实现(mcpp 2026.10.5.1,#766)。** + ## 6. 新增条件化的准入 -除 docs/05 附录 A 的准入条件外,新的条件化需求**必须**先尝试用 +除 docs/90「新增一个 manifest 字段:准入标准」的准入条件外,新的条件化需求**必须**先尝试用 `[target..
]` 表达。表达不了才讨论新语法,并**必须**在设计文档里 说明为什么表达不了。 @@ -583,3 +595,4 @@ mcpp 2026.9.26.2,#703)。** | 1.8 | 2026-09-27 | mcpp 2026.9.27.1:§4.5 的版本位按 xlings 文法回答(#712);新增 §4.6 宿主构建读取宿主三元组的行(#704);§9 补第 8 至 10 条(#713、#714、#710);新增 §10 依赖的程序:`tools`、特性的 `tools`、`artifacts`(#709、#711);§7 补第 16 至 20 条判据。 | | 1.9 | 2026-09-28 | mcpp 2026.9.28.1:§9 第 1 条补上带 `[package]` 的工作空间根自己的 `path` 依赖所到达的成员,`-p` 先按包的身份解析(#725);§3.1 接受 `[target..build] dialect_cxxflags`,§9 第 10 条把它列为根位置的键(#717);§3.1.1 的状态改为部分实现,多个命中的条件表的先后见 mcpp#728。 | | 1.10 | 2026-09-28 | 多个命中的条件表按选择器的具体程度生效,三元组高于操作系统高于族,字典序只打破平局(mcpp 2026.9.28.2,mcpp#728,2026-09-28 设计 D7):§3.1.1 陈述规则与具体程度,§3.1 与 §9 第 2 条的「按清单顺序」随之更正;§3.1.1 转为已实现。 | +| 1.11 | 2026-10-05 | 新增 §5.3:只在一个平台生效的键带平台前缀(mcpp 2026.10.5.1,#766;`auto_export` 在发布前更名为 `windows_auto_export`)。§1 与 §6 引用的字段准入条件改指 docs/90,字段参考改指 docs/04。 | diff --git a/docs/specs/toolchain-maintenance.md b/docs/specs/toolchain-maintenance.md index f2ff6140..e463bd91 100644 --- a/docs/specs/toolchain-maintenance.md +++ b/docs/specs/toolchain-maintenance.md @@ -4,8 +4,8 @@ |---|---| | 规范编号 | SPEC-009 | | 标题 | 工具链的支持与维护:版本线、默认值、来源、移动与退役 | -| 状态 | 草案 v0.1 | -| 最后修改 | 2026-10-02 | +| 状态 | 草案 v0.2 | +| 最后修改 | 2026-10-05 | | 对应实现 | 逐条标注;本版只有规范,多数条款未实现 | | 相关设计文档 | `.agents/docs/2026-10-02-pr-ci-acceleration-and-the-toolchain-specification-design.md`(第 IV 部分) | | 相关 issue | mcpp#669(macOS 27 的链接)、mcpp#685、mcpp#687、mcpp#755 | @@ -277,7 +277,7 @@ issue 关闭时该腿**必须**离开已知红色的列表。 引擎在每一个将以该发布为 Default 的行上得到验证,且该发布通过下列的门。载荷**可以**按路径命名(SPEC-006 §2.2.1),因此本步不必等待 10.4。 候选发布 R 与该行当前的 Default D 在同一个 mcpp 提交、同一个 runner 镜像、同一个作业中比较,使比较只跨越发布的变化。 -R 通过,当且仅当下列六项全部成立。 +R 通过,当且仅当下列七项全部成立。 | 门 | 判据 | |---|---| @@ -287,10 +287,11 @@ R 通过,当且仅当下列六项全部成立。 | G4 | 运行 §7 的每一个复现,且 G1 至 G3 不需要新的绕行或重塑的源码;需要者使门失败,除非评审接受并附登记项 | | G5 | mcpp 自身源码与 e2e 模块夹具所扫描出的模块图(每个单元提供与需要的模块),在 R 与 D 下相同 | | G6 | mcpp 与 `bench/` 工程的冷构建和暖构建,以 R 所用时间不超过 D 的 110%(三次运行的中位数),BMI 的体积不超过 110% | +| G7 | MSVC ABI 的行上,e2e 881 以 R 通过:DLL 的导出发现读取 R 输出的文本 IR,而文本 IR 不是 LLVM 保证稳定的接口,其变化会使源码中的导出声明不再被识别 | 未通过门的发布留在 Available。同一条线的下一个发布成为候选。 -当前:没有门的工作流,G1 至 G6 均未实现;CI 不在同一作业中比较两个发布。 +当前:没有门的工作流,G1 至 G6 均未实现;CI 不在同一作业中比较两个发布。G7 由 Windows e2e 分片对当前钉住的 LLVM 运行 e2e 881 部分实现(mcpp 2026.10.5.1,#766)。 ### 10.6 引擎 @@ -384,3 +385,4 @@ mcpp 自己的清单**必须**使用其构建所在的每一行的 Default 发 | 版本 | 日期 | 变更 | |---|---|---| | v0.1 | 2026-10-02 | 初版 | +| v0.2 | 2026-10-05 | §10.5 增加 G7:MSVC ABI 行上 e2e 881 以候选发布通过(mcpp 2026.10.5.1,#766)。 | diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 4c6ec44a..5f45a5e8 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -216,6 +216,10 @@ loader 打开的 `.dll` 与链接器消费的导入库,导出列表从对象 ABI 生成(不带 `__declspec(dllexport)` 或 `.def` 时什么都不导出)。见 `tests/e2e/08`、`257` 与 `259`。 +同时有 `shared` target 与 `bin` target 的包,每个可执行文件都以本包自己的对象链接, +作为独立的程序:可执行文件不加载本包自己的共享库。加载某个包的共享库的程序属于另一个包, +例如同一工作空间中的另一个成员([07 —— 工作空间](07-workspace.md))。 + #### `kind = "app"` —— 用户启动的那个东西(mcpp 2026.9.12.3+) ```toml @@ -267,7 +271,7 @@ exports = "abi/mydriver.exports" # or inline: exports = ["vk_icd*"] 省略 `exports` 会保留 ELF 和 Mach-O 的原生可见性规则。在 MSVC ABI 上, mcpp 会发现可导出的外部定义;任一输入已经声明导出,或设置了 -`auto_export = false` 时不进行这种发现。`exports` 收窄链接器发布的集合。 +`windows_auto_export = false` 时不进行这种发现。`exports` 收窄链接器发布的集合。 两类工程需要这种收窄。**带稳定 ABI 的运行时**只发布一份经过审查的 集合,其余一概不发布,让不在集合里的东西保留自由变化的空间。**与同类 @@ -284,7 +288,7 @@ loader 以及进程中的其它 ICD 相撞。 |---|---| | ELF | 一份 version script,`-Wl,--version-script=` | | Mach-O | `-Wl,-exported_symbols_list`(前导下划线由引擎补上) | -| PE | 那份 `.def`,替换自动生成的、导出一切的那一份 | +| PE(MSVC ABI) | 那份 `.def`:被发现的符号中与某个模式匹配的那些 | **它不改变编译期可见性,这是刻意的。** 这种收窄在全部三种格式上都是 链接期属性,所以一个键只有一种效果。`-fvisibility=hidden` 仍可通过 @@ -300,27 +304,37 @@ script 并通过 `[build] ldflags` 传入,或者自行计算并发出 [`dependency_linkage`](#dependency_linkage--静态还是动态由消费者决定), 在那里,一个库采取的形式变成消费者的决定。 -#### `auto_export` —— MSVC ABI 上的原生导出控制(尚未发布) +在 MSVC ABI 上,一个 DLL 的导出集合的三种来源按以下次序生效: + +1. **源码中的声明。** 任一对象声明了导出(`__declspec(dllexport)`、 + `#pragma comment(linker, "/EXPORT:...")`,或 LLVM bitcode 中的同类声明)时, + DLL 恰好发布这些符号。与这种声明并存的 `exports` 不起作用,构建以警告说明这一点, + 并指出该对象。 +2. **`exports`。** 否则,发布被发现的符号中名字与某个模式匹配的那些。模式匹配的是 + 符号在链接器中的名字:32 位 x86 上不带修饰,C++ 符号为 MSVC 修饰名。因此为 C 名字 + 写的模式可在三种格式间通用,为 C++ 修饰名写的模式不能。 +3. **发现。** 否则,发布全部可导出的定义。 -这个键和 LLVM bitcode 导出发现需要尚未发布的源码构建,mcpp 2026.10.3.1 -不提供这些能力。自行控制导出的 target 可以省略自动导出发现步骤: +不发布任何符号的 DLL 没有导入库。同一构建中的程序链接这样的 DLL 时,它的 `.def` 步骤 +失败并指出该 DLL;没有程序链接的 DLL(例如只含资源的 DLL)照常构建。 + +#### `windows_auto_export` —— MSVC ABI 上的导出发现(mcpp 2026.10.5.1+) ```toml [targets.plugin] kind = "shared" -auto_export = false +windows_auto_export = false ``` -这个布尔值默认为 `true`,只作用于 MSVC ABI 上的 PE 共享库,包括 clang 和 -clang-cl;库作为依赖构建时也生效。它不影响静态库、可执行文件、ELF、Mach-O -或 MinGW。关闭发现后,原生 `__declspec(dllexport)`、链接旗标和显式 `exports` -列表仍然生效。 +这个布尔值默认为 `true`。`false` 去掉发现步骤,DLL 发布其源码与 `[build] ldflags` +所声明的符号。它作用于 MSVC ABI 上的 PE 共享库,包括 clang 与 clang-cl,target +作为依赖构建时同样生效;在静态库、可执行文件、ELF、Mach-O 与 MinGW 上不产生任何内容。 + +规划 MSVC ABI 的 target 时,`windows_auto_export = false` 与 `exports` 同时出现会被 +拒绝,因为 `exports` 收窄的是被发现的符号;同一份 manifest 在 ELF 与 Mach-O 上照常构建。 -开启发现时,任一输入的显式导出意图都会禁止整个 DLL 的自动导出。首先检查 -COFF 指令,再使用所选 LLVM 编译器检查 bitcode 中的 `dllexport` 声明和 -linker-option 元数据。只有没有标注的 DLL 才需要枚举候选符号,bitcode 的 -候选符号由该编译器旁的 `llvm-nm` 提供。FullLTO 和 ThinLTO 输入都可以与 -普通 COFF 对象混用。 +发现直接读取 COFF 对象,并用所选 LLVM 编译器及其旁边的 `llvm-nm` 读取 LLVM bitcode +(FullLTO 与 ThinLTO,单独或与 COFF 对象混用)。 #### `windows_subsystem` 与 `windows_entry` —— Windows GUI 可执行文件(mcpp 2026.9.12.2+) diff --git a/docs/zh/09-commands-by-scenario.md b/docs/zh/09-commands-by-scenario.md index dec07775..f12b1ba1 100644 --- a/docs/zh/09-commands-by-scenario.md +++ b/docs/zh/09-commands-by-scenario.md @@ -339,6 +339,24 @@ $ echo $? 无法启动的程序保留拒绝时的状态(`127` 找不到,`126` 不可执行,`125` 其他),`mcpp build`、`mcpp test`、`mcpp pack` 的状态不变。程序本身,或者 runner(`--runner`、`[target.].runner`),自己返回 101 时,读起来与构建失败相同;各区间见 [50 —— 机器可读输出](50-machine-output.md) §6。 +### 程序与终端 *(2026.10.5.1+)* + +`mcpp run` 启动的程序拥有终端,与由 shell 直接启动时相同:它读取终端的输入,Ctrl-C、Ctrl-\ 与 Ctrl-Z 送达程序,由程序决定其含义。runner 与具名 runner(`--runner`)同样如此。 + +- 在 Linux 与 macOS 上,构建完成后 `mcpp` 被程序替换。程序以 shell 启动的进程号运行,`mcpp` 不再留存:`timeout` 或关闭终端直接作用于程序。 +- 因信号结束的程序,调用方看到的同样是该信号,与直接运行一致:shell 报告 `128+n`,Python 的 `subprocess` 报告 `-n`。 +- 在 Windows 上,程序在同一控制台与同一进程组中运行,`mcpp` 返回其退出码。 +- 关于整条命令的提示(`tip:` 行,例如要求更新 mcpp 的索引)在 `Running` 行之前输出,因为程序之后不再有 `mcpp` 的输出。 + +```console +$ mcpp run -q +> hello +read: hello +> ^C # 程序的 SIGINT 处理函数运行 +$ echo $? +3 # 处理函数返回的状态 +``` + ## 发布前校验描述符 `mcpp xpkg parse` 用解析器自己的文法读一个描述符,所以它报告的就是解析时 diff --git a/docs/zh/12-binary-distribution.md b/docs/zh/12-binary-distribution.md index 415b62f4..0fdbed41 100644 --- a/docs/zh/12-binary-distribution.md +++ b/docs/zh/12-binary-distribution.md @@ -359,8 +359,8 @@ bitcode 检查(尚未发布的源码构建)使用所选 LLVM 编译器和它 叠加一份列表,会把同一批符号导出两次(`LNK4197`),还会把其余所有符号也一并 导出,用「全部」取代作者选定的那个公开面。bitcode 的 `dllexport` 存储类别和 linker-option 元数据表达同样的意图。导出意图在枚举候选符号前检查。每个 target -可以通过 [`auto_export`](04-mcpp-toml.md#auto_export--msvc-abi-上的原生导出控制尚未发布) -完全关闭导出发现(尚未发布的源码构建)。 +可以通过 [`windows_auto_export`](04-mcpp-toml.md#windows_auto_export--msvc-abi-上的导出发现mcpp-20261051) +关闭导出发现,[`exports`](04-mcpp-toml.md#exports--产物发布的符号集合mcpp-2026965) 收窄发现的结果。 超过 65535 个可导出符号时,mcpp 拒绝而不是截断。一个被截断的导出表能干净地 链接完成,随后在恰好需要那个掉出去的符号的消费方那里失败。 diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index 5f1897b1..281a3b38 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -481,9 +481,9 @@ int main() { } ``` -模式相对 manifest 目录,`*` / `**` 的文法与 `sources = [...]` 完全一致。固定目录 -前缀可以指向包外:`../inputs/**/*.in` 监视同级目录,包括首次构建后才创建该目录 -的情况。构建输出目录与 `.git` 仍被排除,也不跟随目录符号链接。它的指纹 +模式相对 manifest 目录,`*` / `**` 的文法与 `sources = [...]` 完全一致,遍历方式也相同: +跟随目录符号链接,符号链接环只进入一次,不进入名为 `.git`、`.mcpp` 或 `target` 的目录、 +构建输出目录以及已登记的子模块(2026.10.5.1+)。它的指纹 是**排序后的匹配路径集合**,不含其他任何东西: - **不含内容** —— 字节内容重要的文件本来就该用 `rerun_if_changed` 声明,那条 @@ -492,7 +492,20 @@ int main() { 稳定,而 size 是比上面那个哈希更弱的信号。 构建输出目录与 `.git` 永远不进入集合,因此再宽的模式也不会让程序对着自己的产物 -无限重跑。 +无限重跑。固定前缀经过被排除目录的模式(`target/**/*.in`、`.git/HEAD`)永远不会匹配, +构建以警告指出该模式;监视单个文件的内容用 `rerun_if_changed`。 + +**包外的输入**(2026.10.5.1+)。当包是工程本身、path 依赖或工作空间成员时,模式可以 +离开包: + +| 模式 | 监视对象 | +|---|---| +| `../inputs/**/*.in` | 同级目录,包括首次构建后才创建该目录的情况 | +| `/srv/data/**/*.csv` | 一个绝对路径目录,按绝对路径匹配 | + +在 registry 或 git 依赖中,经由 `..` 或以绝对路径离开包的模式是错误,错误信息指出包与 +模式。宽泛的固定前缀(`../../**`)在每次快路径检查时都会遍历整棵树,具体的前缀让检查 +保持简短。 **声明过的输入在快路径上同样被比较**(2026.9.5.4+)。当所有源文件都不比 `build.ninja` 新时,工程走快路径,跳过读取构建程序缓存的那个阶段;在 2026.9.5.4 diff --git a/docs/zh/README.md b/docs/zh/README.md index 6b9e3897..2504af6a 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -114,7 +114,7 @@ | | 章节 | | 章节 | |---|---|---|---| | `[package]`、`[targets.]`、`[build]`、`[lib]` | [04](04-mcpp-toml.md) | `[profile.]`、`[resources]`、`[runtime]` | [04](04-mcpp-toml.md) | -| `[targets.] auto_export` | [04](04-mcpp-toml.md) | `[targets.] exports` | [04](04-mcpp-toml.md) | +| `[targets.] windows_auto_export` | [04](04-mcpp-toml.md) | `[targets.] exports` | [04](04-mcpp-toml.md) | | `[dependencies]`、`[dev-dependencies]`、`[build-dependencies]` | [05](05-dependencies.md) | `scan_overrides`、`module_extensions` | [04](04-mcpp-toml.md) | | `[features]`、`[feature-deps.]`、`provides` / `requires` | [06](06-features-and-capabilities.md) | `[workspace]` | [07](07-workspace.md) | | `[toolchain]`、`cxx_runtime` | [20](20-toolchains.md) | `[target.]`、`cfg(…)` | [22](22-target-side.md) | diff --git a/mcpp.toml b/mcpp.toml index 687186da..fa822283 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.10.3.1" +version = "2026.10.5.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/buildmcpp/src/directives.cppm b/modules/buildmcpp/src/directives.cppm index 0743440a..aff2044c 100644 --- a/modules/buildmcpp/src/directives.cppm +++ b/modules/buildmcpp/src/directives.cppm @@ -611,6 +611,20 @@ std::string glob_fingerprint(const std::filesystem::path& root, std::string_view pattern, std::string_view outputDirName); +// Why `pattern` can never match, or nullopt: its literal prefix passes through +// a directory no glob walk enters, or it is absolute with no literal directory. +// The fingerprint of such a pattern is the empty set, so a build program that +// declares it is never re-run by it; the run reports the reason (#766). +std::optional glob_unwatchable(const std::filesystem::path& root, + std::string_view pattern, + std::string_view outputDirName); + +// Whether `pattern` names files outside its package: absolute, or leading +// with `..` once normalized. Honoured for the root, a path dependency and a +// workspace member, whose surroundings are the user's tree; refused for a +// registry or git dependency, whose surroundings are the package store. +bool glob_leaves_package(std::string_view pattern); + // ── Apply ────────────────────────────────────────────────────────────────── // Fold the collected directives into the manifest's buildConfig. The single @@ -930,57 +944,90 @@ bool accept_cache_record(Directives& d, std::string_view tag, std::string_view v return true; } +namespace { + +bool absolute_glob(std::string_view pattern) { + return pattern.starts_with('/') + || (pattern.size() > 2 && std::isalpha(static_cast(pattern[0])) + && pattern[1] == ':' && pattern[2] == '/'); +} + +// Where a glob's walk starts: its literal directory prefix, against `root` +// unless the pattern is absolute. +std::filesystem::path glob_walk_start(const std::filesystem::path& root, std::string_view pattern) { + const auto prefix = mcpp::modgraph::glob_literal_prefix(pattern); + if (absolute_glob(pattern)) return prefix.lexically_normal(); + return (root / prefix).lexically_normal(); +} + +} // namespace + +bool glob_leaves_package(std::string_view pattern) { + if (absolute_glob(pattern)) return true; + const auto normal = std::filesystem::path(std::string(pattern)).lexically_normal(); + return !normal.empty() && *normal.begin() == ".."; +} + +std::optional glob_unwatchable(const std::filesystem::path& root, + std::string_view pattern, + std::string_view outputDirName) { + namespace fs = std::filesystem; + if (absolute_glob(pattern) && mcpp::modgraph::glob_literal_prefix(pattern).empty()) + return std::string("an absolute pattern needs a literal directory before its first wildcard"); + // Only the prefix as written is examined: the directories above the + // package root, and the whole path an absolute pattern spells, are the + // user's own choice and are not a walk's to exclude. + if (absolute_glob(pattern)) return std::nullopt; + const auto outName = mcpp::modgraph::native_path_from_generic(outputDirName); + fs::path at = root; + for (auto const& component : mcpp::modgraph::glob_literal_prefix(pattern)) { + at /= component; + if (component == ".." || component == ".") continue; + if (mcpp::modgraph::is_excluded_walk_dir(at, root) + || (!outputDirName.empty() && component == outName)) + return std::format("its literal prefix passes through '{}', a directory no glob " + "walk enters (`.git`, `.mcpp`, `target`, the output directory " + "or a submodule)", component.generic_string()); + } + return std::nullopt; +} + std::string glob_fingerprint(const std::filesystem::path& root, std::string_view pattern, std::string_view outputDirName) { namespace fs = std::filesystem; - std::vector hits; - std::error_code ec; const auto empty = mcpp::toolchain::hash_string(""); - const auto prefix = mcpp::modgraph::glob_literal_prefix(pattern); - // Starting at the literal prefix must not bypass the exclusions applied - // during traversal, or target/** and symlink/subdir/** would enter them. - auto start = root; - for (const auto& component : prefix) { - if (component == ".git" || (!outputDirName.empty() - && component == mcpp::modgraph::native_path_from_generic(outputDirName))) - return empty; - start /= component; - if (fs::is_symlink(start, ec)) return empty; - } - start = start.lexically_normal(); - ec.clear(); - // skip_permission_denied only: symlinked directories are NOT followed, the - // same rule the source scan uses, so a self-referential link cannot make - // this walk diverge. - fs::recursive_directory_iterator it( - start, fs::directory_options::skip_permission_denied, ec); + // A glob no walk can enter is the empty set; glob_unwatchable says why, + // and the program's run reports it. + if (glob_unwatchable(root, pattern, outputDirName)) return empty; + const bool absolute = absolute_glob(pattern); + const auto start = glob_walk_start(root, pattern); // A missing input directory is the same empty set as an existing directory // containing no matches. Its first matching file will invalidate the key. - if (ec) return empty; - for (; it != fs::recursive_directory_iterator(); it.increment(ec)) { - if (ec) break; - const auto& p = it->path(); - std::error_code dec; - if (it->is_directory(dec)) { - const auto name = p.filename(); - if (name == ".git" || (!outputDirName.empty() - && name == mcpp::modgraph::native_path_from_generic(outputDirName))) { - it.disable_recursion_pending(); - continue; + std::error_code ec; + if (!fs::is_directory(start, ec)) return empty; + const auto outName = mcpp::modgraph::native_path_from_generic(outputDirName); + std::vector hits; + // The walk the source globs take (mcpp.modgraph.glob): directory symlinks + // followed with the cycle guard, the same exclusions, plus this build's + // output directory by name, since a build program writes its outputs + // inside the project and `**` would otherwise change on every run. + mcpp::modgraph::walk_glob_tree(root, start, + [&](const fs::path& d) { return outputDirName.empty() || d.filename() != outName; }, + [&](const fs::path& f) { + // Relative patterns name the file relative to the package root; an + // absolute pattern names it absolutely. + auto spelled = mcpp::modgraph::try_narrow(absolute ? f.lexically_normal() + : f.lexically_relative(root)); + if (!spelled) { + mcpp::modgraph::note_unnarrowable_path(f); + return; } - if (it->is_symlink(dec)) it.disable_recursion_pending(); - continue; - } - if (!mcpp::modgraph::path_matches_glob(p, root, pattern)) continue; - auto rel = mcpp::modgraph::try_narrow(p.lexically_relative(root)); - if (!rel) { - mcpp::modgraph::note_unnarrowable_path(p); - continue; - } - hits.push_back(std::move(*rel)); - } + if (mcpp::modgraph::relative_path_matches_glob(*spelled, pattern)) + hits.push_back(std::move(*spelled)); + }); std::ranges::sort(hits); + hits.erase(std::ranges::unique(hits).begin(), hits.end()); std::string joined; for (auto const& h : hits) { joined += h; joined.push_back('\n'); } return mcpp::toolchain::hash_string(joined); diff --git a/modules/buildmcpp/tests/test_glob_inputs.cpp b/modules/buildmcpp/tests/test_glob_inputs.cpp index cec50120..312401a4 100644 --- a/modules/buildmcpp/tests/test_glob_inputs.cpp +++ b/modules/buildmcpp/tests/test_glob_inputs.cpp @@ -94,15 +94,49 @@ TEST_F(GlobInputs, ParentPatternsRetainPathSetSemantics) { EXPECT_NE(fingerprint("../inputs/**"), before); } -TEST_F(GlobInputs, DirectorySymlinksAreNotFollowedThroughTheLiteralPrefix) { +TEST_F(GlobInputs, DirectorySymlinksAreFollowedAsTheSourceScanFollowsThem) { write("../real/nested/a.in"); std::error_code ec; std::filesystem::create_directory_symlink(tree / "workspace" / "real", root / "../link", ec); if (ec) GTEST_SKIP() << "Directory symlinks are unavailable: " << ec.message(); - EXPECT_EQ(fingerprint("../link/**/*.in"), mcpp::toolchain::hash_string("")); - EXPECT_EQ(fingerprint("../link/nested/*.in"), mcpp::toolchain::hash_string("")); - const auto before = fingerprint("../**/*.in"); + const auto before = fingerprint("../link/**/*.in"); + EXPECT_NE(before, mcpp::toolchain::hash_string("")); + EXPECT_NE(fingerprint("../link/nested/*.in"), mcpp::toolchain::hash_string("")); write("../real/nested/b.in"); - EXPECT_NE(fingerprint("../**/*.in"), before); + EXPECT_NE(fingerprint("../link/**/*.in"), before); +} + +TEST_F(GlobInputs, ALinkCycleEndsTheWalk) { + write("../loop/a.in"); + std::error_code ec; + std::filesystem::create_directory_symlink(tree / "workspace" / "loop", + root / "../loop/again", ec); + if (ec) GTEST_SKIP() << "Directory symlinks are unavailable: " << ec.message(); + EXPECT_NE(fingerprint("../loop/**/*.in"), mcpp::toolchain::hash_string("")); +} + +TEST_F(GlobInputs, AbsolutePatternsMatchAbsolutePaths) { + write("../inputs/a.in"); + const auto dir = (tree / "workspace" / "inputs").lexically_normal().generic_string(); + const auto before = fingerprint(dir + "/**/*.in"); + EXPECT_NE(before, mcpp::toolchain::hash_string("")); + write("../inputs/nested/b.in"); + EXPECT_NE(fingerprint(dir + "/**/*.in"), before); +} + +TEST(GlobInputBoundary, PatternsThatLeaveThePackageAreRecognised) { + EXPECT_FALSE(dirs::glob_leaves_package("proto/**/*.proto")); + EXPECT_FALSE(dirs::glob_leaves_package("a/../b/*.in")); + EXPECT_TRUE(dirs::glob_leaves_package("../inputs/**/*.in")); + EXPECT_TRUE(dirs::glob_leaves_package("a/../../inputs/*.in")); + EXPECT_TRUE(dirs::glob_leaves_package("/usr/share/data/*.in")); + EXPECT_TRUE(dirs::glob_leaves_package("C:/data/*.in")); +} + +TEST_F(GlobInputs, AnUnwatchablePatternSaysWhy) { + EXPECT_FALSE(dirs::glob_unwatchable(root, "proto/**", "target").has_value()); + EXPECT_FALSE(dirs::glob_unwatchable(root, "../inputs/**", "target").has_value()); + for (const auto pattern : {"target/**", ".git/HEAD", "../inputs/.mcpp/*", "/*.in"}) + EXPECT_TRUE(dirs::glob_unwatchable(root, pattern, "target").has_value()) << pattern; } diff --git a/modules/manifest/src/glob.cppm b/modules/manifest/src/glob.cppm index a99f9e0b..54b7360f 100644 --- a/modules/manifest/src/glob.cppm +++ b/modules/manifest/src/glob.cppm @@ -139,6 +139,28 @@ void note_unnarrowable_path(const std::filesystem::path& p); // ("路径窄化不变式") and the user-facing behaviour in docs/04-mcpp-toml.md. std::vector take_unnarrowable_paths(); +// THE WALK OF EVERY GLOB: a package's `sources` and a build program's +// `rerun_if_changed_glob` inputs select files from the same walk, so one tree +// cannot hold a file that one sees and the other does not (#766). +// +// From `start`, directory symlinks are followed (vendored trees are often +// symlink farms). A directory whose canonical path is already on the current +// recursion chain is a link cycle and is not entered; the same real directory +// reached through a second lexical path still is, because matching is lexical. +// is_excluded_walk_dir's directories are not entered. `onDir` sees every other +// directory below `start` and may refuse to enter it; `onFile` sees every +// regular file. Returns false when the iteration stopped on an error. +bool walk_glob_tree(const std::filesystem::path& root, const std::filesystem::path& start, + const std::function& onDir, + const std::function& onFile); + +// Directories no glob walk enters: VCS metadata (`.git`), mcpp's build output +// (`target`), mcpp's project-metadata directory (`.mcpp`, whose xlings data +// tree links back to every path-dependency root, mcpp#230), and the +// submodules `.gitmodules` registers under `root`, which are foreign and +// often large trees. +bool is_excluded_walk_dir(const std::filesystem::path& dir, const std::filesystem::path& root); + // Does `relative`, a generic spelling relative to the glob's root, match // `glob`? The matching half of path_matches_glob, for a caller that already // holds the narrowed relative spelling (a cached directory listing). @@ -349,6 +371,98 @@ std::string no_utf8_spelling_reason() { } } +namespace { + +std::string_view trim_ws(std::string_view s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) s.remove_prefix(1); + while (!s.empty() && std::isspace(static_cast(s.back()))) s.remove_suffix(1); + return s; +} + +// The submodule paths `.gitmodules` registers under `root`, canonical and +// absolute, read once per root for the process. Glob walks run on several +// threads at once (a workspace's build programs), hence the lock. An entry is +// never erased, so the reference stays valid after the lock is released; this +// runs once per directory entry of a walk, where a copy would be paid each time. +const std::set& submodule_paths(const std::filesystem::path& root) { + static std::mutex m; + static std::map> cache; + std::error_code kec; + auto key = std::filesystem::canonical(root, kec); + if (kec) key = root; + std::lock_guard lock(m); + if (auto it = cache.find(key); it != cache.end()) return it->second; + std::set paths; + std::ifstream f(root / ".gitmodules"); + std::string line; + while (f && std::getline(f, line)) { + auto eq = line.find('='); + if (eq == std::string::npos) continue; + std::string_view k = trim_ws(std::string_view(line).substr(0, eq)); + if (k != "path") continue; + std::string_view v = trim_ws(std::string_view(line).substr(eq + 1)); + if (v.empty()) continue; + std::error_code pec; + auto abs = std::filesystem::canonical(root / std::filesystem::path(std::string(v)), pec); + paths.insert(pec ? (root / std::filesystem::path(std::string(v))) : abs); + } + return cache.emplace(key, std::move(paths)).first->second; +} + +} // namespace + +bool is_excluded_walk_dir(const std::filesystem::path& dir, + const std::filesystem::path& root) { + // Compare as paths, never narrowed: a name the ANSI code page cannot spell + // made `filename().string()` throw on Windows (#516), and this runs once + // per directory entry, before any other guard of the walk. + static const std::filesystem::path kMcppDir{".mcpp"}; + static const std::filesystem::path kGitDir{".git"}; + static const std::filesystem::path kTargetDir{"target"}; + const auto name = dir.filename(); + if (name == kMcppDir || name == kGitDir || name == kTargetDir) return true; + auto const& submodules = submodule_paths(root); + if (submodules.empty()) return false; + std::error_code ec; + auto c = std::filesystem::canonical(dir, ec); + return submodules.contains(ec ? dir : c); +} + +bool walk_glob_tree(const std::filesystem::path& root, const std::filesystem::path& start, + const std::function& onDir, + const std::function& onFile) { + namespace fs = std::filesystem; + std::vector chain; // canonical directories of the recursion stack + std::error_code ec, eec; // ec: iteration; eec: per-entry probes + { + auto c = fs::canonical(start, eec); + chain.push_back(eec ? start : c); + } + fs::recursive_directory_iterator it(start, fs::directory_options::follow_directory_symlink, ec); + for (fs::recursive_directory_iterator end; !ec && it != end; it.increment(ec)) { + auto& e = *it; + if (e.is_directory(eec) && !eec) { + if (is_excluded_walk_dir(e.path(), root)) { + it.disable_recursion_pending(); + continue; + } + auto depth = static_cast(it.depth()); + chain.resize(std::min(chain.size(), depth + 1)); + auto c = fs::canonical(e.path(), eec); + if (!eec && std::find(chain.begin(), chain.end(), c) != chain.end()) { + it.disable_recursion_pending(); // link cycle + continue; + } + chain.push_back(eec ? e.path() : c); + if (!onDir(e.path())) it.disable_recursion_pending(); + continue; + } + if (!e.is_regular_file(eec) || eec) continue; + onFile(e.path()); + } + return !ec; +} + std::vector take_unnarrowable_paths() { std::lock_guard lk(g_unnarrowableMu); std::vector out(g_unnarrowable.begin(), g_unnarrowable.end()); diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index d6773181..e94e9c2d 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1849,11 +1849,11 @@ std::expected parse_string(std::string_view content, if (auto msg = validate_target_soname(t, std::format("targets.{}.", tname))) { return std::unexpected(error(origin, *msg)); } - if (auto it = tt.find("auto_export"); it != tt.end()) { + if (auto it = tt.find("windows_auto_export"); it != tt.end()) { if (!it->second.is_bool()) return std::unexpected(error(origin, std::format( - "targets.{}.auto_export must be a boolean", tname))); - t.autoExport = it->second.as_bool(); + "targets.{}.windows_auto_export must be a boolean", tname))); + t.windowsAutoExport = it->second.as_bool(); } // `exports` -- a file of symbol patterns, or the patterns inline. // @@ -1989,7 +1989,7 @@ std::expected parse_string(std::string_view content, // must reach SHARED code is intentionally not a target key; point users // at the right axis (workspace / features / profile). static constexpr std::string_view kKnownTargetKeys[] = { - "kind", "linkage", "main", "soname", "exports", "auto_export", + "kind", "linkage", "main", "soname", "exports", "windows_auto_export", "cflags", "cxxflags", "defines", "required_features", "windows_entry", "windows_subsystem", "windows_code_page", }; diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index be3a6662..3717032f 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -180,7 +180,7 @@ struct Target { std::string soname; // ABI name for shared libraries, e.g. libfoo.so.1 // PE / MSVC ABI only: discover exports when the objects do not declare // any. False leaves export control entirely to the native linker inputs. - bool autoExport = true; + bool windowsAutoExport = true; // WHICH SYMBOLS THIS ARTIFACT PUBLISHES. Empty = every symbol, which is // what both platforms do today (ELF default visibility; PE gets an // auto-generated .def listing everything, mcpp.build.coff_exports). diff --git a/modules/manifest/src/xpkg.cppm b/modules/manifest/src/xpkg.cppm index 228e16d0..c123d85d 100644 --- a/modules/manifest/src/xpkg.cppm +++ b/modules/manifest/src/xpkg.cppm @@ -1626,13 +1626,13 @@ synthesize_from_xpkg_lua(std::string_view luaContent, t.main = cur.read_string(); } else if (sub == "soname") { t.soname = cur.read_string(); - } else if (sub == "auto_export") { + } else if (sub == "windows_auto_export") { auto raw = cur.read_bareword(); if (raw != "true" && raw != "false") return std::unexpected(ManifestError{ - std::format("targets.{}.auto_export must be a boolean", tname), + std::format("targets.{}.windows_auto_export must be a boolean", tname), m.sourcePath, 0, 0}); - t.autoExport = raw == "true"; + t.windowsAutoExport = raw == "true"; } else if (sub == "required_features") { // #355: without this, a Form B descriptor could not // express the cost gate that makes an optional host diff --git a/modules/platform/src/process.cppm b/modules/platform/src/process.cppm index 730f58d1..5436b4dc 100644 --- a/modules/platform/src/process.cppm +++ b/modules/platform/src/process.cppm @@ -32,6 +32,7 @@ module; // Linux and macOS launchers do a direct exec (see run_exec / capture_exec // below); only Windows keeps the std::system shell path (#248). #include // pipe, dup2, close, read +#include // errno after a refused execve (run_foreground) #include // O_RDONLY, O_WRONLY for capture_stdout's /dev/null #include // waitpid #include // posix_spawnp, posix_spawn_file_actions_* (incl. addchdir_np) @@ -100,6 +101,28 @@ int run_exec(const std::vector& argv, const std::vector>& extraEnv = {}, int* spawn_error = nullptr); +// THE PROGRAM A USER RUNS (`mcpp run`): it owns the terminal, its signals and +// its exit status, as it would if the shell had started it. +// +// POSIX: mcpp REPLACES ITSELF with the program (execve). The program inherits +// mcpp's process group, which is the terminal's foreground group, so it reads +// the terminal and receives Ctrl-C, Ctrl-\ and Ctrl-Z itself. Whoever waits on +// mcpp waits on the program, so its exit status, a death by signal included, +// is seen unchanged, and nothing can outlive mcpp because nothing remains of +// it. `run_exec` is the wrong launcher for this: its process group of its own +// is a BACKGROUND group on a terminal, where the program's first read stops it +// with SIGTTIN and the terminal's Ctrl-C reaches mcpp instead of the program. +// Returns only when the program could not be started: 127, with the errno in +// `*spawn_error` under the contract of `run_exec`. The caller has finished +// everything it prints before calling: nothing of mcpp runs afterwards. +// +// Windows: the program runs in mcpp's console and process group, mcpp ignores +// Ctrl-C while it waits, and the program's exit code is returned +// (winproc::run_foreground). +int run_foreground(const std::vector& argv, + const std::vector>& extraEnv = {}, + int* spawn_error = nullptr); + // Same as run_exec but captures stdout AND stderr combined (replaces the old // `… 2>&1` redirect) into RunResult::output. Required because the only consumer // (ninja fast-path) parses error text — which ninja writes to stderr — via @@ -787,6 +810,76 @@ int run_exec(const std::vector& argv, #endif } +int run_foreground(const std::vector& argv, + const std::vector>& extraEnv, + int* spawn_error) +{ + if (spawn_error) *spawn_error = 0; + if (argv.empty()) return 127; +#if defined(__linux__) || defined(__APPLE__) + // A guarded group is a child mcpp still owns (ninja, a `during_build` + // hook). Replacing mcpp would leave it without an owner, so that is an + // internal error and not a silent orphan. + if (!mcpp::platform::unixproc::group_guard_idle()) { + std::fputs("mcpp: internal: a child of mcpp is still running; the program " + "is not started\n", stderr); + return 125; + } + // The terminal's mode and the signal handlers mcpp installed are restored + // here; execve then resets every handled signal to its default. + mcpp::platform::unixproc::unguard_terminal_mode(); + mcpp::platform::unixproc::clear_group_guard(); + + auto envStore = merged_environ(extraEnv); + std::vector envp; + for (auto& s : envStore) envp.push_back(s.data()); + envp.push_back(nullptr); + std::vector cargv; + for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); + cargv.push_back(nullptr); + + std::fflush(nullptr); + // PATH is searched with mcpp's own PATH, as posix_spawnp searched it for + // run_exec, and not with the program's environment. + const std::string& file = argv.front(); + int err = ENOENT; + if (file.find('/') != std::string::npos) { + ::execve(file.c_str(), cargv.data(), envp.data()); + err = errno; + } else { + const char* path = std::getenv("PATH"); + std::string_view dirs = (path && *path) ? path : "/usr/bin:/bin"; + bool denied = false; + for (auto dir : dirs | std::views::split(':')) { + std::string candidate{std::string_view(dir)}; + if (candidate.empty()) candidate = "."; + candidate += '/'; + candidate += file; + ::execve(candidate.c_str(), cargv.data(), envp.data()); + // The search continues past a directory that cannot hold the + // program; any other refusal names the program that was found. + if (errno == EACCES) { denied = true; continue; } + if (errno != ENOENT && errno != ENOTDIR) { err = errno; denied = false; break; } + } + if (denied && err == ENOENT) err = EACCES; + } + if (spawn_error) *spawn_error = err; + else std::fputs(spawn_failure(file, err).c_str(), stderr); + return 127; +#else + std::string prefix = mcpp::platform::env::build_env_prefix(extraEnv); + std::string cmd = windows_shell_command_line(prefix + command_from_argv(argv)); + unsigned long refused = 0; + const int code = mcpp::platform::winproc::run_foreground(cmd.c_str(), &refused); + if (code == -1 && refused != 0) { + if (spawn_error) *spawn_error = static_cast(refused); + else std::fputs(spawn_failure(argv.front(), static_cast(refused)).c_str(), stderr); + return 127; + } + return code; +#endif +} + RunResult capture_exec( const std::vector& argv, const std::vector>& extraEnv, diff --git a/modules/platform/src/unix/bounded_process.cppm b/modules/platform/src/unix/bounded_process.cppm index 1de9f725..ede5ebc2 100644 --- a/modules/platform/src/unix/bounded_process.cppm +++ b/modules/platform/src/unix/bounded_process.cppm @@ -220,6 +220,10 @@ void background_stop(long long group, long long graceMs); void guard_group_on_signal(long long group); void unguard_group(long long group); void clear_group_guard(); +// Whether no process group is guarded. A caller about to replace mcpp with +// another program (`mcpp run`) requires it: a guarded group is a child that +// would lose its owner. +bool group_guard_idle(); // THE TERMINAL MODE A SIGNAL RESTORES (build output design revision 3, // §5.14). `--play-game` reads keys from the terminal without echo. A Ctrl-C, @@ -622,6 +626,13 @@ void clear_group_guard() { ::signal(SIGHUP, SIG_DFL); } +bool group_guard_idle() { + std::lock_guard lock(g_guardMutex); + for (int i = 0; i < kMaxGuardedGroups; ++i) + if (g_guardedGroups[i] != 0) return false; + return true; +} + void guard_terminal_mode(int fd) { std::lock_guard lock(g_guardMutex); if (fd < 0 || ::tcgetattr(fd, &g_terminalMode) != 0) return; @@ -674,6 +685,7 @@ void LaunchSection::release() {} void guard_group_on_signal(long long) {} void unguard_group(long long) {} void clear_group_guard() {} +bool group_guard_idle() { return true; } void guard_terminal_mode(int) {} void unguard_terminal_mode() {} diff --git a/modules/platform/src/windows/bounded_process.cppm b/modules/platform/src/windows/bounded_process.cppm index 6f34b4e8..3185bc5a 100644 --- a/modules/platform/src/windows/bounded_process.cppm +++ b/modules/platform/src/windows/bounded_process.cppm @@ -212,6 +212,18 @@ void clear_job_guard(); // for the supervisor that must not block. int wait_background(unsigned long long process, int* exitCode); +// ─── The program a user runs (`mcpp run`) ──────────────────────────────── +// +// Started in the console mcpp was started from and in mcpp's own process +// group, so the console's input, Ctrl-C and Ctrl-Break reach the program and +// the program decides what they mean. While it runs, mcpp ignores Ctrl-C and +// Ctrl-Break through a handler of its own, which a child does not inherit, so +// that mcpp survives to return the program's status. The kill-on-close job +// still ends the program's tree when mcpp itself ends for any other reason. +// Returns the program's exit code, or -1 when it could not be started, with +// GetLastError() in `*refused`. +int run_foreground(const char* commandLine, unsigned long* refused); + } // namespace mcpp::platform::winproc namespace mcpp::platform::winproc { @@ -650,6 +662,62 @@ void clear_job_guard() { ::SetConsoleCtrlHandler(background_console_handler, FALSE); } +namespace { +BOOL WINAPI ignore_interrupt(DWORD type) { + return (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT) ? TRUE : FALSE; +} +} // namespace + +int run_foreground(const char* commandLine, unsigned long* refused) { + if (refused) *refused = 0; + if (!commandLine || !*commandLine) return -1; + + HANDLE job = ::CreateJobObjectA(nullptr, nullptr); + if (job) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{}; + jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ::SetInformationJobObject(job, JobObjectExtendedLimitInformation, + &jeli, sizeof(jeli)); + } + + STARTUPINFOA si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + std::string cmdBuf(commandLine); // CreateProcessA may modify it + + // A handler, not SetConsoleCtrlHandler(nullptr, TRUE): the null form sets + // an attribute the child inherits, and the child would then ignore the + // Ctrl-C it is meant to receive. + ::SetConsoleCtrlHandler(ignore_interrupt, TRUE); + BOOL ok = FALSE; + { + LaunchSection launch; + // No CREATE_NEW_PROCESS_GROUP: that flag is what keeps the console's + // Ctrl-C from the child. CREATE_SUSPENDED so the child joins the job + // before it can start anything. + ok = ::CreateProcessA(nullptr, cmdBuf.data(), nullptr, nullptr, + /*bInheritHandles=*/TRUE, CREATE_SUSPENDED, + nullptr, nullptr, &si, &pi); + } + if (!ok) { + if (refused) *refused = ::GetLastError(); + if (job) ::CloseHandle(job); + ::SetConsoleCtrlHandler(ignore_interrupt, FALSE); + return -1; + } + if (job) ::AssignProcessToJobObject(job, pi.hProcess); + ::ResumeThread(pi.hThread); + ::CloseHandle(pi.hThread); + + ::WaitForSingleObject(pi.hProcess, INFINITE); + DWORD code = 0; + ::GetExitCodeProcess(pi.hProcess, &code); + ::CloseHandle(pi.hProcess); + if (job) ::CloseHandle(job); + ::SetConsoleCtrlHandler(ignore_interrupt, FALSE); + return static_cast(code); +} + int wait_background(unsigned long long process, int* exitCode) { HANDLE h = reinterpret_cast(process); if (!h) return -1; @@ -679,6 +747,7 @@ void guard_job_on_signal(unsigned long long) {} void unguard_job(unsigned long long) {} void clear_job_guard() {} int wait_background(unsigned long long, int*) { return -1; } +int run_foreground(const char*, unsigned long*) { return -1; } #endif diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 0ed6c554..6ffea8e8 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.10.3.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.10.5.1"; } // namespace mcpp diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index abb2a21e..a60ddcb6 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -84,6 +84,10 @@ struct BuildProgramEnv { // every other package are folded into one (build progress design // 2026-09-29, §4.3). bool requested = false; + // The program's package is a registry or git dependency, whose tree sits in + // the package store: a `rerun_if_changed_glob` pattern leaving it is + // refused (#766). + bool sealedPackage = false; std::string targetTriple; // resolved canonical triple; "" = host // The resolved toolchain's payload root and the target's own C library // root. Both exist so a package can ASK instead of DECLARE — see @@ -2079,6 +2083,24 @@ std::expected run_build_program_impl( if (auto derr = dirs::deploy_directive_error(m, d); !derr.empty()) { return std::unexpected(derr); } + // GLOB INPUTS OUTSIDE THE PACKAGE (#766). A registry or git dependency's + // tree sits in the package store, whose other contents depend on what + // else is installed: a pattern leaving that package would make its re-run + // key a fact about this machine. Refused, naming the pattern. Elsewhere it + // is honoured; a pattern that no walk can enter is reported, since it can + // never re-run the program. + for (auto const& g : d.at(Slot::RerunGlobs)) { + if (env.sealedPackage && dirs::glob_leaves_package(g)) + return std::unexpected(std::format( + "build.mcpp of '{}' watches '{}', which leaves the package: a registry or git " + "dependency may only watch files inside its own tree", + m.package.name, g)); + if (auto why = dirs::glob_unwatchable(root, g, output_dir_name(root, bdir))) + mcpp::ui::warning(std::format( + "build.mcpp of '{}': rerun_if_changed_glob(\"{}\") can never re-run it: {}. " + "To watch one file's contents, use rerun_if_changed", + m.package.name, g, *why)); + } if (d.protocol == 0) { for (auto const& k : d.unknownKeys) mcpp::ui::warning(std::format( diff --git a/src/build/execute.cppm b/src/build/execute.cppm index d58b9ca2..6772cd0e 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -2067,6 +2067,17 @@ void run_separator() { mcpp::ui::flush(); } +// THE PROGRAM OWNS THE TERMINAL FROM THE `Running` LINE ON, and nothing of +// mcpp runs after it: on POSIX mcpp is replaced by the program +// (process::run_foreground). The live report is closed, which restores the +// terminal's mode, and the run's closing notices are printed now, before the +// `Running` line, because after the program there is no mcpp left to print +// them. +void yield_terminal() { + mcpp::build::progress::close(); + mcpp::ui::print_closing_notices(); +} + // mcpp#225 (E2): `mcpp run`'s fast path. Mirrors try_fast_build's // fingerprint/freshness gate against the SAME cache entry `mcpp build` // wrote (targetTriple == "" — a HOST build; see the precondition below), then @@ -2179,7 +2190,7 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, auto exe = outputDir / chosen->second; auto pathCtx = mcpp::fetcher::make_path_ctx(/*cfg=*/nullptr, projectRoot); - mcpp::build::progress::close(); // the program owns the terminal + yield_terminal(); mcpp::ui::status("Running", std::format("`{}`", mcpp::ui::shorten_path(exe, pathCtx))); run_separator(); @@ -2209,7 +2220,7 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, // `runnerDeclared` gate above), so the artifact is the only thing that // could have been refused. int spawnErr = 0; - const int exitRc = mcpp::platform::process::run_exec(argv, childEnv, &spawnErr); + const int exitRc = mcpp::platform::process::run_foreground(argv, childEnv, &spawnErr); if (spawnErr != 0) { using namespace mcpp::build::runner_lookup; const auto triple = mcpp::toolchain::triple::host_triple().str(); @@ -2369,13 +2380,13 @@ int run_artifact_via_runner(mcpp::build::BuildContext& ctx, : slotName; if (!isRunSlot && !runner_from_format && !verb.empty()) verb[0] = static_cast(std::toupper(verb[0])); - mcpp::build::progress::close(); // the program owns the terminal + yield_terminal(); mcpp::ui::status(verb, std::format("`{} … {}`", choice.tmpl.front(), mcpp::ui::shorten_path(exe, pathCtx))); } else { argv.push_back(exe.string()); for (auto& a : passthrough) argv.push_back(a); - mcpp::build::progress::close(); // the program owns the terminal + yield_terminal(); mcpp::ui::status("Running", std::format("`{}`", mcpp::ui::shorten_path(exe, pathCtx))); } @@ -2401,7 +2412,7 @@ int run_artifact_via_runner(mcpp::build::BuildContext& ctx, // message carries the key that would change that. Anything else is reported // as itself — EACCES is a permission problem, not an absence. int spawnErr = 0; - const int rc = mcpp::platform::process::run_exec(argv, childEnv, &spawnErr); + const int rc = mcpp::platform::process::run_foreground(argv, childEnv, &spawnErr); if (spawnErr != 0) { using namespace mcpp::build::runner_lookup; if (!choice.tmpl.empty()) diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index b6a268c5..d2cd860c 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -600,6 +600,13 @@ std::string exports_file_contents(const LinkUnit& lu, std::string_view os) { for (auto const& p : lu.exportPatterns) out += "_" + p + "\n"; return out; } + // PE (MSVC ABI): the patterns themselves, one per line, read by the `.def` + // edge (`mcpp coff-def --exports-file`), which narrows the discovered + // symbols to them. The linker is given the resulting `.def`, not this file. + if (os == "windows") { + for (auto const& p : lu.exportPatterns) out += p + "\n"; + return out; + } // ELF version script. One anonymous version node: naming versions is a // separate capability (symbol VERSIONING, `foo@@LIB_1.0`) that cannot be // stated neutrally, and a package needing it writes the map itself and @@ -612,8 +619,8 @@ std::string exports_file_contents(const LinkUnit& lu, std::string_view os) { // The flag that names the file. PE is absent on purpose: there the export set // is the `.def`, which lu.defFile already declares and the def-generating step -// already writes, so narrowing it is that step's business rather than a second -// flag on the link line. +// writes from the discovered symbols narrowed to these patterns +// (`--exports-file`), rather than a second flag on the link line. std::string exports_flag(const LinkUnit& lu, std::string_view os, const std::filesystem::path& file) { if (lu.kind != LinkUnit::SharedLibrary || lu.exportPatterns.empty()) return ""; @@ -2154,7 +2161,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // POSIX-shell command is skipped entirely on Windows, the only platform this // edge exists for. append("rule coff_def\n"); - append(" command = $mcpp coff-def --output $out --name $def_name $coff_tools $in\n"); + append(" command = $mcpp coff-def --output $out --name $def_name $coff_args $in\n"); append(" description = DEF $out\n\n"); // A WINDOWS PROGRAM'S RUNTIME DLLS, PLACED AFTER ITS LINK (SPEC-007 R4.3). @@ -3192,13 +3199,17 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // drift from it. It is an ordinary explicit input to the link rather // than an implicit one: the linker reads it, so ninja should rebuild the // DLL when it changes. + // + // `exports` narrows the discovered symbols on this edge: the patterns + // are written beside the objects and are an input of the edge, so a + // changed list regenerates the `.def`. `--required` when a link unit + // of this plan consumes the import library, which an empty export + // surface does not produce. if (!lu.defFile.empty()) { std::string defIns; for (auto const& o : lu.objects) defIns += " " + escape_ninja_path(o); - append(std::format("build {} : coff_def{}\n", - escape_ninja_path(lu.defFile), defIns)); - append(std::format(" def_name = {}\n", - lu.output.filename().string())); + std::string coffArgs; + std::string defImplicit; if (mcpp::toolchain::is_clang(plan.toolchain)) { const auto& compiler = plan.toolchain.binaryPath; const auto nm = compiler.parent_path() @@ -3207,10 +3218,27 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, auto nmArg = mcpp::modgraph::try_narrow(nm); if (!cxxArg || !nmArg) throw std::runtime_error("the selected LLVM tools have no UTF-8 spelling"); - append(" coff_tools = --llvm-cxx " + ninja_command_word(*cxxArg) + coffArgs += " --llvm-cxx " + ninja_command_word(*cxxArg) + " --llvm-nm " + ninja_command_word(*nmArg) - + " --llvm-target " + ninja_command_word(plan.toolchain.targetTriple) + "\n"); + + " --llvm-target " + ninja_command_word(plan.toolchain.targetTriple); + } + if (!lu.exportPatterns.empty()) { + const auto rel = std::filesystem::path("obj") / (lu.targetName + ".exports.gen"); + std::error_code mkec; + std::filesystem::create_directories((plan.outputDir / rel).parent_path(), mkec); + write_file(plan.outputDir / rel, exports_file_contents(lu, "windows")); + coffArgs += " --exports-file " + ninja_command_word(rel.generic_string()); + defImplicit = " | " + escape_ninja_path(rel); } + const bool consumed = std::ranges::any_of(plan.linkUnits, [&](const LinkUnit& other) { + return &other != &lu && std::ranges::contains(other.implicitInputs, lu.importLibrary); + }); + if (consumed) coffArgs += " --required"; + append(std::format("build {} : coff_def{}{}\n", + escape_ninja_path(lu.defFile), defIns, defImplicit)); + append(std::format(" def_name = {}\n", + lu.output.filename().string())); + if (!coffArgs.empty()) append(" coff_args =" + coffArgs + "\n"); append("\n"); } diff --git a/src/build/pe_exports.cppm b/src/build/pe_exports.cppm index 21fbf1c3..ee8bc11c 100644 --- a/src/build/pe_exports.cppm +++ b/src/build/pe_exports.cppm @@ -21,11 +21,29 @@ bool ir_declares_exports(std::string_view ir); std::expected, std::string> read_nm_exports(std::string_view text, bool i386); -// Inputs that already declare their exported surface produce an empty result. -// No candidate reader is entered on that path; in particular, annotated COFF -// does not require LLVM tools merely because another input happens to be LTO. -std::expected, std::string> -read_exports(std::span objects, const LLVMTools& tools); +// What the inputs of one MSVC-ABI DLL say about its exported surface. +// +// An input that declares exports itself (`__declspec(dllexport)`, `/EXPORT:` +// directives, linker-option metadata) decides the surface for the whole DLL: +// the linker reads those declarations whatever the `.def` says, so `declaredBy` +// names the first such input and `candidates` is empty. No candidate reader is +// entered on that path; in particular, annotated COFF does not require LLVM +// tools merely because another input happens to be LTO. Otherwise +// `candidates` holds every exportable definition the inputs contain. +struct Discovery { + std::optional declaredBy; + std::vector candidates; +}; +std::expected +discover_exports(std::span objects, const LLVMTools& tools); + +// `[targets.] exports` on the MSVC ABI: the candidates whose name matches +// one of `patterns`, with the glob semantics of an ELF version script (`*`, +// `?`, `[...]`, `[!...]`). A pattern matches the linker-level name: undecorated +// on i386, as coff::export_name spells it, and MSVC-mangled for C++. +bool symbol_matches(std::string_view pattern, std::string_view name); +std::vector narrow(std::vector candidates, + std::span patterns); } // namespace mcpp::build::pe @@ -197,8 +215,26 @@ read_nm_exports(std::string_view text, bool i386) { return out; } -std::expected, std::string> -read_exports(std::span objects, const LLVMTools& tools) { +namespace { + +// The tool runs of one call, `jobs` at a time and in input order. Each +// inspects one object, and the objects of a large LTO DLL number in the +// hundreds, so running them one after another dominated the `.def` edge. +template +void for_each_concurrently(std::size_t count, Fn&& fn) { + const std::size_t jobs = std::clamp(std::thread::hardware_concurrency(), 1, 8); + std::atomic next{0}; + std::vector workers; + for (std::size_t w = 0; w < std::min(jobs, count); ++w) + workers.emplace_back([&] { + for (std::size_t i = next++; i < count; i = next++) fn(i); + }); +} + +} // namespace + +std::expected +discover_exports(std::span objects, const LLVMTools& tools) { auto error = [](const std::filesystem::path& obj, std::string message) { return std::unexpected(mcpp::modgraph::escaped_spelling(obj) + ": " + message); }; @@ -207,7 +243,8 @@ read_exports(std::span objects, const LLVMTools& to auto bytes = read_object(obj); if (!bytes) return error(obj, bytes.error()); bitcode.push_back(is_bitcode(*bytes)); - if (!bitcode.back() && coff::declares_exports(*bytes)) return std::vector{}; + if (!bitcode.back() && coff::declares_exports(*bytes)) + return Discovery{.declaredBy = obj, .candidates = {}}; } std::vector arguments; for (auto const& obj : objects) { @@ -217,34 +254,107 @@ read_exports(std::span objects, const LLVMTools& to } const bool i386 = tools.target.starts_with("i386-") || tools.target.starts_with("i686-") || tools.target.starts_with("x86-"); - for (std::size_t i = 0; i < objects.size(); ++i) { - if (!bitcode[i]) continue; - if (tools.target.empty()) return error(objects[i], "LLVM bitcode inspection requires the selected target triple"); + std::vector lto; + for (std::size_t i = 0; i < objects.size(); ++i) + if (bitcode[i]) lto.push_back(i); + if (!lto.empty() && tools.target.empty()) + return error(objects[lto.front()], "LLVM bitcode inspection requires the selected target triple"); + + // Intent first, for every bitcode input; the first declaring input in + // input order is the one named. + std::vector> declares(lto.size(), false); + for_each_concurrently(lto.size(), [&](std::size_t k) { + const auto i = lto[k]; std::vector args{"--driver-mode=g++", "-S", "-emit-llvm", "-x", "ir", - "--target=" + tools.target}; - args.insert(args.end(), {arguments[i], "-o", "-"}); + "--target=" + tools.target, arguments[i], "-o", "-"}; auto ir = run_tool(tools.compiler, std::move(args)); - if (!ir) return error(objects[i], ir.error()); - if (ir_declares_exports(*ir)) return std::vector{}; + if (!ir) declares[k] = std::unexpected(ir.error()); + else declares[k] = ir_declares_exports(*ir); + }); + for (std::size_t k = 0; k < lto.size(); ++k) { + if (!declares[k]) return error(objects[lto[k]], declares[k].error()); + if (*declares[k]) return Discovery{.declaredBy = objects[lto[k]], .candidates = {}}; } - std::vector all; + + std::vector, std::string>> symbols( + objects.size(), std::vector{}); + for_each_concurrently(lto.size(), [&](std::size_t k) { + const auto i = lto[k]; + auto text = run_tool(tools.nm, {"--quiet", "--format=posix", "--extern-only", "--defined-only", + "--no-demangle", arguments[i]}); + if (!text) symbols[i] = std::unexpected(text.error()); + else symbols[i] = read_nm_exports(*text, i386); + }); + Discovery out; for (std::size_t i = 0; i < objects.size(); ++i) { - std::expected, std::string> symbols; - if (bitcode[i]) { - auto text = run_tool(tools.nm, {"--quiet", "--format=posix", "--extern-only", "--defined-only", - "--no-demangle", arguments[i]}); - if (!text) return error(objects[i], text.error()); - symbols = read_nm_exports(*text, i386); - } else { + if (!bitcode[i]) { auto bytes = read_object(objects[i]); if (!bytes) return error(objects[i], bytes.error()); - symbols = coff::read_exports(*bytes); + symbols[i] = coff::read_exports(*bytes); } - if (!symbols) return error(objects[i], symbols.error()); - all.insert(all.end(), std::make_move_iterator(symbols->begin()), - std::make_move_iterator(symbols->end())); + if (!symbols[i]) return error(objects[i], symbols[i].error()); + out.candidates.insert(out.candidates.end(), + std::make_move_iterator(symbols[i]->begin()), + std::make_move_iterator(symbols[i]->end())); } - return all; + return out; +} + +bool symbol_matches(std::string_view pattern, std::string_view name) { + // Iterative glob with single-star backtracking, as fnmatch does it without + // FNM_PATHNAME: a symbol name has no separator to respect. + std::size_t p = 0, n = 0, starP = std::string_view::npos, starN = 0; + auto class_matches = [&](std::size_t& at, char c) -> std::optional { + // `at` is on '['. Returns nullopt for an unterminated class, which is + // then an ordinary character. + std::size_t i = at + 1; + bool negate = false; + if (i < pattern.size() && (pattern[i] == '!' || pattern[i] == '^')) { negate = true; ++i; } + bool hit = false; + bool first = true; + for (; i < pattern.size() && (first || pattern[i] != ']'); ++i, first = false) { + if (i + 2 < pattern.size() && pattern[i + 1] == '-' && pattern[i + 2] != ']') { + if (pattern[i] <= c && c <= pattern[i + 2]) hit = true; + i += 2; + } else if (pattern[i] == c) { + hit = true; + } + } + if (i >= pattern.size()) return std::nullopt; + at = i; // on ']' + return hit != negate; + }; + while (n < name.size()) { + if (p < pattern.size() && pattern[p] == '*') { + starP = p++; + starN = n; + continue; + } + if (p < pattern.size()) { + if (pattern[p] == '?') { ++p; ++n; continue; } + if (pattern[p] == '[') { + std::size_t at = p; + if (auto m = class_matches(at, name[n])) { + if (*m) { p = at + 1; ++n; continue; } + } else if (name[n] == '[') { ++p; ++n; continue; } + } else if (pattern[p] == name[n]) { ++p; ++n; continue; } + } + if (starP == std::string_view::npos) return false; + p = starP + 1; + n = ++starN; + } + while (p < pattern.size() && pattern[p] == '*') ++p; + return p == pattern.size(); +} + +std::vector narrow(std::vector candidates, + std::span patterns) { + std::erase_if(candidates, [&](const coff::Export& e) { + return std::ranges::none_of(patterns, [&](const std::string& p) { + return symbol_matches(p, e.name); + }); + }); + return candidates; } } // namespace mcpp::build::pe diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 0c120928..196a0f85 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -1491,6 +1491,20 @@ bool source_defines_main(const std::filesystem::path& src) { return false; } +// On the MSVC ABI `exports` narrows the symbols discovery found, so a target +// that turns discovery off has nothing for its patterns to narrow. The same +// manifest is valid on ELF and Mach-O, where `exports` acts on its own, so this +// is refused when an MSVC-ABI row is planned and not when the manifest loads. +std::string exports_without_discovery(const mcpp::manifest::Target& t, + std::string_view package) { + return std::format( + "target '{}' of '{}' sets `exports` and `windows_auto_export = false`: on the " + "MSVC ABI `exports` narrows the symbols that automatic discovery finds, and " + "discovery is off. Declare the exports in the source (__declspec(dllexport)), " + "or remove `windows_auto_export = false`", + t.name, package); +} + std::expected make_plan(const mcpp::manifest::Manifest& manifest, const mcpp::toolchain::Toolchain& tc, @@ -2487,6 +2501,26 @@ make_plan(const mcpp::manifest::Manifest& manifest, } }; + // WHOSE OBJECTS A PROGRAM OF PACKAGE `ownerIndex` LINKS ITSELF (#761, + // #766). Its own package's, even when that package also produces a shared + // image: an executable is an independent program of its package. Those of + // the statics placed in its own image (#646 F1), for the same reason. Not + // another shared package's, nor those of the statics placed in another + // package's image: that image's library supplies them by link. A package + // whose objects a unit links also contributes its direct shared + // dependencies to that unit's link, which is how a static placed in the + // owner's image brings the library it calls. + auto links_objects_of = [&](const std::string& packageName, std::size_t ownerIndex) { + if (packageName == qualified_package_name(packages[ownerIndex].manifest)) return true; + if (sharedDepPackages.contains(packageName)) return false; + if (!placedInImage.contains(packageName)) return true; + auto it = staticsByImagePackage.find(ownerIndex); + return it != staticsByImagePackage.end() + && std::ranges::any_of(it->second, [&](std::size_t s) { + return qualified_package_name(packages[s].manifest) == packageName; + }); + }; + auto append_package_objects = [&](LinkUnit& lu, const std::string& packageName) { for (auto& cu : plan.compileUnits) { if (cu.packageName != packageName) continue; @@ -2511,7 +2545,9 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.dependencyOwned = true; lu.output = dep.output; lu.importLibrary = import_library_for(dep.target, naming); - if (msvcTarget && dep.target.autoExport && !lu.importLibrary.empty()) + if (msvcTarget && !dep.target.windowsAutoExport && !dep.target.exportPatterns.empty()) + return std::unexpected(exports_without_discovery(dep.target, dep.packageName)); + if (msvcTarget && dep.target.windowsAutoExport && !lu.importLibrary.empty()) lu.defFile = std::filesystem::path("bin") / (dep.target.name + ".def"); lu.soname = dep.target.soname; lu.exportPatterns = dep.target.exportPatterns; @@ -2643,7 +2679,9 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.importLibrary = import_library_for(t, naming); // MSVC only: MinGW's linker auto-exports, and generating a second // source of truth for what a DLL exports is how the two disagree. - if (msvcTarget && t.autoExport && !lu.importLibrary.empty()) + if (msvcTarget && !t.windowsAutoExport && !t.exportPatterns.empty()) + return std::unexpected(exports_without_discovery(t, qualified_package_name(manifest))); + if (msvcTarget && t.windowsAutoExport && !lu.importLibrary.empty()) lu.defFile = std::filesystem::path("bin") / (t.name + ".def"); lu.soname = t.soname; lu.exportPatterns = t.exportPatterns; @@ -2877,7 +2915,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, // that package also provides a shared image to other consumers. for (auto const& cu : plan.compileUnits) { if (!closure.contains(cu.packageName)) continue; - if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue; + if (!links_objects_of(cu.packageName, r.packageIndex)) continue; if (mcpp::links_unconditionally(cu.kind)) lu.objects.push_back(cu.object); } if (!r.target.main.empty()) { @@ -2925,7 +2963,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, } for (auto const& cu : plan.compileUnits) { if (!closure.contains(cu.packageName)) continue; - if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue; + if (!links_objects_of(cu.packageName, r.packageIndex)) continue; if (!is_implementation_source(cu.kind)) continue; if (lu.entryMain && cu.source == *lu.entryMain) continue; if (entryFilesAcrossTargets.contains(cu.source)) continue; @@ -2935,9 +2973,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, // compile unit also picked up consumers of its sibling shared image // and made the artifact depend on that image instead of its own code. for (auto i : seen) - if (i == r.packageIndex - || (!sharedDepPackages.contains(qualified_package_name(packages[i].manifest)) - && !placedInImage.contains(qualified_package_name(packages[i].manifest)))) + if (links_objects_of(qualified_package_name(packages[i].manifest), r.packageIndex)) append_direct_shared_deps(lu, i); // In a workspace plan the plan's own line pools the dependencies' // flags and not a member's, so the program links with its closure's @@ -3104,7 +3140,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, // package's shared target supplies its implementation by link. for (auto const& cu : plan.compileUnits) { if (!closure.contains(cu.packageName)) continue; - if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue; + if (!links_objects_of(cu.packageName, mi)) continue; if (mcpp::links_unconditionally(cu.kind)) lu.objects.push_back(cu.object); } if (!t.main.empty() && lu.kind != LinkUnit::StaticLibrary) { @@ -3162,7 +3198,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, const bool entryDefinesMain = lu.entryMain && source_defines_main(*lu.entryMain); for (auto const& cu : plan.compileUnits) { if (!closure.contains(cu.packageName)) continue; - if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue; + if (!links_objects_of(cu.packageName, mi)) continue; if (!is_implementation_source(cu.kind)) continue; if (lu.entryMain && cu.source == *lu.entryMain) continue; if (entryFilesAcrossTargets.contains(cu.source)) continue; @@ -3174,12 +3210,10 @@ make_plan(const mcpp::manifest::Manifest& manifest, } if (lu.kind != LinkUnit::StaticLibrary) { const auto before = lu.implicitInputs.size(); - // The member still links its declared shared dependencies - // even when it also produces a shared target of its own. + // The shared libraries of the packages whose objects it links + // (links_objects_of), its own package's included. for (auto i : closureIdx) - if (i == mi - || (!sharedDepPackages.contains(qualified_package_name(packages[i].manifest)) - && !placedInImage.contains(qualified_package_name(packages[i].manifest)))) + if (links_objects_of(qualified_package_name(packages[i].manifest), mi)) append_direct_shared_deps(lu, i); // The graph-built shared libraries this unit loads are placed // beside it. diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index 36821e99..f403b3ba 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -15,6 +15,7 @@ import mcpp.platform.axis; import mcpp.manifest; import mcpp.source_kind; import mcpp.modgraph.glob; +import mcpp.pm.compat; import mcpp.modgraph.graph; import mcpp.modgraph.scanner; import mcpp.modgraph.validate; @@ -1920,6 +1921,19 @@ static std::expected step6_dependency_build_programs(PrepareS fill_package_build_env(bpEnv, pkg.manifest); state.fillPackEnv(bpEnv, i); bpEnv.requested = pkg.selectedMember; + // A registry or git dependency: its tree is the package store's, + // and its program may not watch files outside it (#766). + { + auto rn = mcpp::pm::compat::resolve_package_name( + pkg.manifest.package.name, pkg.manifest.package.namespace_); + for (auto const& ns : {rn.namespace_, std::string(mcpp::pm::kDefaultNamespace), + std::string{}}) + if (auto it = state.resolved.find(ResolvedKey{ns, rn.shortName}); + it != state.resolved.end()) { + bpEnv.sealedPackage = it->second.source != "path"; + break; + } + } bpEnv.languageModules = pkg.manifest.language.modules; bpEnv.ruleModules = pkg.manifest.buildConfig.ruleModules; if (auto dit = state.deviceSourcesByPackage.find(pkg.root.string()); dit != state.deviceSourcesByPackage.end()) diff --git a/src/cli.cppm b/src/cli.cppm index ffab28db..a7e3bbd7 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -989,6 +989,8 @@ int run(int argc, char** argv) { .option(cl::Option("llvm-cxx").takes_value().value_name("PATH").help("selected LLVM compiler for bitcode inspection")) .option(cl::Option("llvm-nm").takes_value().value_name("PATH").help("llvm-nm from the selected LLVM installation")) .option(cl::Option("llvm-target").takes_value().value_name("TRIPLE").help("selected target triple")) + .option(cl::Option("exports-file").takes_value().value_name("PATH").help("the target's `exports` patterns, one per line")) + .option(cl::Option("required").help("a consumer of this build links the DLL: an empty export surface is an error")) .action(wrap_rc(cmd_coff_def))) .subcommand(cl::App("bmi-equal") .description("(internal: invoked by ninja) Compare two BMIs ignoring the compiler's embedded timestamp") diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 0e9ef994..750e6310 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -18,6 +18,7 @@ import mcpp.build.directives; // the device-slot table import mcpp.build.configure; import mcpp.build.coff_exports; import mcpp.build.pe_exports; +import mcpp.modgraph.glob; // escaped_spelling, for coff-def's warning import mcpp.build.stage; import mcpp.build.schedule.detach_codegen; import mcpp.build.test_targets; @@ -1457,12 +1458,48 @@ export int cmd_coff_def(const mcpplibs::cmdline::ParsedArgs& parsed) { std::vector objects; for (std::size_t i = 0; i < parsed.positional_count(); ++i) objects.emplace_back(parsed.positional(i)); - auto exports = mcpp::build::pe::read_exports(objects, tools); - if (!exports) { - std::println(stderr, "error: {}", exports.error()); + // `[targets.] exports`, one pattern per line (ninja_backend writes it). + std::vector patterns; + if (auto v = parsed.value("exports-file")) { + std::ifstream in(mcpp::platform::fs::extended_length(std::filesystem::path{*v})); + if (!in) { + std::println(stderr, "error: cannot read the exports list '{}'", *v); + return 1; + } + for (std::string line; std::getline(in, line);) { + while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.pop_back(); + if (!line.empty()) patterns.push_back(std::move(line)); + } + } + auto discovery = mcpp::build::pe::discover_exports(objects, tools); + if (!discovery) { + std::println(stderr, "error: {}", discovery.error()); + return 1; + } + // THE PRECEDENCE OF THE THREE SOURCES (docs/04, `exports`): the inputs' + // own declarations decide; otherwise `exports` narrows what discovery + // found; otherwise everything discovered is published. + if (discovery->declaredBy && !patterns.empty()) + std::println(stderr, + "warning: {}: `exports` has no effect on this DLL: '{}' declares its exports " + "in the source (__declspec(dllexport) or /EXPORT:), and the linker publishes " + "exactly those", + libName, mcpp::modgraph::escaped_spelling(*discovery->declaredBy)); + auto all = discovery->declaredBy ? std::vector{} + : patterns.empty() ? std::move(discovery->candidates) + : mcpp::build::pe::narrow(std::move(discovery->candidates), patterns); + // A DLL that publishes nothing gets no import library, and a consumer of + // this build links that import library: say so here, at the DLL, rather + // than as a missing file at the consumer. A DLL nothing links (resources + // only, or loaded for DllMain) is valid as it is. + if (all.empty() && !discovery->declaredBy && parsed.is_flag_set("required")) { + std::println(stderr, + "error: {} exports no symbol, and a consumer in this build links it.\n" + " Declare the exports in the source (__declspec(dllexport)), or let\n" + " `[targets.] exports` patterns match the symbols to publish.", + libName); return 1; } - auto all = std::move(*exports); // Refused, not truncated. A `.def` cut at the ceiling links cleanly and // then fails at whichever consumer happens to need a symbol that fell off diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index c33ef89e..3c2f28ac 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -386,71 +386,8 @@ bool is_module_name_char(char c) { return std::isalnum(static_cast(c)) || c == '_' || c == '.' || c == ':'; } -// mcpp#225: submodule paths registered in `/.gitmodules` ("path = ..." -// entries), resolved to canonical absolute paths. is_excluded_walk_dir is -// called once per directory ENTRY seen during a walk, so this is parsed -// once per root and cached for the life of the process rather than -// re-reading .gitmodules on every call (that would defeat the point of -// bounding the walk). -const std::set& -submodule_paths(const std::filesystem::path& root) { - static std::map> cache; - std::error_code kec; - auto key = std::filesystem::canonical(root, kec); - if (kec) key = root; - if (auto it = cache.find(key); it != cache.end()) return it->second; - - std::set paths; - std::ifstream f(root / ".gitmodules"); - std::string line; - while (f && std::getline(f, line)) { - auto eq = line.find('='); - if (eq == std::string::npos) continue; - std::string_view k = trim(std::string_view(line).substr(0, eq)); - if (k != "path") continue; - std::string_view v = trim(std::string_view(line).substr(eq + 1)); - if (v.empty()) continue; - std::error_code pec; - auto abs = std::filesystem::canonical(root / std::filesystem::path(std::string(v)), pec); - paths.insert(pec ? (root / std::filesystem::path(std::string(v))) : abs); - } - return cache.emplace(key, std::move(paths)).first->second; -} - -// mcpp#225: directory names that never hold project sources — VCS metadata, -// mcpp's own build output, and mcpp's own project-metadata dir (mcpp#230: -// `.mcpp`'s xlings data tree holds a symlink back to each path-dep index -// root, so following it walks that entire checkout). A directory whose path -// matches a `.gitmodules`-registered submodule path under `root` is pruned -// too — submodules are foreign trees, often huge, and not part of this -// package's source glob. -bool is_excluded_walk_dir(const std::filesystem::path& dir, - const std::filesystem::path& root) { - // Compare as paths. Do NOT narrow. - // - // #516: `dir.filename().string()` went through MSVC's wide→ANSI - // conversion and threw std::system_error for any directory name the - // active code page cannot spell (`test/www/Dir/` in cpp-httplib). - // This function is the FIRST line of the walk loop and runs once per - // directory entry, so it fires before `path_matches_glob`'s guard — - // hardening that one (#231) could never cover a directory name. - // - // The three literals are ASCII, so their conversion to the native - // representation is lossless, and `path::operator==` compares native - // strings case-sensitively — byte-for-byte the same decision the narrow - // comparison made. Static constants rather than temporaries per entry: - // #225 bounded this walk for a reason, and this is on its hot path. - static const std::filesystem::path kMcppDir{".mcpp"}; - static const std::filesystem::path kGitDir{".git"}; - static const std::filesystem::path kTargetDir{"target"}; - const auto name = dir.filename(); - if (name == kMcppDir || name == kGitDir || name == kTargetDir) return true; - auto const& submodules = submodule_paths(root); - if (submodules.empty()) return false; - std::error_code ec; - auto c = std::filesystem::canonical(dir, ec); - return submodules.contains(ec ? dir : c); -} +// The exclusions and the walk itself are mcpp.modgraph.glob's +// (is_excluded_walk_dir, walk_glob_tree), shared with build-program inputs. } // namespace @@ -569,46 +506,16 @@ std::shared_ptr walk_tree(const std::filesystem::path& root, }; note_dir(start); - // Follow directory symlinks (vendored trees are often symlink farms). - // Cycle guard: a directory whose canonical path is already on the - // CURRENT recursion chain is a link loop — only that is pruned; the same - // real directory reached via a second lexical path (dir + link to it) - // still walks, because glob matching is lexical. Files reachable twice - // are deduped by canonical identity by the caller. - std::vector chain; // canonical dirs of the recursion stack - std::error_code ec, eec; // ec: iteration; eec: per-entry probes - { - auto c = fs::canonical(start, eec); - chain.push_back(eec ? start : c); - } - fs::recursive_directory_iterator it( - start, fs::directory_options::follow_directory_symlink, ec); - for (fs::recursive_directory_iterator end; !ec && it != end; it.increment(ec)) { - auto& e = *it; - if (e.is_directory(eec) && !eec) { - if (is_excluded_walk_dir(e.path(), root)) { - it.disable_recursion_pending(); - continue; - } - auto depth = static_cast(it.depth()); - chain.resize(std::min(chain.size(), depth + 1)); - auto c = fs::canonical(e.path(), eec); - if (!eec && std::find(chain.begin(), chain.end(), c) != chain.end()) { - it.disable_recursion_pending(); // link cycle - } else { - chain.push_back(eec ? e.path() : c); - note_dir(e.path()); - } - continue; - } - if (!e.is_regular_file(eec) || eec) continue; - auto rel = try_narrow(e.path().lexically_relative(root)); - // A name the code page cannot spell can never match a glob, and is - // recorded as path_matches_glob records it. - if (!rel) note_unnarrowable_path(e.path()); - listing->files.push_back({e.path(), std::move(rel)}); - } - if (ec) listing->trusted = false; + const bool complete = walk_glob_tree(root, start, + [&](const fs::path& d) { note_dir(d); return true; }, + [&](const fs::path& f) { + auto rel = try_narrow(f.lexically_relative(root)); + // A name the code page cannot spell can never match a glob, and is + // recorded as path_matches_glob records it. + if (!rel) note_unnarrowable_path(f); + listing->files.push_back({f, std::move(rel)}); + }); + if (!complete) listing->trusted = false; return listing; } @@ -734,35 +641,15 @@ std::vector expand_dir_glob(const std::filesystem::path& std::error_code startEc; if (!std::filesystem::exists(start, startEc)) return out; - // Walk all directories under start, match each against the glob. Same - // follow-symlinks + recursion-chain cycle guard as expand_glob above. + // Every directory under start, matched against the glob, by the walk the + // file globs take. out.push_back(root); // sentinel, always dropped below regardless of value - std::vector chain; - std::error_code eec; // per-entry probes; ec drives iteration - { - auto c = std::filesystem::canonical(start, eec); - chain.push_back(eec ? start : c); - } - std::filesystem::recursive_directory_iterator it( - start, std::filesystem::directory_options::follow_directory_symlink, ec); - for (std::filesystem::recursive_directory_iterator end; - !ec && it != end; it.increment(ec)) { - auto& e = *it; - if (!e.is_directory(eec) || eec) continue; - if (is_excluded_walk_dir(e.path(), root)) { - it.disable_recursion_pending(); - continue; - } - auto depth = static_cast(it.depth()); - chain.resize(std::min(chain.size(), depth + 1)); - auto c = std::filesystem::canonical(e.path(), eec); - if (!eec && std::find(chain.begin(), chain.end(), c) != chain.end()) { - it.disable_recursion_pending(); // link cycle - continue; - } - chain.push_back(eec ? e.path() : c); - if (path_matches_glob(e.path(), root, glob)) out.push_back(e.path()); - } + walk_glob_tree(root, start, + [&](const std::filesystem::path& d) { + if (path_matches_glob(d, root, glob)) out.push_back(d); + return true; + }, + [](const std::filesystem::path&) {}); out.erase(out.begin()); // drop root sentinel std::sort(out.begin(), out.end()); out.erase(std::unique(out.begin(), out.end()), out.end()); diff --git a/tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh b/tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh index 7e79fb25..4085c734 100755 --- a/tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh +++ b/tests/e2e/880_a_members_executable_keeps_its_own_implementation.sh @@ -213,4 +213,55 @@ cp "$TMP/tooldual/src/main.cpp" src/main.cpp "$(bin_of toolapp)" || fail "the dual-role provider's consumer did not run" [ -n "$(find target -path '*/bin/*' \( -name '*dual_dll.dll' -o -name '*dual_dll.so*' -o -name '*dual_dll.dylib' \) -type f | head -1)" ] || fail "the dual-role provider's shared library is missing" dual-role.log + +# A static placed in the owner's own image (#766). `common` is reached only +# through `dual`, which produces a shared image, so `common` is placed in that +# image. `dual`'s executable links `common`'s objects itself and must then also +# link `common`'s own shared dependency. `client` links `dual`'s image, which +# holds `common`, and not `common`'s objects a second time. +mkdir -p "$TMP/ws2" +cd "$TMP/ws2" +cat > mcpp.toml <<'EOF' +[workspace] +members = ["dual", "client", "support"] +EOF +mkdir -p common/src dual/src client/src support/src +cp "$TMP/ws/support/mcpp.toml" support/mcpp.toml +cp "$TMP/ws/support/src/support.cppm" support/src/support.cppm +cat > common/mcpp.toml <<'EOF' +[package] +name = "common" +version = "0.1.0" + +[dependencies] +support = { path = "../support" } + +[targets.common] +kind = "lib" +EOF +printf 'export module t880_base;\nimport t880_support;\nexport int base_value() { return 40 + delta(); }\n' > common/src/base.cppm +cat > dual/mcpp.toml <<'EOF' +[package] +name = "dual" +version = "0.1.0" + +[dependencies] +common = { path = "../common" } + +[targets.dual_dll] +kind = "shared" + +[targets.dual] +kind = "bin" +main = "src/main.cpp" +EOF +cp "$TMP/ws/dual/src/dual.cppm" dual/src/dual.cppm +printf 'module t880_dual;\nimport t880_base;\nint answer() { return base_value() + 1; }\n' > dual/src/impl.cpp +cp "$TMP/ws/dual/src/main.cpp" dual/src/main.cpp +cp "$TMP/ws/client/mcpp.toml" client/mcpp.toml +cp "$TMP/ws/client/src/main.cpp" client/src/main.cpp +"$MCPP" build --workspace > placed.log 2>&1 || fail "a static placed in the owner's image lost its shared dependency" placed.log +"$(bin_of dual)" || fail "the owner's executable with a placed static did not run" +"$(bin_of client)" || fail "the consumer of an image holding a placed static did not run" + echo "PASS: 880_a_members_executable_keeps_its_own_implementation" diff --git a/tests/e2e/881_pe_auto_exports_accept_llvm_bitcode.sh b/tests/e2e/881_pe_auto_exports_accept_llvm_bitcode.sh index f28fbdde..e4d2c30f 100644 --- a/tests/e2e/881_pe_auto_exports_accept_llvm_bitcode.sh +++ b/tests/e2e/881_pe_auto_exports_accept_llvm_bitcode.sh @@ -103,7 +103,7 @@ def=$(find annotated/target -name annotated.def | head -1) # C: native-only export control never depends on coff-def. printf '\n' >> annotated/mcpp.toml -sed 's/kind = "shared"/kind = "shared"\nauto_export = false/' annotated/mcpp.toml > native.toml +sed 's/kind = "shared"/kind = "shared"\nwindows_auto_export = false/' annotated/mcpp.toml > native.toml mv native.toml annotated/mcpp.toml (cd annotated && "$MCPP" build --profile release > native.log 2>&1) || fail "C: opt-out did not build" annotated/native.log nj=$(find annotated/target -name build.ninja | head -1) @@ -166,4 +166,77 @@ grep -q 'literal_value DATA' union.def || fail "G: bitcode data was lost" union. "$compiler" --driver-mode=g++ --target=i686-pc-windows-msvc -flto=thin -c literal.cpp -o x86.obj "$MCPP" coff-def --output x86.def --name x86 --llvm-cxx "$compiler" --llvm-nm "$nm" --llvm-target i686-pc-windows-msvc x86.obj > x86.log 2>&1 || fail "H: x86 bitcode discovery failed" x86.log grep -q 'literal_value DATA' x86.def || fail "H: x86 cdecl decoration was not normalized" x86.def + +# I: `exports` narrows what discovery finds (#766). Only the matching symbols +# are published, data keeps its DATA keyword, and the rest is not exported. +mkdir -p narrowed/src +cat > narrowed/mcpp.toml <<'EOF' +[package] +name = "narrowed" +version = "0.1.0" +[toolchain] +windows = "llvm@20.1.7" +[targets.narrowed] +kind = "shared" +exports = ["keep_*"] +EOF +cat > narrowed/src/api.cpp <<'EOF' +extern "C" int keep_api() { return 29; } +extern "C" int keep_value = 31; +extern "C" int drop_api() { return 37; } +EOF +(cd narrowed && "$MCPP" build > build.log 2>&1) || fail "I: the narrowed DLL did not build" narrowed/build.log +narrowedDef=$(find narrowed/target -name narrowed.def | head -1) +grep -q 'keep_api$' "$narrowedDef" || fail "I: a matching function was not exported" "$narrowedDef" +grep -q 'keep_value DATA' "$narrowedDef" || fail "I: matching data lost its DATA keyword" "$narrowedDef" +if grep -q 'drop_api' "$narrowedDef"; then fail "I: a symbol outside the patterns was exported" "$narrowedDef"; fi +narrowedDll=$(find narrowed/target -name narrowed.dll | head -1) +python3 - "$narrowedDll" <<'PY' +import ctypes, os, sys +lib = ctypes.CDLL(os.path.abspath(sys.argv[1])) +assert lib.keep_api() == 29 +assert ctypes.c_int.in_dll(lib, "keep_value").value == 31 +try: + lib.drop_api +except AttributeError: + pass +else: + raise AssertionError("drop_api is published") +PY + +# J: beside source annotations `exports` cannot narrow, and says so. +printf 'keep_*\n' > patterns.txt +"$MCPP" coff-def --output warned.def --name warned --exports-file patterns.txt annotated.obj > warned.log 2>&1 \ + || fail "J: coff-def failed beside annotations" warned.log +grep -q '`exports` has no effect on this DLL' warned.log || fail "J: no warning beside annotations" warned.log + +# K: `exports` with discovery off has nothing to narrow, and is refused when +# an MSVC-ABI row is planned. +mkdir -p contradictory/src +cat > contradictory/mcpp.toml <<'EOF' +[package] +name = "contradictory" +version = "0.1.0" +[toolchain] +windows = "llvm@20.1.7" +[targets.contradictory] +kind = "shared" +exports = ["api_*"] +windows_auto_export = false +EOF +printf 'extern "C" int api_one() { return 1; }\n' > contradictory/src/api.cpp +if (cd contradictory && "$MCPP" build > build.log 2>&1); then + fail "K: exports with windows_auto_export = false was accepted" contradictory/build.log +fi +grep -q 'windows_auto_export = false' contradictory/build.log || fail "K: the refusal did not name the keys" contradictory/build.log + +# L: a DLL a consumer links must export something; one nothing links may not. +printf 'static int internal_only() { return 1; }\n' > empty.cpp +"$compiler" --driver-mode=g++ --target=x86_64-pc-windows-msvc -c empty.cpp -o empty.obj +"$MCPP" coff-def --output empty.def --name empty empty.obj > empty.log 2>&1 \ + || fail "L: an empty surface without a consumer was refused" empty.log +if "$MCPP" coff-def --output empty.def --name empty --required empty.obj > required.log 2>&1; then + fail "L: an empty surface a consumer links was accepted" required.log +fi +grep -q 'exports no symbol, and a consumer in this build links it' required.log || fail "L: the refusal was not stated" required.log echo "PASS: 881_pe_auto_exports_accept_llvm_bitcode" diff --git a/tests/e2e/882_parent_directory_glob_inputs.sh b/tests/e2e/882_parent_directory_glob_inputs.sh index 057820ec..6bdd4953 100755 --- a/tests/e2e/882_parent_directory_glob_inputs.sh +++ b/tests/e2e/882_parent_directory_glob_inputs.sh @@ -82,4 +82,68 @@ for step in content unchanged; do fi done + +# An absolute pattern is matched against absolute paths (#766): the root +# package may watch a directory outside its tree by its absolute name. +INPUTS_ABS=$(cd ../inputs && pwd -P) +sed -i.bak "s|\"../inputs/\\*\\*/\\*.in\"|\"$INPUTS_ABS/**/*.in\"|" build.mcpp +grep -q "$INPUTS_ABS/\*\*/\*.in" build.mcpp || fail "the fixture did not take the absolute pattern" +build_count absolute 1 +printf 'c\n' > ../inputs/c.in +build_count absolute-added 2 +rm ../inputs/c.in +mv build.mcpp.bak build.mcpp + +# A pattern no walk enters is reported, not silently kept as an empty set. +cp build.mcpp build.mcpp.bak +awk '{ print } /rerun_if_changed_glob\("\.\.\/inputs/ { print " mcpp::rerun_if_changed_glob(\"target/**/*.in\");" }' \ + build.mcpp.bak > build.mcpp +grep -q 'rerun_if_changed_glob("target/' build.mcpp || fail "the fixture did not take the second glob" +"$MCPP" build > "$TMP/logs/unwatchable.log" 2>&1 || fail "build with an unwatchable glob failed" +grep -q 'rerun_if_changed_glob("target/\*\*/\*.in") can never re-run it' "$TMP/logs/unwatchable.log" \ + || fail "the unwatchable glob was not reported" +mv build.mcpp.bak build.mcpp + +# A git dependency is sealed: its program may not watch outside its own tree. +mkdir -p "$TMP/origin/src" "$TMP/consumer/src" +cd "$TMP/origin" +git init --quiet +git config user.email "test@local" +git config user.name test +cat > mcpp.toml <<'EOF' +[package] +name = "sealed" +version = "0.1.0" + +[targets.sealed] +kind = "lib" +EOF +printf 'export module t882_sealed;\nexport int sealed_value() { return 1; }\n' > src/sealed.cppm +cat > build.mcpp <<'EOF' +import mcpp; +int main() { + mcpp::rerun_if_changed_glob("../outside/**/*.in"); + return 0; +} +EOF +git add -A >/dev/null +git commit --quiet -m init +REV=$(git rev-parse HEAD) +ORIGIN_HOST=$(host_path "$TMP/origin") +cd "$TMP/consumer" +cat > mcpp.toml < src/main.cpp +if "$MCPP" build > "$TMP/logs/sealed.log" 2>&1; then + fail "a git dependency's glob that leaves its package was accepted" +fi +grep -q "which leaves the package" "$TMP/logs/sealed.log" \ + || fail "the refusal did not name the escaping glob" + echo "PASS: parent-directory glob inputs invalidate builds on membership changes" diff --git a/tests/e2e/883_run_hands_the_terminal_to_the_program.sh b/tests/e2e/883_run_hands_the_terminal_to_the_program.sh new file mode 100755 index 00000000..6bbff0a7 --- /dev/null +++ b/tests/e2e/883_run_hands_the_terminal_to_the_program.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# requires: python3 unix-shell +# 883 -- `mcpp run` hands the terminal to the program: it reads the terminal's +# input, receives the terminal's Ctrl-C, and its exit status reaches the caller +# unchanged. Before 2026.10.5.1 the program ran in a background process group, +# where its first read stopped it with SIGTTIN and Ctrl-C killed it instead of +# reaching its handler. +set -euo pipefail +source "$(dirname "$0")/_host_path.sh" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +MCPP="${MCPP:-mcpp}" +REGISTRY_HOST=$(host_path "${MCPP_HOME:-$HOME/.mcpp}/registry") +export MCPP_HOME="$TMP/mcpp-home" +mkdir -p "$MCPP_HOME" +cat > "$MCPP_HOME/config.toml" </dev/null; done; exit 1; } + +mkdir -p "$TMP/app/src" +cd "$TMP/app" +cat > mcpp.toml <<'EOF' +[package] +name = "t883" +version = "0.1.0" +EOF +cat > src/main.cpp <<'EOF' +#include +#include +#include +#include +#include +#include +#include + +volatile std::sig_atomic_t interrupted = 0; + +int main(int argc, char** argv) { + const std::string mode = argc > 1 ? argv[1] : "echo"; + if (mode == "echo") { + std::string line; + while (std::cout << "> " << std::flush, std::getline(std::cin, line)) { + if (line == "quit") break; + std::cout << "read: " << line << std::endl; + } + return 0; + } + if (mode == "interrupt") { + std::signal(SIGINT, [](int) { interrupted = 1; }); + std::puts("ready"); + std::fflush(stdout); + while (!interrupted) ::usleep(20000); + std::puts("handled"); + std::fflush(stdout); + return 3; + } + if (mode == "abort") std::abort(); + if (mode == "forever") { + std::printf("pid %d\n", static_cast(::getpid())); + std::fflush(stdout); + for (;;) ::pause(); + } + return 2; +} +EOF + +"$MCPP" build > build.log 2>&1 || fail "the fixture did not build" build.log + +# One driver: a pseudo-terminal in front of mcpp, as a user's terminal is. +cat > "$TMP/drive.py" <<'EOF' +import os, pty, select, signal, sys, time + +def run(argv, keys, settle=1.0, deadline=120.0): + pid, fd = pty.fork() + if pid == 0: + os.execvp(argv[0], argv) + out = b"" + def drain(seconds): + nonlocal out + end = time.time() + seconds + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.1) + if r: + try: + chunk = os.read(fd, 4096) + except OSError: + return False + if not chunk: + return False + out += chunk + return True + # Wait for the program's first output before typing. + end = time.time() + deadline + while time.time() < end and keys and keys[0][0] not in out: + if not drain(0.2): + break + for wait_for, data in keys: + end = time.time() + deadline + while time.time() < end and wait_for not in out: + if not drain(0.2): + break + os.write(fd, data) + end = time.time() + deadline + status = None + while time.time() < end: + drain(0.2) + p, st = os.waitpid(pid, os.WNOHANG) + if p: + status = st + break + if status is None: + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + print(out.decode(errors="replace")) + print("STATUS hung") + return + drain(settle) + print(out.decode(errors="replace")) + print("STATUS", os.waitstatus_to_exitcode(status)) + +mode = sys.argv[1] +argv = sys.argv[2:] +if mode == "echo": + run(argv, [(b"> ", b"hello\r"), (b"read: hello", b"quit\r")]) +elif mode == "interrupt": + run(argv, [(b"ready", b"\x03")]) +EOF + +# 1. Line input on a terminal, through the cached fast path and through the +# prepared path (a profile bypasses the fast path). +python3 "$TMP/drive.py" echo "$MCPP" run > fast.log 2>&1 || true +grep -q "read: hello" fast.log && grep -q "STATUS 0" fast.log \ + || fail "the program did not read the terminal through the fast path" fast.log +python3 "$TMP/drive.py" echo "$MCPP" run -q --release > prepared.log 2>&1 || true +grep -q "read: hello" prepared.log && grep -q "STATUS 0" prepared.log \ + || fail "the program did not read the terminal through the prepared path" prepared.log + +# 2. Ctrl-C reaches the program's handler, and its status is the caller's. +python3 "$TMP/drive.py" interrupt "$MCPP" run -q -- interrupt > interrupt.log 2>&1 || true +grep -q "handled" interrupt.log && grep -q "STATUS 3" interrupt.log \ + || fail "Ctrl-C did not reach the program's handler" interrupt.log + +# 3. Piped input, no terminal: unchanged. +printf 'piped\nquit\n' | "$MCPP" run -q > piped.log 2>&1 || fail "the piped run failed" piped.log +grep -q "read: piped" piped.log || fail "the program did not read piped input" piped.log + +# 4. A death by signal reaches the caller as that signal, as a direct run does. +direct=$(python3 -c 'import subprocess,sys; print(subprocess.run(sys.argv[1:]).returncode)' \ + "$(find target -path '*/bin/t883' -type f | head -1)" abort 2>/dev/null) +viarun=$(python3 -c 'import subprocess,sys; print(subprocess.run(sys.argv[1:]).returncode)' \ + "$MCPP" run -q -- abort 2>/dev/null) +[[ "$direct" == "$viarun" ]] || fail "status of a signal death: direct $direct, through mcpp run $viarun" + +# 5. Nothing outlives a terminated `mcpp run`: the run IS the program. +python3 - "$MCPP" > forever.log 2>&1 <<'EOF' || fail "a terminated run left a process behind" forever.log +import os, signal, subprocess, sys, time +p = subprocess.Popen([sys.argv[1], "run", "-q", "--", "forever"], stdout=subprocess.PIPE) +line = p.stdout.readline().decode() +assert line.startswith("pid "), line +child = int(line.split()[1]) +assert child == p.pid, f"the program runs as pid {child}, not as the run's own pid {p.pid}" +p.send_signal(signal.SIGTERM) +p.wait(timeout=30) +time.sleep(0.2) +try: + os.kill(child, 0) + print("still running:", child) + sys.exit(1) +except ProcessLookupError: + print("terminated with the run") +EOF + +echo "PASS: 883_run_hands_the_terminal_to_the_program" diff --git a/tests/e2e/_timeout.py b/tests/e2e/_timeout.py new file mode 100755 index 00000000..7d84362a --- /dev/null +++ b/tests/e2e/_timeout.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""The per-test bound of run_all.sh where neither GNU `timeout` nor `gtimeout` +exists (the macOS runners). + +Usage: _timeout.py SECONDS COMMAND [ARG...] + +The command runs in a session of its own, so the bound ends the whole tree a +test started (mcpp, ninja, compilers), not only its shell. At the deadline the +group receives SIGTERM, and SIGKILL ten seconds later. The exit status follows +GNU timeout: 124 when the deadline was reached, the command's own status +otherwise. +""" +import os +import signal +import subprocess +import sys + + +def main() -> int: + if len(sys.argv) < 3: + print("usage: _timeout.py SECONDS COMMAND [ARG...]", file=sys.stderr) + return 125 + seconds = float(sys.argv[1]) + child = subprocess.Popen(sys.argv[2:], start_new_session=True) + try: + return child.wait(timeout=seconds) + except subprocess.TimeoutExpired: + pass + for sig, grace in ((signal.SIGTERM, 10), (signal.SIGKILL, None)): + try: + os.killpg(child.pid, sig) + except ProcessLookupError: + break + try: + child.wait(timeout=grace) + break + except subprocess.TimeoutExpired: + continue + return 124 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh index 57c6f3dc..c5fc73c2 100755 --- a/tests/e2e/run_all.sh +++ b/tests/e2e/run_all.sh @@ -346,16 +346,20 @@ check_requires() { # Per-test timeout: bail out of an individual test that gets stuck (e.g. # 10_env_command.sh has been observed hanging for the full job budget on # slow xlings/network combinations). 600s default; override via env. -# Linux + git-bash on Windows have GNU `timeout`; macOS may need `gtimeout` -# (coreutils). If neither is present, we run without a wrapper and rely on -# the step-level GitHub Actions timeout-minutes as the backstop. +# Linux + git-bash on Windows have GNU `timeout`; macOS has `gtimeout` only +# with coreutils, and the hosted macOS runners have neither, so the bound +# falls back to `_timeout.py`, which ends the test's whole process tree. Without +# a bound, one hung test (721 on macOS, 2026-10-04) consumed the shard's +# step-level budget and failed every test after it, and the coverage job that +# reads the shard's report. E2E_TEST_TIMEOUT="${E2E_TEST_TIMEOUT:-600}" -TIMEOUT_CMD="" -if command -v timeout &>/dev/null; then TIMEOUT_CMD=timeout -elif command -v gtimeout &>/dev/null; then TIMEOUT_CMD=gtimeout +TIMEOUT_CMD=() +if command -v timeout &>/dev/null; then TIMEOUT_CMD=(timeout) +elif command -v gtimeout &>/dev/null; then TIMEOUT_CMD=(gtimeout) +elif command -v python3 &>/dev/null; then TIMEOUT_CMD=(python3 "$HERE/_timeout.py") fi -if [[ -n "$TIMEOUT_CMD" ]]; then - echo "Per-test timeout: ${E2E_TEST_TIMEOUT}s (via $TIMEOUT_CMD)" +if [[ ${#TIMEOUT_CMD[@]} -gt 0 ]]; then + echo "Per-test timeout: ${E2E_TEST_TIMEOUT}s (via ${TIMEOUT_CMD[*]})" else echo "Per-test timeout: (no timeout/gtimeout on PATH)" fi @@ -505,8 +509,8 @@ for test in "$HERE"/[0-9]*.sh; do fi echo "=== $name ===" _start_ms=$(_t_ms) - if [[ -n "$TIMEOUT_CMD" ]]; then - MCPP="$MCPP" "$TIMEOUT_CMD" "$E2E_TEST_TIMEOUT" bash "$test" + if [[ ${#TIMEOUT_CMD[@]} -gt 0 ]]; then + MCPP="$MCPP" "${TIMEOUT_CMD[@]}" "$E2E_TEST_TIMEOUT" bash "$test" else MCPP="$MCPP" bash "$test" fi diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 2ef6e772..92c96392 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -10,24 +10,24 @@ import mcpp.platform; import mcpp.build.prepare; // merge_conditional_config: a row's statement replaces TEST(Manifest, PeAutoExportDefaultsOnAndAcceptsAnExplicitOptOut) { - for (auto const& declaration : {"", "auto_export = true", "auto_export = false"}) { + for (auto const& declaration : {"", "windows_auto_export = true", "windows_auto_export = false"}) { auto m = mcpp::manifest::parse_string(std::format( "[package]\nname = \"dll\"\nversion = \"0.1.0\"\n" "[targets.dll]\nkind = \"shared\"\n{}\n", declaration)); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets.front().autoExport, std::string_view(declaration) != "auto_export = false"); + EXPECT_EQ(m->targets.front().windowsAutoExport, std::string_view(declaration) != "windows_auto_export = false"); EXPECT_TRUE(m->schemaWarnings.empty()); } auto bad = mcpp::manifest::parse_string( "[package]\nname = \"dll\"\nversion = \"0.1.0\"\n" - "[targets.dll]\nkind = \"shared\"\nauto_export = \"false\"\n"); + "[targets.dll]\nkind = \"shared\"\nwindows_auto_export = \"false\"\n"); ASSERT_FALSE(bad.has_value()); - EXPECT_NE(bad.error().format().find("auto_export must be a boolean"), std::string::npos); + EXPECT_NE(bad.error().format().find("windows_auto_export must be a boolean"), std::string::npos); } TEST(SynthesizeFromXpkgLua, PeAutoExportHasTheSameDefaultAndBooleanContract) { - for (auto const& declaration : {"", "auto_export = true,", "auto_export = false,"}) { + for (auto const& declaration : {"", "windows_auto_export = true,", "windows_auto_export = false,"}) { auto m = mcpp::manifest::synthesize_from_xpkg_lua(std::format(R"( package = {{ spec = "1", name = "dll", @@ -37,17 +37,17 @@ package = {{ )", declaration), "dll", "0.1.0", mcpp::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets.front().autoExport, std::string_view(declaration) != "auto_export = false,"); + EXPECT_EQ(m->targets.front().windowsAutoExport, std::string_view(declaration) != "windows_auto_export = false,"); } auto bad = mcpp::manifest::synthesize_from_xpkg_lua(R"( package = { spec = "1", name = "dll", xpm = { windows = { ["0.1.0"] = { url = "u", sha256 = "h" } } }, - mcpp = { sources = { "*/api.cpp" }, targets = { ["dll"] = { kind = "shared", auto_export = "false" } } }, + mcpp = { sources = { "*/api.cpp" }, targets = { ["dll"] = { kind = "shared", windows_auto_export = "false" } } }, } )", "dll", "0.1.0", mcpp::platform::HostPlatform::current()); ASSERT_FALSE(bad.has_value()); - EXPECT_NE(bad.error().format().find("auto_export must be a boolean"), std::string::npos); + EXPECT_NE(bad.error().format().find("windows_auto_export must be a boolean"), std::string::npos); } TEST(Manifest, CppFlyStandard) { diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 59756d3d..6b27d119 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -97,7 +97,7 @@ TEST(NinjaBackend, BitcodeExportToolsComeFromTheSelectedLlvmAndAreQuotedAsWords) EXPECT_NE(text.find("build bin/probe.def : coff_def obj/probe.o"), std::string::npos); plan.toolchain.compiler = mcpp::toolchain::CompilerId::MSVC; plan.toolchain.binaryPath = "/native/bin/cl.exe"; - EXPECT_EQ(emit_ninja_string(plan).find("coff_tools ="), std::string::npos); + EXPECT_EQ(emit_ninja_string(plan).find("coff_args ="), std::string::npos); } TEST(NinjaBackend, ObjectiveCSourceUsesCObjectRuleAndCFlags) { @@ -2885,3 +2885,39 @@ TEST(NinjaBackendEncoding, OutputThatNamesNoEncodingDecidesNothing) { EXPECT_FALSE(ninja_encoding_mismatch("ninja: error: unknown tool 'wincodepage'", 65001, "ninja").has_value()); } + +TEST(NinjaBackend, PeExportsPatternsAndAConsumerReachTheDefEdge) { + auto plan = minimal_plan(); + plan.toolchain.compiler = mcpp::toolchain::CompilerId::MSVC; + plan.toolchain.targetTriple = "x86_64-pc-windows-msvc"; + plan.toolchain.binaryPath = "/native/bin/cl.exe"; + plan.outputDir = std::filesystem::temp_directory_path() / "mcpp-ninja-pe-exports"; + LinkUnit dll; + dll.kind = LinkUnit::SharedLibrary; + dll.targetName = "probe"; + dll.output = "bin/probe.dll"; + dll.importLibrary = "bin/probe.lib"; + dll.defFile = "bin/probe.def"; + dll.exportPatterns = {"probe_*"}; + dll.objects = {"obj/probe.o"}; + plan.linkUnits.push_back(dll); + auto alone = emit_ninja_string(plan); + EXPECT_NE(alone.find("build bin/probe.def : coff_def obj/probe.o | obj/probe.exports.gen"), + std::string::npos) << alone; + EXPECT_NE(alone.find("--exports-file obj/probe.exports.gen"), std::string::npos); + EXPECT_EQ(alone.find("--required"), std::string::npos); + std::ifstream in(plan.outputDir / "obj" / "probe.exports.gen"); + std::string written((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + EXPECT_EQ(written, "probe_*\n"); + + LinkUnit consumer; + consumer.kind = LinkUnit::Binary; + consumer.targetName = "app"; + consumer.output = "bin/app.exe"; + consumer.objects = {"obj/app.o"}; + consumer.implicitInputs = {"bin/probe.dll", "bin/probe.lib"}; + plan.linkUnits.push_back(consumer); + EXPECT_NE(emit_ninja_string(plan).find("--required"), std::string::npos); + std::error_code ec; + std::filesystem::remove_all(plan.outputDir, ec); +} diff --git a/tests/unit/test_pe_exports.cpp b/tests/unit/test_pe_exports.cpp index 1eb49691..e453eb8c 100644 --- a/tests/unit/test_pe_exports.cpp +++ b/tests/unit/test_pe_exports.cpp @@ -103,7 +103,7 @@ TEST(PeExports, OptOutAffectsOnlyTheMsvcAbiSharedLinkForm) { manifest.package.version = "0.1.0"; manifest.package.standard = "c++23"; manifest.targets.push_back({.name = "probe", - .kind = mcpp::manifest::Target::SharedLibrary, .autoExport = enabled}); + .kind = mcpp::manifest::Target::SharedLibrary, .windowsAutoExport = enabled}); mcpp::toolchain::Toolchain tc; tc.compiler = mcpp::toolchain::CompilerId::Clang; tc.targetTriple = triple; @@ -119,3 +119,57 @@ TEST(PeExports, OptOutAffectsOnlyTheMsvcAbiSharedLinkForm) { } } } + +TEST(PeExports, SymbolPatternsFollowVersionScriptGlobs) { + using mcpp::build::pe::symbol_matches; + EXPECT_TRUE(symbol_matches("vk_icd*", "vk_icdGetInstanceProcAddr")); + EXPECT_TRUE(symbol_matches("vk_icd*", "vk_icd")); + EXPECT_FALSE(symbol_matches("vk_icd*", "vkGetInstanceProcAddr")); + EXPECT_TRUE(symbol_matches("api_?", "api_x")); + EXPECT_FALSE(symbol_matches("api_?", "api_xy")); + EXPECT_TRUE(symbol_matches("api_[a-c]*", "api_b_open")); + EXPECT_FALSE(symbol_matches("api_[!a-c]*", "api_b_open")); + EXPECT_TRUE(symbol_matches("*Plugin*", "?createPlugin@@YAPEAXXZ")); + EXPECT_TRUE(symbol_matches("exact", "exact")); + EXPECT_FALSE(symbol_matches("exact", "exactly")); + EXPECT_TRUE(symbol_matches("*", "")); + EXPECT_TRUE(symbol_matches("a[b", "a[b")); // an unterminated class is a character +} + +TEST(PeExports, NarrowKeepsTheMatchingCandidatesAndTheirDataFlag) { + std::vector all{ + {"plugin_open", false}, {"plugin_table", true}, {"helper", false}}; + const std::vector patterns{"plugin_*"}; + auto kept = mcpp::build::pe::narrow(all, patterns); + ASSERT_EQ(kept.size(), 2u); + EXPECT_EQ(kept[0].name, "plugin_open"); + EXPECT_FALSE(kept[0].data); + EXPECT_EQ(kept[1].name, "plugin_table"); + EXPECT_TRUE(kept[1].data); +} + +TEST(PeExports, ExportsWithoutDiscoveryIsRefusedOnlyOnMsvcAbiRows) { + for (auto triple : {"x86_64-pc-windows-msvc", "x86_64-w64-mingw32", "x86_64-linux-gnu"}) { + mcpp::manifest::Manifest manifest; + manifest.package.name = "probe"; + manifest.package.version = "0.1.0"; + manifest.package.standard = "c++23"; + manifest.targets.push_back({.name = "probe", + .kind = mcpp::manifest::Target::SharedLibrary, + .windowsAutoExport = false, .exportPatterns = {"probe_*"}}); + mcpp::toolchain::Toolchain tc; + tc.compiler = mcpp::toolchain::CompilerId::Clang; + tc.targetTriple = triple; + mcpp::modgraph::PackageRoot root; + root.root = std::filesystem::temp_directory_path() / "mcpp-pe-plan"; + root.manifest = manifest; + const auto plan = mcpp::build::make_plan(manifest, tc, {}, {}, {}, {root}, + root.root, root.root / "target", {}, {}); + if (std::string_view(triple).ends_with("windows-msvc")) { + ASSERT_FALSE(plan.has_value()) << triple; + EXPECT_NE(plan.error().find("windows_auto_export = false"), std::string::npos); + } else { + EXPECT_TRUE(plan.has_value()) << triple << ": " << (plan ? "" : plan.error()); + } + } +} From 62c4a0db3b03327d6cc6096d5157b303600d793a Mon Sep 17 00:00:00 2001 From: xlings-ci Date: Mon, 5 Oct 2026 13:48:23 +0900 Subject: [PATCH 2/2] test: spell the absolute glob of E2E 882 in the host's form Git Bash's pwd prints /d/a/..., which is not an absolute Windows path; the fixture now writes the host_path spelling, D:/a/..., into build.mcpp. --- tests/e2e/882_parent_directory_glob_inputs.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/e2e/882_parent_directory_glob_inputs.sh b/tests/e2e/882_parent_directory_glob_inputs.sh index 6bdd4953..ca55e714 100755 --- a/tests/e2e/882_parent_directory_glob_inputs.sh +++ b/tests/e2e/882_parent_directory_glob_inputs.sh @@ -85,9 +85,10 @@ done # An absolute pattern is matched against absolute paths (#766): the root # package may watch a directory outside its tree by its absolute name. -INPUTS_ABS=$(cd ../inputs && pwd -P) -sed -i.bak "s|\"../inputs/\\*\\*/\\*.in\"|\"$INPUTS_ABS/**/*.in\"|" build.mcpp -grep -q "$INPUTS_ABS/\*\*/\*.in" build.mcpp || fail "the fixture did not take the absolute pattern" +# The host's own spelling: on Windows `C:/...`, not Git Bash's `/c/...`. +INPUTS_HOST=$(host_path "$(cd ../inputs && pwd -P)") +sed -i.bak "s|\"../inputs/\\*\\*/\\*.in\"|\"$INPUTS_HOST/**/*.in\"|" build.mcpp +grep -q "$INPUTS_HOST/\*\*/\*.in" build.mcpp || fail "the fixture did not take the absolute pattern" build_count absolute 1 printf 'c\n' > ../inputs/c.in build_count absolute-added 2