Skip to content

Commit dd6a5f6

Browse files
committed
feat(hooks): a hook is a command owned for an interval, and during_build
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
1 parent 9b9f026 commit dd6a5f6

12 files changed

Lines changed: 1409 additions & 87 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
# Project build hooks as owned intervals (#496)
2+
3+
## The question this design answers
4+
5+
The first shape of `[hooks]` gave `mcpp build` three commands — `build_start`,
6+
`build_finished`, `build_failed` — each run to completion with a timeout, each
7+
judged by its exit code.
8+
9+
The request that followed was a background command: music that plays *for the
10+
duration of the build* and stops when it ends, optionally restarting when the
11+
player exits. Written as a knob it looks like
12+
13+
```toml
14+
build_start = { cmd = "play bgm.mp3", loop = true }
15+
```
16+
17+
but `loop` is not the new thing. The new thing is that this command's life is
18+
**longer than the moment that started it**. Adding `background = true` to
19+
`build_start` would give that key two incompatible meanings and would silently
20+
change what `timeout_seconds` and `side_effect` mean for one of them — a table
21+
whose keys mean different things depending on a sibling key is the shape this
22+
design exists to avoid.
23+
24+
## The model
25+
26+
> A hook is a command mcpp **owns for an interval**. The event names the
27+
> interval. mcpp starts the command when the interval opens and ends it when
28+
> the interval closes.
29+
30+
| Event | The interval opens | The interval closes |
31+
|---|---|---|
32+
| `build_start` | after preparation, before the build | when the command exits |
33+
| `build_finished` | after a build that succeeded | when the command exits |
34+
| `build_failed` | after a build that failed | when the command exits |
35+
| `during_build` | after preparation, before the build | after the build, before the terminal hook |
36+
37+
The first three have **self-closing** intervals. "Synchronous" is not a
38+
separate mode in this model — it is what an interval closed by the command
39+
itself looks like. `during_build` is the one interval closed by something else,
40+
and everything that reads as a special case for it falls out of that single
41+
difference rather than being declared:
42+
43+
- **`timeout_seconds` bounds one run of the command.** For a self-closing
44+
interval that is the whole hook. For `during_build` the build already bounds
45+
it, so the key is *rejected* there rather than accepted and reinterpreted —
46+
a per-run cap would only be enforceable when `loop` is on (nothing is
47+
polling otherwise), and a key that works under one sibling setting and not
48+
another is the hole this design is trying not to dig.
49+
- **`loop` restarts a command that exits before its interval closes.** A
50+
self-closing interval ends *when the command exits*, so `loop` can never fire
51+
there. It is rejected on those events rather than accepted and ignored, with
52+
a message naming `during_build`.
53+
- **`side_effect` is unchanged**: does a hook failure fail the build. For
54+
`during_build`, "failure" means *failed to start*, or *failed to stay up*
55+
(below). Being stopped because the interval closed is not a failure.
56+
57+
Ordering inside the lifecycle:
58+
59+
```text
60+
during_build opens
61+
build_start
62+
├─ build succeeds → during_build closes → build_finished
63+
└─ build fails → during_build closes → build_failed
64+
```
65+
66+
`during_build` closes **before** the terminal hook, not after. A "build
67+
finished" sound playing over the background music it was supposed to replace is
68+
the whole reason the order is fixed rather than incidental.
69+
70+
## Schema
71+
72+
Every event value is a string or a table; the string is sugar.
73+
74+
```toml
75+
[hooks]
76+
build_start = "echo start" # = { cmd = "echo start" }
77+
build_finished = "notify-send 'build finished'"
78+
during_build = { cmd = "mcpp-hooks-audioplayer bgm", loop = true }
79+
80+
# Table-level, unchanged.
81+
timeout_seconds = 10
82+
enabled = true
83+
side_effect = true
84+
```
85+
86+
Per-event table keys: `cmd` (required, non-empty), `timeout_seconds`
87+
(overrides the table default for this event), `loop` (`during_build` only).
88+
89+
The string-or-table pattern is already how `[dependencies]` and
90+
`[resources].version-info` are spelled, so it introduces no new parsing
91+
semantics — Appendix A of docs/05 is satisfied for the same reason the first
92+
shape satisfied it: fixed keys, open values, and no key that duplicates an
93+
answer another section already gives.
94+
95+
## What the feature actually costs
96+
97+
The schema is the small half. A process whose life spans the build needs three
98+
things mcpp does not have.
99+
100+
### 1. A process group, because SIGKILL on a pid is not enough
101+
102+
`modules/platform/src/unix/bounded_process.cppm:216` kills the direct child:
103+
104+
```cpp
105+
::kill(pid, SIGKILL);
106+
```
107+
108+
For `sh -c "sleep 5"` that is sufficient — the shell execs the command. For
109+
`sh -c "while :; do play a.mp3; done"` it is not: the shell dies and `play`
110+
survives. **Music that cannot be stopped, from a process the user cannot name,
111+
is the worst failure this feature can have**, and it is the default outcome
112+
without process groups.
113+
114+
So the POSIX side gains `posix_spawnattr_setpgroup` + `killpg`. Windows already
115+
has the right shape: a Job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`
116+
(`windows/bounded_process.cppm:205`), which takes the whole tree — and takes it
117+
even if mcpp dies, because the handle closes with the process.
118+
119+
This is worth doing on its own terms: the same gap is why
120+
`mcpp test --timeout` and `[build] build_program_timeout` can leave
121+
grandchildren behind today.
122+
123+
### 2. A signal handler, because a process group stops receiving Ctrl-C
124+
125+
The two requirements fight each other. A child in its own process group no
126+
longer receives the terminal's SIGINT — which is what makes `killpg` possible
127+
and *also* what makes Ctrl-C leave the music playing. Both halves are needed:
128+
its own group, plus a SIGINT/SIGTERM handler in mcpp that stops the group
129+
before re-raising.
130+
131+
mcpp installs no signal handlers today. The one added here is the minimum that
132+
is async-signal-safe: a `volatile sig_atomic_t` holding the process-group id, a
133+
handler that calls `killpg` (which is async-signal-safe) and re-raises the
134+
default action. It is installed only while a spanning hook is running.
135+
136+
Windows needs no equivalent for correctness — the job object already covers
137+
process death — but `SetConsoleCtrlHandler` is installed for a clean stop.
138+
139+
### 3. A restart floor, because `loop` on a typo is a fork bomb
140+
141+
`loop = true` with `play /nonexistant` restarts thousands of times per second
142+
for the length of the build. Two bounds, both fixed in v1 rather than
143+
configurable, because a knob whose wrong value is a spin is not a knob:
144+
145+
- **250 ms between runs.**
146+
- **Five consecutive runs that exited non-zero in under a second** stops the
147+
loop and reports a hook failure: *"during_build command failed to stay up".*
148+
Whether that fails the build is `side_effect`, as everywhere else.
149+
150+
A supervisor thread exists **only when `loop = true`**. Without it, a spanning
151+
hook is a spawn and a stop, and no thread is created.
152+
153+
## Stopping
154+
155+
POSIX: `SIGTERM` to the group, a 2 s grace period, then `SIGKILL`. A player
156+
asked to stop should get to close its audio device.
157+
158+
Windows: the job object is closed, which terminates the tree at once. There is
159+
no graceful equivalent that does not require enumerating the job's processes
160+
and posting window messages; the asymmetry is stated rather than hidden.
161+
162+
## Output
163+
164+
A self-closing hook inherits stdio, which is safe because nothing else is
165+
writing at that moment. A spanning hook writes **concurrently with ninja** and
166+
would interleave into the middle of a compiler diagnostic. Its output is
167+
therefore discarded by default, and inherited under `--verbose` — no new schema
168+
key, and the answer to "why is there no music" is one flag away.
169+
170+
## Criteria
171+
172+
The assertions this design has to earn are about *state*, not about log lines —
173+
"we called stop" is not evidence that anything stopped.
174+
175+
1. **It runs during the build.** The command appends a heartbeat line every
176+
200 ms; the file is non-empty when the build ends.
177+
2. **It is stopped.** Record the heartbeat file's size after `mcpp build`
178+
returns, wait one second, read it again: unchanged. A log line saying
179+
"stopped" would pass whether or not the process died.
180+
3. **`loop` restarts it.** A command that exits immediately produces a heartbeat
181+
count that grows across the build; without `loop` it produces exactly one.
182+
4. **The whole tree dies, not just the shell.** The command is
183+
`sh -c '... & wait'`, so the writer is a grandchild. This is the case
184+
`kill(pid)` misses and `killpg` catches, and it is the only assertion that
185+
distinguishes the fix from the bug.
186+
5. **The failure cap trips.** A command that fails instantly stops after five
187+
attempts and reports; the heartbeat count is bounded, not "large".
188+
6. **Ctrl-C leaves nothing behind.** POSIX only: `mcpp build` in the
189+
background, `kill -INT`, then criterion 2. Sending Ctrl-C to another process
190+
on Windows needs a helper this suite does not have; the gap is declared
191+
rather than papered over with a test that passes vacuously.
192+
7. **A self-closing event rejects `loop`.** Otherwise the key is accepted,
193+
does nothing, and the user concludes the feature does not work.

docs/05-mcpp-toml.md

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2096,55 +2096,98 @@ do`.
20962096

20972097
### 2.16 `[hooks]` — Project Build Lifecycle Commands
20982098

2099-
`mcpp build` can run one host-shell command before the build and one command
2100-
for its terminal result:
2099+
A hook is a command `mcpp build` **owns for an interval**, and the event names
2100+
the interval:
21012101

21022102
```toml
21032103
[hooks]
21042104
build_start = "echo build started"
21052105
build_failed = "notify-send 'build failed'"
21062106
build_finished = "notify-send 'build finished'"
21072107

2108+
# Runs alongside the build and is stopped when it ends.
2109+
during_build = { cmd = "mcpp-hooks-audioplayer bgm", loop = true }
2110+
21082111
# Optional; these are the defaults.
21092112
timeout_seconds = 10
21102113
enabled = true
21112114
side_effect = true
21122115
```
21132116

2114-
| Key | Type | Default | Meaning |
2117+
| Key | Type | Default | The interval it names |
21152118
|---|---|---:|---|
2116-
| `build_start` | string || Runs after project preparation, immediately before the build |
2117-
| `build_failed` | string || Runs when the build exits unsuccessfully |
2118-
| `build_finished` | string || Runs when the build exits successfully |
2119-
| `timeout_seconds` | integer, 1–86400 | `10` | Maximum time for each command |
2119+
| `build_start` | command || Opens after project preparation, closes when the command exits |
2120+
| `build_finished` | command || Opens after a build that succeeded, closes when the command exits |
2121+
| `build_failed` | command || Opens after a build that failed, closes when the command exits |
2122+
| `during_build` | command || Opens before the build, closes after it |
2123+
| `timeout_seconds` | integer, 1–86400 | `10` | Bounds one run of a command |
21202124
| `enabled` | bool | `true` | Enables all commands in this table |
21212125
| `side_effect` | bool | `true` | Whether a hook failure makes the overall build fail |
21222126

2123-
Commands run synchronously through the host shell (`/bin/sh` or `cmd.exe`),
2124-
with the **project root** as their working directory — not the directory
2125-
`mcpp build` was typed in, so a relative path in a hook means the same thing
2126-
wherever the build was started. Standard input/output/error keep their ordinary
2127-
terminal behaviour. Missing event commands are skipped.
2127+
The first three intervals are **self-closing** — they end when the command
2128+
does. "Synchronous" is not a separate mode here; it is what a self-closing
2129+
interval looks like. `during_build` is the one interval closed by something
2130+
else, and the two keys that only make sense for one shape follow from that
2131+
rather than being exceptions.
2132+
2133+
A command is a string, or a table when it needs options:
2134+
2135+
| Table key | Applies to | Meaning |
2136+
|---|---|---|
2137+
| `cmd` | every event | The command. Required. |
2138+
| `timeout_seconds` | self-closing events | Overrides the table default for this event |
2139+
| `loop` | `during_build` | Restart the command if it exits before the build ends |
2140+
2141+
`loop` on a self-closing event and `timeout_seconds` on `during_build` are both
2142+
**errors**, not ignored keys: a self-closing interval ends when its command
2143+
exits, so there is nothing to restart, and `during_build` is already bounded by
2144+
the build. A key that is accepted and does nothing reads as a broken feature.
2145+
2146+
Commands run through the host shell (`/bin/sh` or `cmd.exe`), with the
2147+
**project root** as their working directory — not the directory `mcpp build`
2148+
was typed in, so a relative path in a hook means the same thing wherever the
2149+
build was started. A self-closing command keeps ordinary terminal
2150+
input/output. Missing event commands are skipped.
21282151

21292152
The lifecycle is:
21302153

21312154
```text
2155+
during_build opens
21322156
build_start
2133-
├─ build succeeds → build_finished
2134-
└─ build fails → build_failed
2157+
├─ build succeeds → during_build closes → build_finished
2158+
└─ build fails → during_build closes → build_failed
21352159
```
21362160

2161+
`during_build` closes **before** the terminal hook, so a "build finished" sound
2162+
is not competing with the background music it replaces.
2163+
21372164
`build_failed` and `build_finished` are mutually exclusive, and both are
21382165
reachable only after `build_start` has run. A project that cannot be *prepared*
21392166
— an invalid manifest, an unresolvable dependency, no usable toolchain — fires
21402167
nothing: it has not started building, and its hook program may be exactly what
21412168
preparation would have installed.
21422169

21432170
A hook command that cannot start, returns non-zero, or exceeds its timeout is a
2144-
hook failure. With `side_effect = false`, mcpp reports a warning and preserves
2145-
the build's result; with `true`, it returns failure — but a build that failed on
2146-
its own keeps its own exit code, so `mcpp build` never reports a compile error
2147-
as a notifier problem. A hook's own failure does not trigger another hook.
2171+
hook failure. For `during_build` there is one more: a looped command that
2172+
**fails to stay up** — five consecutive runs ending unsuccessfully within a
2173+
second — stops being restarted and is reported. (A command that finishes
2174+
quickly and *successfully* is doing exactly what `loop` was asked to repeat,
2175+
and is not a failure.) With `side_effect = false`, mcpp reports a warning and
2176+
preserves the build's result; with `true`, it returns failure — but a build that
2177+
failed on its own keeps its own exit code, so `mcpp build` never reports a
2178+
compile error as a notifier problem. A hook's own failure does not trigger
2179+
another hook.
2180+
2181+
Two things are worth knowing about a `during_build` command specifically:
2182+
2183+
- **Its output is discarded**, because it writes concurrently with the build
2184+
and would otherwise land in the middle of a compiler diagnostic. Run
2185+
`mcpp build --verbose` to see it.
2186+
- **It is stopped as a process tree**, not as a process. `player & wait` makes
2187+
the player a grandchild of the command mcpp started, and stopping only the
2188+
latter would leave the audio device held after the build. mcpp puts the
2189+
command in its own process group (a job object on Windows) and stops that,
2190+
including when the build is interrupted with Ctrl-C.
21482191

21492192
Scope, precisely:
21502193

@@ -2162,9 +2205,10 @@ Scope, precisely:
21622205
already-current hooked project to cost a preparation pass rather than
21632206
milliseconds.
21642207

2165-
An unrecognised key in `[hooks]` is a warning (an error under `--strict`), so a
2166-
manifest written for a newer mcpp still loads. An unrecognised *value* — a
2167-
non-string command, a `timeout_seconds` outside 1–86400 — is a manifest error.
2208+
An unrecognised key in `[hooks]`, or inside one event's table, is a warning (an
2209+
error under `--strict`), so a manifest written for a newer mcpp still loads. An
2210+
unrecognised *value* — a missing or non-string `cmd`, a `timeout_seconds`
2211+
outside 1–86400, a key offered to the wrong interval — is a manifest error.
21682212

21692213
> **A hook is code, and `mcpp.toml` is part of the repository.** Building a
21702214
> freshly cloned project runs whatever its `[hooks]` say, with the privileges
@@ -2178,6 +2222,7 @@ than adding media handling to mcpp:
21782222

21792223
```toml
21802224
[hooks]
2225+
during_build = { cmd = "mcpp-hooks-audioplayer bgm", loop = true }
21812226
build_finished = "mcpp-hooks-audioplayer niulai-mm"
21822227
build_failed = "mcpp-hooks-audioplayer niulai-niulai"
21832228
side_effect = false
@@ -2186,6 +2231,10 @@ side_effect = false
21862231
deps = ["xim:mcpp-hooks-audioplayer@0.0.1"]
21872232
```
21882233

2234+
Background music for the length of the build, a different sound for how it
2235+
ended, and `side_effect = false` so that a missing audio device is a warning
2236+
rather than a failed build.
2237+
21892238
## Appendix A. Schema Ownership Principle (admission criteria for new fields)
21902239

21912240
> **Closed syntax, open vocabulary**: whoever owns the parsing semantics defines the keys; whoever owns the domain knowledge defines the values.

0 commit comments

Comments
 (0)