mandrel-platform 1.13.3 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # ACTIONS_RUNNER_HOOK_JOB_COMPLETED hook — reap THIS job's own surviving
4
+ # process tree at job end, on PERSISTENT self-hosted runners
5
+ # (mandrel-platform runner kit).
6
+ #
7
+ # ── WHY A SECOND HOOK (the gap job-cleanup.sh cannot close) ─────────────────
8
+ #
9
+ # `job-cleanup.sh` (ACTIONS_RUNNER_HOOK_JOB_STARTED) reaps a PREVIOUS job's
10
+ # orphans at the START of the next one. That is the right defence for a job
11
+ # that has not begun — but it runs before the new job's own processes exist,
12
+ # so it can do nothing once that job is minutes in. Nothing reaped a job's
13
+ # tree when the job ENDED, and a cancelled job is exactly where the tree
14
+ # survives: the runner terminates the step it is executing, not everything
15
+ # that step forked.
16
+ #
17
+ # The observed failure (consumer run 34854313590, 2026-09-14): a `Unit` job
18
+ # exited 143 (SIGTERM) two minutes into a 32-minute budget with every test
19
+ # passing and no cancellation request in the runner's own `Worker_*.log` —
20
+ # i.e. the signal came from outside the runner. Four minutes earlier the SAME
21
+ # runner had hosted a `cancelled` Unit job for a superseded push. Every
22
+ # concurrent job on the pool's other runners passed. A survivor of the
23
+ # cancelled job — a vitest fork or a dev server — was still on that runner
24
+ # with the next job's processes.
25
+ #
26
+ # This hook runs after the last step of every job, cancelled or not, and
27
+ # terminates whatever of that job's tree is still alive: SIGTERM, a bounded
28
+ # grace period, then SIGKILL. Together the two hooks are belt and braces —
29
+ # started = defence against the PREVIOUS job, completed = the job cleans up
30
+ # after itself while the runner still knows whose processes these are.
31
+ #
32
+ # ── CONCURRENCY SAFETY (the same load-bearing constraints as job-cleanup.sh)
33
+ #
34
+ # Multiple runners on one host typically run as the SAME OS user, so anything
35
+ # resolved against $HOME is SHARED across every co-resident runner. This hook
36
+ # therefore honours the constraints issue #343 produced, one for one:
37
+ #
38
+ # 1. NEVER a $HOME-shared path. `~/setup-pnpm` (pnpm/action-setup's DEFAULT
39
+ # `dest`) is not a reap target here any more than it is in
40
+ # job-cleanup.sh: a co-resident runner may be mid-install in it. The
41
+ # pnpm shim is runner-scoped at install time instead — see
42
+ # templates/runbooks/runner-provisioning.md § "pnpm scoping".
43
+ # 2. NEVER a shared $TMPDIR scan, and no directory enumeration at all. The
44
+ # hook runs on the JOB's clock, so every read is billed to the job. Its
45
+ # whole input is ONE `ps` snapshot, whose cost is a function of the
46
+ # host's process count — bounded and small — never of the 841,690-entry
47
+ # temp root that made `Set up runner` take 5m29s in #343.
48
+ # 3. EVERY path derives from RUNNER_DIR, which is unique per runner. A
49
+ # process is a reap candidate only when its command line resolves inside
50
+ # THIS runner's `<RUNNER_DIR>/_work/` tree, so a co-resident runner's
51
+ # processes are unreachable from here by construction.
52
+ # 4. NEVER this script, its own ancestors, or the runner itself. The
53
+ # ancestor chain is walked and protected explicitly, and
54
+ # `Runner.Worker` / `Runner.Listener` are excluded by name — signalling
55
+ # either would take the runner offline mid-job.
56
+ #
57
+ # ── WHAT IS REAPED ──────────────────────────────────────────────────────────
58
+ #
59
+ # Seed: processes whose command line contains `<RUNNER_DIR>/_work/`. Then the
60
+ # seeds' DESCENDANTS, transitively, by parent pid — a job's `sleep`, `esbuild`
61
+ # or worker fork carries no runner path in its own argv, so matching on the
62
+ # path alone would leave the leaves of the tree behind. Descendants of a
63
+ # runner-scoped process are runner-scoped by parentage, so the expansion does
64
+ # not widen the blast radius beyond this runner.
65
+ #
66
+ # ── PORTABILITY ─────────────────────────────────────────────────────────────
67
+ #
68
+ # macOS AND Linux runners, and macOS's system bash 3.2 (Apple cannot ship a
69
+ # GPL3 bash) — so no `declare -A`, no `mapfile`/`readarray`, no `${var,,}`.
70
+ # Process handling uses only forms both platforms have: `ps -A -w -w -o
71
+ # pid=,ppid=,command=` and `kill -TERM` / `-KILL` / `-0` with explicit pids.
72
+ # Deliberately NOT `pkill -f`: a pattern kill cannot exclude this script, its
73
+ # ancestors, or a co-resident runner's lookalike, and its matching semantics
74
+ # differ between the two platforms. The repeated `-w` is load-bearing —
75
+ # without it BSD `ps` truncates argv and a runner path late in a long node
76
+ # command line would go unseen.
77
+ #
78
+ # ── PARAMETERIZATION ────────────────────────────────────────────────────────
79
+ #
80
+ # RUNNER_DIR — the runner's root directory. Defaults to the directory
81
+ # containing this script (the kit installs the hook into the
82
+ # runner root, next to config.sh / run.sh). Override via env
83
+ # only if you install the hook elsewhere.
84
+ #
85
+ # Configured via
86
+ # `ACTIONS_RUNNER_HOOK_JOB_COMPLETED=<RUNNER_DIR>/job-completed.sh` in the
87
+ # runner's `.env` (see .env.example in this directory).
88
+ #
89
+ # NEVER fails the job — best-effort cleanup, always exits 0. Findings go to
90
+ # the job log, where they are attributable to the job that leaked them.
91
+
92
+ set -u
93
+
94
+ RUNNER_DIR="${RUNNER_DIR:-$(cd "$(dirname "$0")" && pwd)}"
95
+ RUNNER_WORK="${RUNNER_DIR}/_work"
96
+
97
+ # Grace between SIGTERM and SIGKILL: 30 polls × 0.1s = 3s worst case. Polled
98
+ # rather than slept whole, so a tree that exits on SIGTERM — the normal case —
99
+ # costs one poll, and a job with nothing to reap sleeps not at all.
100
+ GRACE_POLLS=30
101
+ POLL_INTERVAL=0.1
102
+
103
+ log() {
104
+ printf 'job-completed: %s\n' "$1"
105
+ }
106
+
107
+ # Protected set: this script and every one of its ancestors. On a real runner
108
+ # the chain runs job-completed.sh -> Runner.Worker -> Runner.Listener, and
109
+ # signalling any of them would end the job's own bookkeeping or the runner
110
+ # service. Bounded at 32 hops so a cycle in a hostile process table cannot
111
+ # spin here on the job's clock.
112
+ self_pid=$$
113
+ protected=" ${self_pid} "
114
+ ancestor=${self_pid}
115
+ hops=0
116
+ while [ "$hops" -lt 32 ]; do
117
+ parent=$(ps -o ppid= -p "$ancestor" 2>/dev/null | tr -d '[:space:]')
118
+ case "$parent" in
119
+ "" | 0 | 1) break ;;
120
+ esac
121
+ protected="${protected}${parent} "
122
+ ancestor=$parent
123
+ hops=$((hops + 1))
124
+ done
125
+
126
+ # ONE snapshot, reused for the seed pass and every expansion round. Re-running
127
+ # `ps` per round would bill the job for each; it would also let the table shift
128
+ # underneath the walk, so a single snapshot is the cheaper AND the more
129
+ # consistent choice.
130
+ snapshot=$(ps -A -w -w -o pid=,ppid=,command= 2>/dev/null)
131
+ if [ -z "$snapshot" ]; then
132
+ log "process table unavailable — nothing reaped"
133
+ exit 0
134
+ fi
135
+
136
+ # Seed: command lines resolving inside THIS runner's work tree. The pattern is
137
+ # quoted inside the `case`, so a metacharacter in a runner path is matched
138
+ # literally rather than globbed.
139
+ doomed=""
140
+ while read -r pid ppid command; do
141
+ [ -n "$pid" ] || continue
142
+ case "$protected" in
143
+ *" ${pid} "*) continue ;;
144
+ esac
145
+ case "$command" in
146
+ *Runner.Worker* | *Runner.Listener*) continue ;;
147
+ esac
148
+ case "$command" in
149
+ *"${RUNNER_WORK}/"*) doomed="${doomed}${pid} " ;;
150
+ esac
151
+ done <<SNAPSHOT
152
+ $snapshot
153
+ SNAPSHOT
154
+
155
+ # Expand to descendants, transitively. Each round adds the children of pids
156
+ # already condemned; the loop stops as soon as a round adds nothing, and is
157
+ # bounded at 32 rounds (a deeper live tree than any job produces) so a
158
+ # malformed table cannot loop forever.
159
+ rounds=0
160
+ while [ "$rounds" -lt 32 ]; do
161
+ added=0
162
+ while read -r pid ppid command; do
163
+ [ -n "$pid" ] || continue
164
+ case "$protected" in
165
+ *" ${pid} "*) continue ;;
166
+ esac
167
+ case " ${doomed}" in
168
+ *" ${pid} "*) continue ;;
169
+ esac
170
+ case "$command" in
171
+ *Runner.Worker* | *Runner.Listener*) continue ;;
172
+ esac
173
+ case " ${doomed}" in
174
+ *" ${ppid} "*)
175
+ doomed="${doomed}${pid} "
176
+ added=1
177
+ ;;
178
+ esac
179
+ done <<SNAPSHOT
180
+ $snapshot
181
+ SNAPSHOT
182
+ [ "$added" -eq 1 ] || break
183
+ rounds=$((rounds + 1))
184
+ done
185
+
186
+ if [ -z "$doomed" ]; then
187
+ log "no surviving processes under ${RUNNER_WORK} — nothing to reap"
188
+ exit 0
189
+ fi
190
+
191
+ log "reaping processes that outlived this job under ${RUNNER_WORK}: ${doomed% }"
192
+ for pid in $doomed; do
193
+ kill -TERM "$pid" 2>/dev/null
194
+ done
195
+
196
+ # Liveness is read from the process state, not from `kill -0`: a ZOMBIE still
197
+ # answers signal 0 but can never be signalled again — it disappears when its
198
+ # parent waits, or when init reaps it after the parent dies. Polling one out
199
+ # would spend the whole grace period, on the job's clock, waiting for
200
+ # something already dead. An empty state means the pid is gone.
201
+ polls=0
202
+ while :; do
203
+ alive=""
204
+ for pid in $doomed; do
205
+ state=$(ps -o state= -p "$pid" 2>/dev/null | tr -d '[:space:]')
206
+ case "$state" in
207
+ "" | Z*) continue ;;
208
+ esac
209
+ alive="${alive}${pid} "
210
+ done
211
+ [ -n "$alive" ] || break
212
+ [ "$polls" -lt "$GRACE_POLLS" ] || break
213
+ sleep "$POLL_INTERVAL"
214
+ polls=$((polls + 1))
215
+ done
216
+
217
+ if [ -n "$alive" ]; then
218
+ log "grace expired — escalating to SIGKILL: ${alive% }"
219
+ for pid in $alive; do
220
+ kill -KILL "$pid" 2>/dev/null
221
+ done
222
+ fi
223
+
224
+ exit 0