feat: 新增项目级构建 Hook - #530
Conversation
4f04364 to
1033254
Compare
Follow-up to the [hooks] feature. Five things the first shape got wrong, in
descending order of how loudly they fail.
1. Windows: the hook never ran. `run_shell_deadline` built the command line
with `windows_command_from_argv({"cmd.exe","/d","/s","/c", command})`, which
quotes every token — so the switches stopped being switches and the command
arrived carrying a quote pair cmd.exe does not consume:
'"echo start>>hooks.log' is not recognized as an internal or external
command
That is mcpp-community#425 one layer up (`cmd.exe /c` does not use CreateProcess argument
quoting). A shell command now has its own host-independent shaper,
`windows_shell_command_line` — bare switches plus the single outer pair /s
strips — and its own tests in test_windows_command_line.cpp, which compile
on every platform. The Windows branch is unreachable on the machines this
is developed on; a unit test is the only thing that can fail there first.
2. `[hooks]` is a section of mcpp.toml, so mcpp.manifest parses it. The
feature came with a second, independent reader of the same file. It cost a
redundant parse on every build, and it reported ITS syntax errors in ITS
vocabulary: any manifest typo, anywhere in the file, came out as
`error: invalid hook configuration: ...` for every project whether or not
it used hooks. `Manifest::hooks` now carries the config, unknown keys warn
(and error under --strict) like every other section, and mcpp.hooks is left
with the part that is policy rather than grammar.
3. A hook no longer moves mcpp's working directory. The launchers already
carry a per-child cwd (posix_spawn_file_actions_addchdir_np,
lpCurrentDirectory); `run_shell_deadline` takes one, and the
chdir-and-restore dance around the call is gone.
4. The fast-path veto moved into try_fast_build, next to the manifest read
that answers it — cmd_build no longer loads the manifest a second time to
ask whether it may call a function that loads it again.
5. Coverage the claims did not have: workspace members (each member's own
hooks, in that member's root, root manifest silent), preparation failing
fires nothing, an unknown key warns and the known ones still run, and the
e2e compares hook logs by CONTENT — cmd.exe writes CRLF, so the previous
`$'start\nfinished'` comparison could not have passed on Windows even with
the command line fixed. Plus seven manifest tests for the grammar.
Docs (en + zh) state what was implicit: which commands run hooks and which
deliberately do not, what a virtual workspace root does, that an active hook
opts the project out of the fast path, that preparation failure is silent, and
that a `[hooks]` table is executable content in a repository you may have just
cloned.
1033254 to
9b9f026
Compare
…uild`
Adds the background command the feature was asked for — music that plays for
the length of the build and stops when it ends, optionally restarting — by
unifying it with the hooks that already existed rather than bolting a mode
onto them.
## The model
A hook is a command mcpp OWNS FOR AN INTERVAL. The event names the interval.
build_start / build_finished / build_failed opens at the event,
closes when the command exits
during_build opens before the build,
closes after it
The first three are SELF-CLOSING, and "synchronous" stops being a separate
mode: it is what an interval closed by its own command looks like. Everything
that would otherwise be a special case for `during_build` falls out of that one
difference instead of being declared:
* `timeout_seconds` bounds one run, so it does not apply where the build
already bounds it — and is rejected there rather than reinterpreted.
* `loop` restarts a command that ended before its interval did, which a
self-closing interval makes impossible — so it is rejected there too, with
a message naming `during_build`.
* `side_effect` is unchanged. For `during_build`, "failure" means could not
start, or failed to stay up. Being stopped because the interval closed is
not a failure.
`during_build` closes BEFORE the terminal hook, so a "build finished" sound is
not competing with the background music it replaces.
Spelling: every event value is a string or a table (`{ cmd, timeout_seconds }`
or `{ cmd, loop }`) — the string-or-table shape `[dependencies]` and
`[resources].version-info` already use, so no new parsing semantics.
## What it actually cost
The schema was the small half.
1. A PROCESS GROUP. `unix/bounded_process.cppm` killed the direct child, which
is enough for `sh -c "sleep 5"` (the shell execs) and not enough for
`sh -c 'player & wait'` — the shell dies and the player keeps the audio
device. A background player that survives its build, from a process the user
cannot name, is the worst failure this feature can have. POSIX now spawns
with POSIX_SPAWN_SETPGROUP and stops with killpg; Windows already had the
right shape in its job object. The same gap is why `mcpp test --timeout` and
`[build] build_program_timeout` could leave grandchildren behind.
The poller deliberately does NOT reap (waitid WNOWAIT): an unreaped leader
is what keeps the group id from being recycled between the poll and the
kill.
2. A SIGNAL HANDLER. Its own process group is what makes killpg possible AND
what stops the terminal's SIGINT from reaching the child — so Ctrl-C would
have killed mcpp and left the music playing. mcpp had no signal handling at
all; there is now the minimum that is async-signal-safe (a
`volatile sig_atomic_t` group id, killpg, re-raise). Windows gets a console
handler, though its job object already covers process death.
3. A RESTART FLOOR. `loop` on a typo'd command is a fork bomb. 250 ms between
runs, and five consecutive runs that end UNSUCCESSFULLY within a second stop
the loop and report. Both halves matter: an early draft counted short runs
regardless of exit code and killed the build after five restarts of a
perfectly healthy `echo`.
A supervisor thread exists only when `loop = true`.
## Criteria
State, not log lines — "mcpp said it stopped the command" passes whether or not
anything stopped. The e2e asserts the heartbeat file grew and then did not grow
for a further second; that `loop` produces >= 2 runs where its absence produces
exactly 1; that a command which cannot stay up stops and is reported; that
`loop` on `build_start` is refused with a diagnostic naming `during_build`; and
that an interrupted build leaves nothing running. The writer is a GRANDCHILD of
the command mcpp starts, which is the case `kill(pid)` misses and `killpg`
catches. The Ctrl-C case is POSIX-only and prints its skip, because a test that
cannot fail on Windows would read as coverage.
Design: .agents/docs/2026-08-30-project-build-hooks-owned-intervals.md
… console
`background_stop` opened with what looked like the symmetric counterpart of the
POSIX side's SIGTERM-then-grace:
::GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, ::GetProcessId(procH));
That call addresses a process GROUP attached to the CALLER's console, not a
process. When the id does not name a live group of ours — and it does not, once
the child has already exited, which `start /b`-style commands do immediately —
the event reaches everything sharing that console instead.
Measured on the Windows e2e runner: the entire suite died eleven seconds into
the hooks test with exit code -1073741510 (0xC000013A, STATUS_CONTROL_C_EXIT)
and printed no summary at all, because mcpp had sent Ctrl-Break to its own
console. Nothing local can show this: the branch is #if'd out everywhere the
code is developed, and the failure is not in the feature under test — it is the
harness dying.
The design document already said Windows has no graceful stop for a child with
no console and no window of its own. The implementation did not believe it. The
call is removed, `graceMs` is explicitly unspent on that platform, and the
asymmetry with POSIX is stated in the declaration instead of faked with a call
that reaches too far. The job object was always the mechanism.
macOS ARM64 and Linux both ran the new e2e green on the previous commit
(317_project_build_hooks.sh, 21s on macOS), so the POSIX half — process groups,
the grandchild kill, and Ctrl-C cleanup — is confirmed on two OSes.
ReviewThe mechanism is the right one and it satisfies Appendix A: fixed keys, open I have pushed five changes to this branch. Three were defects, two were 1. On Windows the hook never ran (this is the red job)
windows_command_from_argv({"cmd.exe", "/d", "/s", "/c", command})which quotes every token. cmd.exe is not parsed that way: its switches have This is #425 one layer up — The shaper is compiled on every platform and tested in 2.
|
`[hooks]` is marked experimental, and the marking is enforced rather than
written down: `side_effect` now defaults to FALSE, and `side_effect = true` is
REFUSED by the manifest parser.
error: mcpp.toml: error: [hooks].side_effect = true is not available yet:
[hooks] is experimental and cannot decide whether a build succeeded. …
So every hook failure — cannot start, non-zero, timed out, failed to stay up —
is a warning, and `mcpp build` keeps the result it earned on its own.
Refused rather than quietly downgraded, because both silent behaviours are
worse than an error. Honouring `true` would ship an experimental feature with a
veto over every build. Ignoring it would leave a project believing its build is
gated on a notifier when nothing is — which is the "accepted and does nothing"
shape this design already rejects for `loop` on a self-closing event and for
`timeout_seconds` on `during_build`. Making an exception for the one key whose
wrong answer is invisible would be exactly backwards.
The key stays in the schema, so a manifest written today does not have to
change when the feature is promoted, and the mechanism under it already
implements both values: the `sideEffect == true` branch in `mcpp.hooks` is
unreachable today ON PURPOSE. Promotion is the deletion of one block in the
parser, not a reconstruction.
Two further limits are documented as permanent rather than provisional, so they
are not read as part of the experiment: a dependency's `[hooks]` is skipped
always (there is one Span construction and there are two invoke call sites, all
in run_build_with_hooks, all fed from the context's own manifest), and only
`mcpp build` runs hooks.
Criteria: the e2e now asserts BOTH halves — the exit code says the hook had no
vote, and the warning says the failure was not swallowed. Checking only the
exit code would pass just as well if hooks had stopped running altogether. The
timeout and give-up cases flipped from "build fails with error:" to "build
succeeds with warning:", and a negative assertion was rewritten as `if grep`
rather than `grep && { }`, whose exit status under `set -e` is an argument this
file should not be having.
Hook context (MCPP_PROFILE, MCPP_TARGET, …) is deferred; the experimental
window is where that interface can still be named deliberately.
Closes #496
说明
为
mcpp build增加项目级构建生命周期 Hook。Hook 在项目的mcpp.toml中配置,适用于普通项目和 workspace 成员构建。配置
build_start:项目准备完成、正式构建开始前执行。build_finished:构建成功结束后执行。build_failed:构建失败后执行。timeout_seconds:每条命令的最长执行时间,默认 10 秒。enabled:是否启用本表中的 Hook,默认true。side_effect:Hook 失败是否使本次构建失败,默认true。执行行为
/bin/sh或cmd.exe),并沿用终端的标准输入、输出和错误。build_finished与build_failed互斥。side_effect = false时仅输出 warning 并保留原构建结果;默认配置下 Hook 失败会使构建返回失败。配套示例应用为 mcpp-hooks-audioplayer,对应包索引提交为 openxlings/xim-pkgindex#712。
其他修改
side_effect和非法配置。验证
tests/e2e/314_project_build_hooks.sh:通过。mcpp test:96 passed,0 failed。