Skip to content

feat: 新增项目级构建 Hook - #530

Merged
Sunrisepeak merged 5 commits into
mcpp-community:mainfrom
helantianshen:feat/project-build-hooks
Aug 30, 2026
Merged

feat: 新增项目级构建 Hook#530
Sunrisepeak merged 5 commits into
mcpp-community:mainfrom
helantianshen:feat/project-build-hooks

Conversation

@helantianshen

@helantianshen helantianshen commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Closes #496

说明

mcpp build 增加项目级构建生命周期 Hook。Hook 在项目的 mcpp.toml 中配置,适用于普通项目和 workspace 成员构建。

配置

[hooks]
build_start = "echo build started"
build_failed = "notify-send 'build failed'"
build_finished = "notify-send 'build finished'"

# 以下配置可省略
timeout_seconds = 10
enabled = true
side_effect = true
  • build_start:项目准备完成、正式构建开始前执行。
  • build_finished:构建成功结束后执行。
  • build_failed:构建失败后执行。
  • timeout_seconds:每条命令的最长执行时间,默认 10 秒。
  • enabled:是否启用本表中的 Hook,默认 true
  • side_effect:Hook 失败是否使本次构建失败,默认 true

执行行为

  • 命令在项目根目录同步执行,使用宿主 Shell(/bin/shcmd.exe),并沿用终端的标准输入、输出和错误。
  • build_finishedbuild_failed 互斥。
  • 命令无法启动、返回非零或超时均视为 Hook 失败。
  • side_effect = false 时仅输出 warning 并保留原构建结果;默认配置下 Hook 失败会使构建返回失败。
  • Hook 自身失败不会触发另一个 Hook。
  • 未配置或已禁用 Hook 的项目保持现有构建路径。

配套示例应用为 mcpp-hooks-audioplayer,对应包索引提交为 openxlings/xim-pkgindex#712

其他修改

  • 增加中英文配置文档。
  • 增加端到端测试,覆盖成功、失败、禁用、超时、side_effect 和非法配置。

验证

  • 从本分支源码构建 mcpp:通过。
  • tests/e2e/314_project_build_hooks.sh:通过。
  • 全量 mcpp test:96 passed,0 failed。

@helantianshen helantianshen changed the title feat: add project-level build hooks feat: 新增项目级构建 Hook Aug 29, 2026
@Sunrisepeak
Sunrisepeak force-pushed the feat/project-build-hooks branch 2 times, most recently from 4f04364 to 1033254 Compare August 30, 2026 09:36
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.
@Sunrisepeak
Sunrisepeak force-pushed the feat/project-build-hooks branch from 1033254 to 9b9f026 Compare August 30, 2026 09:54
…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.
@Sunrisepeak

Copy link
Copy Markdown
Member

Review

The mechanism is the right one and it satisfies Appendix A: fixed keys, open
values, and no key that duplicates an answer another section already gives.
It is also genuinely distinct from build.mcpp — an action{} node is part of
the build graph and therefore cannot observe the build's terminal result,
which is exactly what build_finished / build_failed are for. Deferring
build_start until after preparation, so that an [xlings] deps hook program
exists by the time it runs, is the correct ordering and worth the fast-path
cost.

I have pushed five changes to this branch. Three were defects, two were
coverage.

1. On Windows the hook never ran (this is the red job)

run_shell_deadline built the command line with

windows_command_from_argv({"cmd.exe", "/d", "/s", "/c", command})

which quotes every token. cmd.exe is not parsed that way: its switches have
to arrive bare, and the command tail is governed by the /C quote rule rather
than by CreateProcess argv quoting. What the runner reported was

'"echo start>>hooks.log' is not recognized as an internal or external command

This is #425 one layer up — run_to_completion in detach_codegen.cppm
carries the same warning in a comment. A user-authored shell command now has
its own host-independent shaper, windows_shell_command_line, producing
cmd.exe /d /s /c "<command>": bare switches plus the single outer pair /s
strips unconditionally, so the command reaches cmd verbatim however many
quotes it contains.

The shaper is compiled on every platform and tested in
test_windows_command_line.cpp, which models the /C rule and asserts that a
redirect and a quoted program path both come back byte for byte. The Windows
branch is unreachable on the machines this is developed on, so a unit test is
the only thing that can fail there before a CI cycle does.

2. [hooks] is a section of mcpp.toml, so mcpp.manifest parses it

hooks::load() was a second, independent reader of the same file. Two costs,
one of them borne by projects that do not use hooks at all:

  • Every mcpp build parsed mcpp.toml one extra time, including on the fast
    path, for every project whether or not it declares hooks.
  • On the plain mcpp build path (no --profile/--target/… override), the
    hook reader is the first thing to touch the file, so any TOML syntax
    error
    — a stray bracket, an unterminated string, anywhere in the manifest
    — came out as
    error: invalid hook configuration: mcpp.toml:12:3: ... and exit 2, in the
    vocabulary of a feature the project may not use. With an override present
    the fast-path block is skipped and the same manifest reports the same error
    correctly, which is the kind of difference that is very hard to explain
    from a bug report.

Manifest::hooks now carries the configuration, parsed in
modules/manifest/src/toml.cppm alongside every other section. Unknown keys
warn and become errors under --strict, the same split [build] uses, so a
manifest written for a later mcpp still loads here; unknown values stay hard
errors. mcpp.hooks keeps the part that is policy rather than grammar.

3. A hook no longer moves mcpp's working directory

invoke() chdir'd the whole process to the project root and restored it
afterwards, with an RAII guard, a manual restore, and a flag to keep the two
from colliding. The launchers underneath already carry a per-child working
directory (posix_spawn_file_actions_addchdir_np on POSIX,
lpCurrentDirectory on Windows) and dispatch_bounded already threads one
through. run_shell_deadline now takes cwd, and about thirty lines of
shared-state juggling are gone.

4. The fast-path veto moved next to the manifest that answers it

cmd_build was loading the manifest to decide whether it was allowed to call
try_fast_build, which loads the manifest. The veto is now inside
try_fast_build, riding on the single fast_path_identity read.

5. Coverage for the claims that had none

  • Workspace members. The description says hooks apply to workspace member
    builds; nothing tested it. The e2e now builds a two-member virtual
    workspace and asserts each member's own hooks ran in that member's root, and
    that the workspace root manifest's own [hooks] stayed silent.
  • Preparation failure fires nothing. The lifecycle is paired —
    build_finished / build_failed are reachable only after build_start
    so a project that cannot be prepared runs no hook at all. That is the right
    contract (the hook program may be what preparation would have installed),
    but it was neither stated nor tested. It is now both.
  • An unknown key warns and the known ones still run.
  • The log comparison could not have passed on Windows. cmd.exe writes
    CRLF, so [[ "$(cat hooks.log)" == $'start\nfinished' ]] would have failed
    even with the command line fixed. Comparisons now normalise line endings,
    and the two-build assertion (start finished start finished) is what proves
    the second build did not silently take the fast path.
  • A dependency's [hooks] never fires. Every manifest mcpp parses carries
    the field, a dependency's included; only the root project's is invoked. That
    is the property that keeps mcpp add from meaning "run this author's shell
    command on my next build", and a comment cannot hold it. There are exactly
    two hooks::invoke call sites, both in run_build_with_hooks, and the e2e
    now builds against a hooked path dependency and asserts its log never
    appears.
  • Eight unit tests for the [hooks] grammar, including that a policy-only
    table is not "active" and therefore must not divert the fast path, and that
    both ends of the documented timeout_seconds range are the range.

Two further defects surfaced only once CI ran the whole suite:

  • The new fixture wrote a POSIX path into a manifest as file content.
    00_fixture_path_hygiene.sh exists for precisely that (a leading / in
    file content is "root of the current drive" to a native mcpp.exe), and it
    caught it — now routed through host_path as $DEP_HOST.
  • The test number collided with main. This branch is three commits
    behind, and 314_dependency_action_generated_header.sh, 315, and 316
    have landed since it was cut. Renamed to 317_project_build_hooks.sh. The
    collision is invisible from the branch and only appears in the merge.

Docs in both languages now 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 no-op fast path, that preparation
failure is silent, and that a [hooks] table is executable content in a
repository the user may have just cloned.

Left for you to decide

None of these are defects; all four are interface decisions I did not think it
was mine to make on your behalf.

  1. side_effect reads backwards. side_effect = true looks like "this
    hook is allowed to have side effects"; it actually means "a failure of this
    hook fails the build". required, fatal, or fail_build would say that.
    The key is permanent public schema, and renaming now is free — except that
    helantianshen/mcpp-hooks-audioplayer and feat: 新增 mcpp Hook 音频播放器包 openxlings/xim-pkgindex#712
    already spell it side_effect, which is why I left it alone rather than
    breaking a companion PR unilaterally.

  2. A hook receives no context. build_finished cannot tell which target,
    profile, or triple finished, or where the artifacts landed. A small set of
    environment variables (MCPP_PROJECT_ROOT, MCPP_PACKAGE, MCPP_PROFILE,
    MCPP_TARGET, MCPP_OUTPUT_DIR) would be additive and cheap —
    dispatch_bounded already takes extraEnv — but it is a public contract
    and worth naming deliberately rather than in a review.

  3. mcpp run and mcpp test build without firing hooks. Documented as
    deliberate, but a user whose build_finished notifier is silent after
    mcpp run will read it as a bug. If they should fire there too, the
    wrapper is already factored for it.

  4. On POSIX the timeout kills the direct child only. sh -c "sleep 5"
    is fine, because the shell execs the command; sh -c "make & wait" would
    leave a grandchild behind. Windows uses a Job object and takes the whole
    tree. This is a pre-existing asymmetry in
    modules/platform/src/unix/bounded_process.cppm that also affects
    mcpp test --timeout and [build] build_program_timeout — worth its own
    issue rather than a change here.

Verification

Locally on Linux (gcc 16.1, self-host): mcpp test 96 passed / 0 failed;
tests/e2e/314_project_build_hooks.sh passes, as do 04_incremental.sh,
35_workspace.sh, 160_test_timeout.sh, 178_test_observability.sh and
186_build_mcpp_protocol_and_bound.sh (the fast path and the other users of
the bounded-process layer I touched). mcpp build --strict on an unknown
[hooks] key was checked to fail rather than warn. A rooted workspace root
was checked to fire its own hooks, which is what the documentation now claims.


Follow-up: during_build, and one model instead of two

The request that followed the review was a background command — music for the
length of the build, stopped when it ends, optionally restarting. Written as
build_start = { cmd = "…", loop = true } it looks like a knob, but loop is
not the new thing: the new thing is a command whose life is longer than the
moment that started it. Bolting background = true onto build_start would
give that key two incompatible meanings and silently change what
timeout_seconds and side_effect mean for one of them.

So the two are unified instead:

A hook is a command mcpp owns for an interval, and the event names the
interval.

build_start / build_finished / build_failed are self-closing — they
end when the command exits. "Synchronous" stops being a mode; it is what a
self-closing interval looks like. during_build is the one interval closed by
something else, and the rest follows from that single difference rather than
being declared:

  • timeout_seconds bounds one run, so it does not apply where the build
    already bounds it — and is rejected on during_build rather than
    reinterpreted.
  • loop restarts a command that ended before its interval did, which a
    self-closing interval makes impossible — rejected there too, with a message
    naming during_build.
  • side_effect is unchanged.

during_build closes before the terminal hook, so a "build finished" sound
is not competing with the music it replaces.

The schema was the small half

Three things the feature needs that mcpp did not have:

  1. A process group. unix/bounded_process.cppm killed the direct child.
    That is enough for sh -c "sleep 5" (the shell execs) and not enough for
    sh -c 'player & wait' — the shell dies, the player keeps the audio device.
    Music that cannot be stopped, 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; the poller deliberately
    does not reap (waitid + WNOWAIT), because an unreaped leader is what
    keeps the group id from being recycled between the poll and the kill. The
    same gap is why mcpp test --timeout could leave grandchildren behind.

  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 kill mcpp and leave the music playing. mcpp had no signal handling at
    all; there is now the minimum that is async-signal-safe.

  3. A restart floor. loop on a typo is a fork bomb. 250 ms between runs,
    and five consecutive runs ending 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.

Two defects that only CI could find

  • GenerateConsoleCtrlEvent took the console with it. Written as the
    symmetric counterpart of the POSIX SIGTERM-then-grace, it addresses a
    process group attached to the caller's console, not a process — and when
    the id does not name a live group of ours (which it does not, once the child
    has exited) the event reaches everything sharing that console. The Windows
    e2e runner died eleven seconds in with 0xC000013A
    (STATUS_CONTROL_C_EXIT) and printed no summary at all, because mcpp had
    Ctrl-Break'd its own console. Nothing local can show this: the branch is
    #if'd out where the code is developed, and what died was the harness, not
    the feature — so there is no FAIL: line anywhere, only a job exit code.
    The design document already said Windows has no graceful stop here; the
    implementation did not believe it. The call is gone and the job object,
    which was always the mechanism, is the whole of it.

  • The fixture-path and test-number issues noted above.

Verification

fa14e6d: 11 workflows, 0 failures. 317_project_build_hooks.sh ran on
all three — Windows 16.82 s, Linux 16.07 s, macOS 21.00 s — rather than being
skipped, which for a test whose Ctrl-C case is POSIX-only was worth checking
separately. Locally: mcpp test 96 passed / 0 failed (14 ManifestHooks), all
six repo lint scripts, and the fast-path, workspace and bounded-process e2e
tests that share the code this touched.

`[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.
@Sunrisepeak
Sunrisepeak merged commit adc7077 into mcpp-community:main Aug 30, 2026
43 of 47 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat/RFC: mcpp 项目级构建 Hook 机制

3 participants