{{ message }}
-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathhooks.cppm
More file actions
284 lines (247 loc) · 11.1 KB
/
Copy pathhooks.cppm
File metadata and controls
284 lines (247 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
// mcpp.hooks — running the project's `[hooks]` lifecycle commands.
//
// EXPERIMENTAL. A hook cannot currently change whether a build succeeded:
// every failure is a warning, and `side_effect = true` is refused by the
// manifest parser. Only the ROOT project's hooks are ever run — a dependency's
// `[hooks]` is inert. See docs/05-mcpp-toml.md §2.16.
//
// The CONFIGURATION is not parsed here: `[hooks]` is a section of mcpp.toml
// and mcpp.toml has one parser (mcpp.manifest). What lives here is the part
// that is policy rather than grammar — when a command runs, what a failure
// means, and what the user is told.
//
// The model is one idea: a hook is a command mcpp OWNS FOR AN INTERVAL, and
// the event names the interval. `invoke` runs a SELF-CLOSING one (the interval
// ends when the command exits). `Span` owns a SPANNING one (`during_build`),
// whose interval is closed by the build. See
// .agents/docs/2026-08-30-project-build-hooks-owned-intervals.md.
export module mcpp.hooks;
import std;
import mcpp.manifest;
import mcpp.platform.process;
import mcpp.ui;
export namespace mcpp::hooks {
enum class Event { BuildStart, BuildFailed, BuildFinished };
// Run the command this event names, if the project declared one, and wait for
// it. The interval is self-closing: it ends when the command exits.
//
// Returns whether the BUILD may keep its own result. False means a hook failed
// while `side_effect` was on, i.e. the failure is the build's now. A missing
// command, a disabled table, and a hook that failed under `side_effect = false`
// all return true — from the build's point of view nothing happened.
bool invoke(const mcpp::manifest::Hooks& hooks, Event event,
const std::filesystem::path& projectRoot);
// `during_build`: a command started before the build and stopped after it.
//
// RAII because the interval must close on every path out of the build,
// including the ones nobody writes down — an early return, an exception, a
// diagnostic that gives up. A background player that outlives its build is the
// failure this whole shape exists to prevent, so the destructor is the only
// place the stop can be promised from.
class Span {
public:
Span() = default;
Span(const mcpp::manifest::Hooks& hooks,
const std::filesystem::path& projectRoot,
bool inheritOutput);
~Span();
Span(const Span&) = delete;
Span& operator=(const Span&) = delete;
// Whether the BUILD may proceed. False means the command could not be
// started and `side_effect` was on — the same verdict, in the same words,
// that a failed `build_start` produces. Already reported.
bool ok() const { return ok_; }
// Close the interval and report what happened while it was open. Returns
// whether the build may keep its result.
//
// Called explicitly BEFORE the terminal hook rather than left to the
// destructor: a "build finished" sound playing over the background music
// it was supposed to replace is the ordering this exists for.
bool finish();
private:
void close();
mcpp::platform::process::BackgroundCommand child_;
std::string command_;
std::filesystem::path root_;
bool inheritOutput_ = false;
bool declared_ = false;
bool started_ = false;
bool closed_ = false;
bool sideEffect_ = false;
bool ok_ = true;
std::atomic<bool> stop_{false};
std::atomic<bool> gaveUp_{false};
std::thread supervisor_;
};
} // namespace mcpp::hooks
namespace mcpp::hooks {
namespace {
namespace proc = mcpp::platform::process;
// `loop` restarts a command that ended before its interval did. Two bounds,
// fixed rather than configurable, because a knob whose wrong value is a spin
// is not a knob: a typo in the command (`play /nonexistant`) would otherwise
// restart thousands of times per second for the length of the build.
constexpr auto kRestartDelay = std::chrono::milliseconds(250);
constexpr auto kTooShort = std::chrono::milliseconds(1000);
constexpr int kShortRunsBeforeGivingUp = 5;
// How long a stopped command is given to leave on its own before it is taken.
// A player asked to stop should get to close its audio device.
constexpr auto kStopGrace = std::chrono::milliseconds(2000);
std::string_view event_name(Event event) {
switch (event) {
case Event::BuildStart: return "build_start";
case Event::BuildFailed: return "build_failed";
case Event::BuildFinished: return "build_finished";
}
return "unknown";
}
const mcpp::manifest::HookCommand&
event_command(const mcpp::manifest::Hooks& hooks, Event event) {
switch (event) {
case Event::BuildFailed: return hooks.buildFailed;
case Event::BuildFinished: return hooks.buildFinished;
case Event::BuildStart: break;
}
return hooks.buildStart;
}
} // namespace
// One place decides what a hook failure costs, so `side_effect` cannot be
// honoured on one failure mode and forgotten on another — and there are now
// four of them (cannot start, non-zero, timed out, failed to stay up). The
// message is identical either way; only its severity and the build's fate
// differ.
//
// WHILE `[hooks]` IS EXPERIMENTAL, `sideEffect` IS ALWAYS FALSE — the
// manifest parser refuses `side_effect = true` (see
// modules/manifest/src/toml.cppm). The `true` branch below is therefore
// unreachable today ON PURPOSE: it is the behaviour the key will select when
// the feature is promoted, and keeping it here means promotion is a deletion
// in the parser rather than a reconstruction here.
bool report_hook_failure_flag(bool sideEffect, const std::string& message) {
if (sideEffect) {
mcpp::ui::error(message);
return false;
}
mcpp::ui::warning(message);
return true;
}
bool report_hook_failure(const mcpp::manifest::Hooks& hooks,
const std::string& message) {
return report_hook_failure_flag(hooks.sideEffect, message);
}
bool invoke(const mcpp::manifest::Hooks& hooks, Event event,
const std::filesystem::path& projectRoot) {
if (!hooks.enabled) return true;
auto const& hook = event_command(hooks, event);
if (hook.empty()) return true;
bool timedOut = false;
// The project root is passed to the launcher, not arranged with a chdir:
// mcpp's working directory is process-wide state, and a hook is not
// entitled to move it even briefly.
int rc = proc::run_shell_deadline(
hook.cmd, projectRoot.string(),
std::chrono::seconds(hooks.timeout_for(hook)), &timedOut);
if (timedOut)
return report_hook_failure(hooks, std::format(
"hook '{}' timed out after {}s", event_name(event),
hooks.timeout_for(hook)));
if (rc != 0)
return report_hook_failure(hooks, std::format(
"hook '{}' exited with status {}", event_name(event), rc));
return true;
}
Span::Span(const mcpp::manifest::Hooks& hooks,
const std::filesystem::path& projectRoot,
bool inheritOutput)
: command_(hooks.duringBuild.cmd)
, root_(projectRoot)
, inheritOutput_(inheritOutput)
, declared_(hooks.enabled && !hooks.duringBuild.empty())
, sideEffect_(hooks.sideEffect)
{
if (!declared_) return;
child_ = proc::start_shell_background(command_, root_.string(),
inheritOutput_);
if (!child_.ok) {
ok_ = report_hook_failure(hooks,
"hook 'during_build' could not be started");
return;
}
started_ = true;
// Its own process group is what makes a whole-tree stop possible and what
// stops the terminal's Ctrl-C from reaching it. Both halves are needed, so
// the group is registered for the duration.
proc::guard_background_on_signal(child_);
if (!hooks.duringBuild.loop) return;
// A supervisor exists ONLY for `loop`. Without it, a spanning hook is a
// spawn and a stop, and no thread is created.
supervisor_ = std::thread([this, runStarted = std::chrono::steady_clock::now()]
() mutable {
int shortFailures = 0;
while (!stop_.load()) {
int code = 0;
if (proc::background_running(child_, &code)) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
continue;
}
// How long the run that JUST ENDED lasted. Measuring from the
// restart instead would compare a spawn against itself and read
// every run as instantaneous — which is a give-up on the fifth
// restart of a perfectly healthy command.
const auto ranFor = std::chrono::steady_clock::now() - runStarted;
// Stop before restarting, not after: the run is over but whatever
// it forked may not be, and the group is addressable only while
// the unreaped leader still holds its id.
proc::stop_background(child_, std::chrono::milliseconds(0));
proc::clear_background_guard(child_);
if (stop_.load()) return;
// "Failed to stay up" is BOTH halves: short AND unsuccessful. A
// command that finishes quickly and cleanly — one `ding.mp3`, an
// `echo` — is doing exactly what `loop` was asked to repeat, and
// counting it here would turn the feature into its own kill switch.
if (code != 0 && ranFor < kTooShort) {
if (++shortFailures >= kShortRunsBeforeGivingUp) {
gaveUp_.store(true);
return;
}
} else {
shortFailures = 0;
}
std::this_thread::sleep_for(kRestartDelay);
if (stop_.load()) return;
runStarted = std::chrono::steady_clock::now();
child_ = proc::start_shell_background(command_, root_.string(),
inheritOutput_);
if (!child_.ok) {
gaveUp_.store(true);
return;
}
proc::guard_background_on_signal(child_);
}
});
}
void Span::close() {
if (!declared_ || closed_) return;
closed_ = true;
stop_.store(true);
if (supervisor_.joinable()) supervisor_.join();
if (started_) {
proc::clear_background_guard(child_);
proc::stop_background(child_, kStopGrace);
}
}
bool Span::finish() {
const bool alreadyClosed = closed_;
close();
if (alreadyClosed || !started_) return ok_;
if (!gaveUp_.load()) return ok_;
// Reported once, here, rather than from the supervisor thread: ui writes
// are not synchronised, and a thread printing into the middle of ninja's
// output is the interleaving `during_build` discards its child's stdout to
// avoid in the first place.
return report_hook_failure_flag(sideEffect_, std::format(
"hook 'during_build' failed to stay up: {} consecutive runs ended "
"within {}ms", kShortRunsBeforeGivingUp, kTooShort.count()));
}
Span::~Span() { close(); }
} // namespace mcpp::hooks
You can’t perform that action at this time.
