-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgenerate_examples
More file actions
executable file
·593 lines (559 loc) · 40.8 KB
/
Copy pathgenerate_examples
File metadata and controls
executable file
·593 lines (559 loc) · 40.8 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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
#!/usr/bin/env bash
#
# Regenerate the QUICK example output files in examples/ by iterating each project-root
# script's # Usage: block, skipping every Usage line that invokes a slow script (the curated
# list in lib/slow_scripts — gated scripts, find_dependencies_minimal_*, the test suites,
# run_php_unit_tests, find_flaky_selenium_tests). ./generate_examples_slow regenerates every
# example, slow ones included. Filenames are derived from the command (see lib/cmd_to_filename).
# Takes roughly 30-60 minutes serially (less with PARALLEL=N); useful for refreshing examples/
# in bulk after script renames or behavior changes without paying for the multi-hour slow set.
#
# The skip is per Usage LINE, keyed on the script the line actually invokes (cmd_to_script,
# lib/cmd_to_filename), not on the script whose header contains it: suggest_parallel's Usage
# block cross-references ./run_selenium_tests_all_gated, and a per-script filter would let
# that multi-hour line into the quick run the moment its home script's identical line — which
# normally claims the shared filename first — was filtered out. The summary reports the
# skipped count ("slow N") plus a pointer at ./generate_examples_slow; a full run (the
# wrapper exports _QUIBBLE_EXAMPLES_ALL=1) skips nothing, prints no pointer, and keeps the
# pre-split summary field layout — only the leading label names the invoked variant.
#
# Usage: ./generate_examples
# PREVIEW=1 ./generate_examples
# DRY_RUN=1 ./generate_examples
# PARALLEL=N ./generate_examples
# PARALLEL=N DRY_RUN=1 ./generate_examples
#
# PREVIEW, DRY_RUN, and PARALLEL each do different things and can be combined:
# PREVIEW=1 ./generate_examples — outer-level preview. Prints "Would generate ..." for each
# Usage line. No files written, no inner scripts run.
# (Named PREVIEW, not DRY_RUN, because PREVIEW skips execution
# entirely; DRY_RUN still runs the wrapper scripts — see below.)
# DRY_RUN=1 ./generate_examples — actually generate every file, but DRY_RUN=1 is inherited by
# every inner Usage command via the env, so Quibble short-circuits.
# Inner scripts that honor DRY_RUN (install, fresh_install,
# run_php_unit_tests, run_selenium_tests, and batch scripts that
# propagate the env var to them via lib/setup) finish in seconds
# instead of minutes. Other scripts ignore the unused env var.
# This matches how DRY_RUN already propagates in
# find_dependencies_minimal_* and other batch scripts.
# Captures are written to examples/dry_run/ — committed
# like the real captures, but kept in their own directory
# so a dry run never overwrites the real examples/*.txt.
# PARALLEL=N ./generate_examples — run middle-phase Usage lines (not whole scripts)
# concurrently across N reusable worker slots (lib/run_pool):
# as each line finishes, its slot is refilled, so a script's
# slow variants spread across slots and no slot idles while
# work remains. Each slot runs in an isolated ENVIRONMENT=N
# (src_N/) seeded from the shared src_save, so every line runs
# against a clean baseline, with QUIBBLE_SRC/QUIBBLE_SAVE
# pinned to the slot (so an inner `PARALLEL=1 ...` line's own
# ENVIRONMENT=1 export cannot escape into src_1 and collide
# with another slot), and with its own QUIBBLE_LOG_DIR
# (log/<timestamp>/<example>, keyed by the line's examples file,
# not the slot) so concurrent batch scripts don't clobber each
# other's logs and a later line reusing the slot can't wipe a
# failed line's logs. Each worker also unsets PARALLEL so a bare
# inner command captures serial output (an inline
# `PARALLEL=1 ...` in a Usage line still wins). Early scripts
# (prepare, prepare_gated, fresh_install, save) and late scripts
# (remove_srcs, remove, remove_all) stay serial — they
# prepare/destroy state every middle worker depends on.
# Per-item output goes to log/<timestamp>/<example>/, where
# each worker also writes a "pass" marker as its last action.
# A missing marker means the worker died mid-item (a captured
# command's own non-zero exit is NOT a failure — see
# generate_example): the pool prints a live "Slot N FAILED"
# line pointing at the item's log, keeps going, recaps every
# failed item at the end, reports "failed N" in the summary,
# and exits non-zero. A failed item's examples/*.txt is
# missing or stale — regenerate it before committing. (On a
# completed full run the late-phase remove_all deletes log/,
# so the recap's command list is the durable record.)
# PREVIEW=1 forces serial regardless of PARALLEL because
# parallel worker output would interleave unhelpfully when
# previewing the plan.
# Combinations: PREVIEW=1 DRY_RUN=1 previews the DRY_RUN command list (PREVIEW wins — nothing runs).
# PARALLEL=N DRY_RUN=1 runs the parallel path while short-circuiting Quibble. Use DRY_RUN=1 to
# iterate on generate_examples itself or to validate the pipeline end-to-end.
# DRY_RUN captures do not reflect real script behavior — which is why they live in their
# own committed examples/dry_run/ directory and never overwrite the real examples/.
#
set -euo pipefail # exit on error, undefined vars, or pipe failures
. "$(dirname "$0")"/lib/duration_trap # print elapsed time on exit
. "$(dirname "$0")"/lib/format_duration # provides _quibble_format_duration for the summary line below (also pulled in transitively by duration_trap; sourced explicitly so the dependency is obvious)
. "$(dirname "$0")"/lib/cmd_to_filename # provides cmd_to_filename and cmd_to_script functions
. "$(dirname "$0")"/lib/slow_scripts # provides _quibble_slow_scripts (scripts whose Usage lines the quick run skips)
. "$(dirname "$0")"/lib/pluralize # provides pluralize() for the quick-mode pointer line at the end
. "$(dirname "$0")"/lib/inhibit_sleep # prevent the machine from suspending during the long run
. "$(dirname "$0")"/lib/default_image # provides QUIBBLE_DEFAULT_IMAGE, seeding QUIBBLE_IMAGE below (for remove_dirs)
. "$(dirname "$0")"/lib/remove_dirs # provides remove_dirs (host rm first, Docker-as-root fallback, loud failure) for the pool worker's item-log-dir reset
# Quick/slow split: without _QUIBBLE_EXAMPLES_ALL (exported by ./generate_examples_slow,
# which execs this script), _process_script/_prewalk_script below skip every Usage line
# that invokes a script listed in lib/slow_scripts — the quick default. With it, nothing
# is skipped (the full, multi-hour set). The label names whichever variant the user
# actually invoked in this run's messages ($0 cannot: the wrapper's exec replaces its
# process with this script, so $0 is always this script's own path).
if [ -n "${_QUIBBLE_EXAMPLES_ALL:-}" ]; then # ${VAR:-} is "" when VAR is unset (works under set -u)
label="generate_examples_slow" # invoked via the wrapper: full run, no filter
else
label="generate_examples" # invoked directly: quick run, slow lines skipped
fi
# DRY_RUN mode: warn loudly on stderr so the user can't miss it when piping/teeing.
# DRY_RUN=1 is inherited by every inner Usage command via the env (no explicit prepend
# needed); lib/setup picks it up in each Quibble-running inner script and passes --dry-run.
if [ -n "${DRY_RUN:-}" ]; then
echo "$label: DRY_RUN mode enabled — captures land in examples/dry_run/, separate from the real examples/" >&2
fi
# PARALLEL=N runs middle-phase Usage lines across N concurrent worker slots; unset/0 = serial (default).
# See the header comment for behavior; PREVIEW=1 forces serial regardless.
parallel="${PARALLEL:-0}"
# Switch to the script's directory (project root) so relative paths and ./scripts work
# regardless of where this is invoked from. $0 may be relative or absolute; dirname handles both.
cd "$(dirname "$0")"
# DRY_RUN captures land in examples/dry_run/ (committed, like the real captures — they
# document dry-run behavior and are used for debugging) instead of examples/*.txt, so a
# pipeline-validation run can never clobber the real captures. Files are overwritten per
# capture, exactly like examples/ itself — deliberately NO reset here: a quick run reaches
# only the quick Usage lines, so wiping the directory would delete the slow scripts'
# committed dry-run captures. Orphan removal is the user's manual operation, the same as
# for examples/ (see CLAUDE.md). PREVIEW writes no files and must stay side-effect-free,
# so it skips the mkdir. Sits after the cd above: the path is project-root-relative.
if [ -n "${DRY_RUN:-}" ] && [ -z "${PREVIEW:-}" ]; then
mkdir -p examples/dry_run # pre-create it: generate_example does not create parent directories
fi
# lib/remove_dirs expects QUIBBLE_DIR and QUIBBLE_IMAGE from lib/setup, but this script never
# sources lib/setup (only the inner Usage commands it captures do) — so provide the same two
# values here: the absolute project root (just cd'd into above), and the default image with the
# same QUIBBLE_IMAGE env override lib/setup honors. Only the pool worker's remove_dirs call
# reads them; they are deliberately not exported, so inner scripts still derive their own.
# shellcheck disable=SC2034 # consumed by the sourced remove_dirs function, which shellcheck can't follow
QUIBBLE_DIR="$(pwd)" # absolute project root; remove_dirs resolves its (relative) dir arguments against it
QUIBBLE_IMAGE="${QUIBBLE_IMAGE:-$QUIBBLE_DEFAULT_IMAGE}" # image for remove_dirs' Docker-as-root fallback
# Track filenames already generated so two distinct Usage lines don't silently overwrite
# each other. Bash 3.2 has no associative arrays; use a delimited string.
seen="|"
# Counters for the labeled summary line printed at the end. The script can run for hours,
# and `lib/duration_trap`'s bare `(Xs)` line at exit gets lost in the per-file `(Xs)` lines
# `./generate_example` also prints (one per Usage variant). The labeled summary gives the
# user a single anchor with the totals and overall runtime, while duration_trap's line still
# prints last as the unconditional short form.
generated=0 # number of Usage lines that produced (or would produce, in PREVIEW mode) an examples/*.txt
skipped=0 # number of Usage lines skipped (no Usage block, filename collision, etc.)
slow=0 # Usage lines skipped by the quick run because they invoke a slow script (lib/slow_scripts); always 0 in a full run (_QUIBBLE_EXAMPLES_ALL=1, via ./generate_examples_slow)
failed=0 # parallel-pool items whose worker died mid-item (counted by _pool_reap below); serial mode instead aborts loudly at the first failure (set -e), so this stays 0 there
# Pin certain scripts at the start and end of the iteration. Most scripts depend on a
# prepared env (cache/, log/, src/, ref/, the Docker image), so prep scripts must run
# first. Destructive scripts must run last, since they wipe state any later script would
# need. Everything else stays alphabetical. See CLAUDE.md ("destructive across environments")
# for the matching contract.
early=(prepare prepare_gated fresh_install save) # in pinned order: prepare → fresh_install → save
late=(remove_srcs remove remove_all) # in pinned order: least → most destructive
# Build the alphabetical "others" list: every project-root file that passes auto-discovery
# (bash shebang or shellcheck directive in its first line) and isn't pinned to early/late.
# Auto-discover scripts the same way ./lint and ./help do.
others=() # alphabetical middle of the iteration
for script in *; do
[ -f "$script" ] || continue # skip directories
[ -x "$script" ] || continue # skip non-executable files
# Avoid recursion: don't try to regenerate examples for ourselves, our slow-variant
# wrapper (capturing it would exec this script again — recursing in a full run, or
# smuggling the full multi-day set into a quick one: the wrapper itself is deliberately
# not in lib/slow_scripts), or our single-file helper.
# Also skip `shellto`: it is interactive by design (drops into a Docker bash shell), so
# its capture would block forever on stdin inside `./generate_example`'s eval. See
# CLAUDE.md ("shellto is an exception").
case "$script" in generate_examples|generate_examples_slow|generate_example|shellto) continue ;; esac
read -r first_line < "$script" || continue # read first line; skip on read failure
# Match scripts the same way auto-discovery does elsewhere in this repo.
case "$first_line" in
'#!/usr/bin/env bash'|'# shellcheck'*) ;;
*) continue ;;
esac
# Skip scripts pinned to the early/late phases — they're added explicitly below in fixed order.
# The leading/trailing spaces around the joined list let the pattern match each whole word.
case " ${early[*]} ${late[*]} " in
*" $script "*) continue ;;
esac
others+=("$script") # else it's an alphabetical middle script
done
# Is this Usage line's invoked script in the slow list? Extracts the target script via
# cmd_to_script (lib/cmd_to_filename) and matches it against _quibble_slow_scripts
# (lib/slow_scripts). Returns 0 (slow) or 1 (quick, or no ./-word in the line). Called by
# the quick-mode per-line filter in _process_script and _prewalk_script.
_slow_cmd() {
local target # the script the line invokes (e.g. run_selenium_tests_all_gated)
# Declared above, assigned here: `local target=$(...)` would replace cmd_to_script's
# exit status with local's own (always 0), breaking the || fallthrough.
target="$(cmd_to_script "$1")" || return 1 # no ./-word: treat the line as not slow (see header)
# The leading/trailing spaces around the joined list let the pattern match each whole
# word — the same idiom as the early/late pin check in the discovery loop above.
case " ${_quibble_slow_scripts[*]} " in
*" $target "*) return 0 ;; # invoked script is slow-listed
esac
return 1 # not listed: the quick run keeps this line
}
# Derive the examples file a Usage command's capture is written to. Under outer DRY_RUN the
# whole run's captures document dry-run (not real) behavior, so they are redirected into the
# sibling examples/dry_run/ directory instead of overwriting the real examples/*.txt.
# Rewriting here — at derivation time — keeps every consumer of the name in agreement: the
# seen collision tracker, the parallel work-file tuples, the per-item log-dir key (the
# ${file##*/} basename is unchanged), PREVIEW's "Would generate" lines, and the
# ./generate_example target. An inline `DRY_RUN=1 ./install ...` Usage line is NOT
# redirected: it is a real capture of inner dry-run behavior; only the outer env — set for
# this whole script — redirects.
_example_file() {
local file # the derived filename
file="$(cmd_to_filename "$1")" # the committed examples/<...>.txt name
if [ -n "${DRY_RUN:-}" ]; then # outer DRY_RUN: redirect to the throwaway dir
file="examples/dry_run/${file#examples/}" # swap the prefix (${file#examples/} strips it); the basename stays identical
fi
printf '%s\n' "$file" # printf, not echo: immune to backslash mangling
}
# Process all Usage lines for one script: parse its Usage block, iterate each line,
# skip slow lines (quick mode) and filename collisions, then either preview or invoke
# generate_example.
# Reads env vars: PREVIEW, _QUIBBLE_EXAMPLES_ALL. (DRY_RUN, if set, propagates via env
# to the inner commands.)
# Mutates globals: seen (collision tracker), generated/skipped/slow (counters).
# Extracted into a function so both the serial loop and (future) parallel workers
# can share the same per-script processing logic without duplication.
_process_script() {
local script="$1" # script name (e.g. install_each_gated)
local usage_block # captured Usage block from parse_usage.awk
local cmd file # working vars inside the inner loop
# Capture every Usage line of this script.
usage_block="$(awk -f lib/parse_usage.awk "$script")"
if [ -z "$usage_block" ]; then
echo "Skipping $script: no # Usage: block" >&2 # >&2 prints to stderr
skipped=$((skipped + 1)) # count toward the summary line below
return # nothing more to do for this script
fi
# Iterate each Usage line. The herestring goes to FD 3, NOT stdin, so child processes
# (e.g. `docker run -i ...` invoked by ./install, ./fresh_install, ./run_selenium_tests,
# ./run_php_unit_tests) can't drain the remaining lines. The `-i` flag attaches docker
# to stdin and forwards it to the container; with the herestring on stdin, docker would
# swallow every subsequent Usage line and the next `read` would silently hit EOF,
# generating only the first Usage variant per script.
while IFS= read -r cmd <&3; do
[ -n "$cmd" ] || continue # skip blank lines (defensive)
# Quick mode: skip a line that invokes a slow script — checked per LINE via _slow_cmd,
# so suggest_parallel's cross-referencing Usage line is caught too (see header). Runs
# BEFORE the collision check below, so a skipped line claims no filename.
if [ -z "${_QUIBBLE_EXAMPLES_ALL:-}" ] && _slow_cmd "$cmd"; then
slow=$((slow + 1)) # count toward the summary's slow field and the pointer line
continue # nothing to generate for this line in a quick run
fi
file="$(_example_file "$cmd")" # derive the capture's target file (examples/, or examples/dry_run/ under DRY_RUN)
case "$seen" in
*"|$file|"*) # already generated this filename earlier
echo "Skipping $cmd: $file already generated (filename collision)" >&2
skipped=$((skipped + 1)) # count toward the summary line below
continue
;;
esac
seen="${seen}${file}|" # remember we've claimed this filename
# DRY_RUN=1 (if set) is inherited by ./generate_example and the eval'd Usage command via env;
# lib/setup picks it up in Quibble-running inner scripts and passes --dry-run. No prepend needed.
if [ -n "${PREVIEW:-}" ]; then # ${VAR:-} is "" when VAR is unset (works under set -u)
echo "Would generate $file from: $cmd" # PREVIEW mode: print what would happen
else
./generate_example "$file" "$cmd" # actually run the command and capture output
fi
generated=$((generated + 1)) # count toward the summary line below
done 3<<< "$usage_block" # herestring on FD 3 — see note above
}
# Pre-walk one script: same slow-line filter and dedup logic as _process_script, but writes
# the surviving (file, cmd) tuples to a per-script work file instead of dispatching them.
# Used by the parallel path to centralize the `seen` collision tracker in the main process
# before workers spawn — workers then process pre-filtered tuples and don't need their own
# tracking.
# DRY_RUN=1 (if set) propagates to worker subshells via env inheritance, no special handling.
# Mutates globals: seen, generated, skipped, slow, work_dir.
_prewalk_script() {
local script="$1" # script name (e.g. install_each_gated)
local work_file="$work_dir/$script.work" # per-script work file path
local usage_block cmd file # working vars
usage_block="$(awk -f lib/parse_usage.awk "$script")"
if [ -z "$usage_block" ]; then
echo "Skipping $script: no # Usage: block" >&2 # >&2 prints to stderr
skipped=$((skipped + 1)) # count toward the summary line below
return # nothing more to do for this script
fi
# Same herestring-on-FD-3 pattern as _process_script — see the comment there for why.
while IFS= read -r cmd <&3; do
[ -n "$cmd" ] || continue # skip blank lines (defensive)
# Same quick-mode slow-line filter as _process_script — see the comment there.
if [ -z "${_QUIBBLE_EXAMPLES_ALL:-}" ] && _slow_cmd "$cmd"; then
slow=$((slow + 1)) # count toward the summary's slow field and the pointer line
continue # a skipped line claims no filename and joins no work file
fi
file="$(_example_file "$cmd")" # derive the capture's target file (examples/, or examples/dry_run/ under DRY_RUN)
case "$seen" in
*"|$file|"*) # already claimed this filename
echo "Skipping $cmd: $file already generated (filename collision)" >&2
skipped=$((skipped + 1))
continue
;;
esac
seen="${seen}${file}|" # claim this filename
# Tab-separated tuple: <file>\t<cmd>. Neither field contains tabs in practice
# (filenames are sanitized by cmd_to_filename; Usage commands don't use tabs).
printf '%s\t%s\n' "$file" "$cmd" >> "$work_file"
generated=$((generated + 1))
done 3<<< "$usage_block"
}
# Read tab-separated <file>\t<cmd> tuples from a work file and invoke ./generate_example
# for each. Used by the parallel path's early and late phases: the pinned scripts run
# serially in the main process (the middle phase uses _run_pool_worker instead).
# Empty/missing work files (script had no Usage block) are a silent no-op.
_execute_work_file() {
local work_file="$1" # path to per-script work file
local file cmd # split fields from each line
[ -f "$work_file" ] || return 0 # no work — nothing to do
# Read the tuples on FD 3, NOT stdin: ./generate_example runs each command with stdin
# inherited, so a capture that runs `docker run -i` (e.g. ./fresh_install in the early
# phase) would drain the rest of this work file off stdin and the loop would run only the
# first tuple — the exact hazard _process_script/_prewalk_script avoid above. Without FD 3,
# ./fresh_install swallows ENVIRONMENT=0 ./fresh_install (so src_0/ is never built) and the
# later ENVIRONMENT=0 ./save capture fails on the missing src_0/.
while IFS=$'\t' read -r file cmd <&3; do # <&3: tuples stay on FD 3, off the command's stdin
./generate_example "$file" "$cmd"
done 3< "$work_file" # 3<: open the work file on FD 3
}
# Append every "<file>\t<cmd>" tuple from a pre-walked work file to the global `items` array
# (the dispatch list lib/run_pool consumes). Missing/empty work file (script had no Usage
# block) is a silent no-op.
_append_work_tuples() {
local work_file="$1" tuple
[ -f "$work_file" ] || return 0
while IFS= read -r tuple; do # one tuple per line; IFS= preserves the tab inside it
[ -n "$tuple" ] || continue
items+=("$tuple") # append to the ambient `items` dispatch array
done < "$work_file"
}
# Worker body for one middle Usage line, run by lib/run_pool inside a background subshell.
# SLOT is a stable 1..parallel id (reused as items complete). Isolates per-slot state, seeds
# src_$SLOT from the shared src_save, and captures the command's output via ./generate_example.
# DRY_RUN (if set) propagates to the inner command via the env. Defines no globals.
_run_pool_worker() {
local slot="$1" # 1..parallel slot id (stable; reused as items finish)
local item="$2" # "<file>\t<cmd>" tuple produced by _prewalk_script
local file cmd
IFS=$'\t' read -r file cmd <<< "$item" # split on the tab (same delimiter _prewalk_script wrote)
export ENVIRONMENT="$slot" # lib/setup derives QUIBBLE_SRC=src_$slot
# Pin the slot's src/save dirs explicitly (not just via ENVIRONMENT): lib/setup preserves an
# already-set QUIBBLE_SRC/QUIBBLE_SAVE, so when a Usage line runs an inner `PARALLEL=1 ...`
# script whose own pool worker exports ENVIRONMENT=1, that worker still operates on THIS
# slot's src_$slot/src_save_$slot instead of escaping to src_1/src_save_1 — where it would
# collide with slot 1 (and with every other slot running an inner-PARALLEL line). Inner
# FAST-mode entries save/restore in the pinned src_save_$slot, isolated per slot. Mirrors
# test_integration_slow's pool worker.
export QUIBBLE_SRC="src_$slot" # pin the slot's src dir (survives a nested ENVIRONMENT=1)
export QUIBBLE_SAVE="src_save_$slot" # pin the slot's save dir (inner FAST-mode entries save/restore here)
export QUIBBLE_BACKGROUND=1 # lib/setup drops Docker -i/-t flags for background runs
local example="${file##*/}" # examples file basename (${file##*/} strips the examples/ prefix); unique per run (pre-walk collision check), so it can key this item's logs
export QUIBBLE_LOG_DIR="$QUIBBLE_LOG_DIR/${example%.txt}" # per-ITEM subdir of this run's log dir (%.txt drops the extension), keyed by the examples file, NOT the slot: run_pool reuses slots, so a slot-keyed dir would let a later item wipe a failed item's logs (the reset below)
unset PARALLEL # bare inner commands capture serial output; an inline `PARALLEL=1 ...` in the Usage line still wins
# Start the item with a clean log dir. Item names are unique per run, so within a run this
# dir cannot pre-exist — but leftovers CAN exist (e.g. a rerun inheriting QUIBBLE_LOG_DIR from
# the env), and Quibble containers write to the mounted log dir as a different user, leaving
# ROOT-OWNED files (e.g. browser-video subdirectories from selenium runs). On Linux a plain
# host `rm -rf` cannot delete those; under set -e that failure would kill this worker before
# it dispatches. remove_dirs handles them: host rm first, Docker-as-root fallback, loud
# failure if it genuinely can't (lib/remove_dirs).
remove_dirs "$QUIBBLE_LOG_DIR" # QUIBBLE_LOG_DIR is project-root-relative (log/<ts>/<example>), resolved against QUIBBLE_DIR
mkdir -p "$QUIBBLE_LOG_DIR" # recreate it empty for this item
exec > "$QUIBBLE_LOG_DIR/$example.log" 2>&1 # redirect this worker's own output to its item log
export _QUIBBLE_TRACE=1 # children's set -x trace belongs in this captured log (lib/capture)
# Seed src_$slot from the shared src_save (populated by the early-phase ./save) so every line
# runs against a clean baseline. Concurrent reads from src_save are safe (it's a regular dir).
# The inline QUIBBLE_SAVE=src_save deliberately overrides the src_save_$slot pin above for
# this one command: the seed READS the shared baseline, while everything after it uses the
# pinned per-slot save dir.
# Under DRY_RUN=1 the early ./fresh_install short-circuits, src/ is empty, ./save errors out
# (it requires a populated src/), and src_save may not exist — skip the seed then; the DRY_RUN
# inner commands won't actually need a populated src_$slot.
if [ -d src_save ]; then
QUIBBLE_SAVE=src_save ./restore || true # best-effort; don't abort the worker on failure
fi
./generate_example "$file" "$cmd" # run the command and capture its output to examples/<file>.txt
# Success marker, written as this worker's LAST action (the result-file contract
# lib/pool_hooks' pools use): under set -e any earlier failure kills this subshell before
# reaching it, so _pool_reap treats a missing marker as this item's failure. QUIBBLE_LOG_DIR
# here is the item's own log dir (re-pinned above) — item-keyed, so no later item wipes it.
echo "pass" > "$QUIBBLE_LOG_DIR/$example.result"
}
# Friendlier pool progress line: show the slot and the command, not the raw "<file>\t<cmd>" tuple.
# Also stamp when this slot's current item started, so _pool_reap can report its wall-clock.
_pool_worker_label() {
local _file _cmd
IFS=$'\t' read -r _file _cmd <<< "$2" # $2 is the tuple; keep only the command for display
_slot_start[$1]=$(date +%s) # epoch seconds; paired with _pool_reap (date +%s is BSD/GNU-portable)
printf " Slot %d: %s\n" "$1" "$_cmd" >&2
}
# lib/run_pool hook: per-completion. Runs in the main process (run_pool is sourced), so it
# updates the shared counters and timing log directly. $1 = slot, $2 = "<file>\t<cmd>" tuple.
# Two jobs:
# - Failure detection (always on): the worker writes a "pass" marker into its item log dir
# as its LAST action, so a missing/other marker means the worker died mid-item (the
# result-file contract lib/pool_hooks' pools use). A captured command's own non-zero exit
# is NOT a failure — generate_example tolerates those by design (usage-on-failure
# examples). Prints a live FAILED line, fixes the counters, records the item for the
# recap after the pool.
# - Timing (TIME_ELAPSED=1 only): the per-item "done" line plus the data for the sorted
# "Slowest items" summary after the pool.
# Every path must return 0: run_pool calls this hook unchecked, so under set -e a non-zero
# return would abort the whole run mid-pool.
_pool_reap() {
local _file _cmd _example _itemdir _where _elapsed
IFS=$'\t' read -r _file _cmd <<< "$2"
_example="${_file##*/}" # examples file basename — the same key the worker derived for its log dir
_itemdir="$QUIBBLE_LOG_DIR/${_example%.txt}" # the item's log dir under the run root (this main-shell QUIBBLE_LOG_DIR is the run root; the worker's item pin was subshell-local)
if [ ! -f "$_itemdir/$_example.result" ] || [ "$(cat "$_itemdir/$_example.result")" != "pass" ]; then # no/other marker: the worker died before completing the item
_where="no log captured" # a worker that died before its exec redirect already printed to the terminal
if [ -f "$_itemdir/$_example.log" ]; then # explicit if (not `[ ] && ...`): a trailing failed test would flip this unchecked hook's return code under set -e
_where="see $_itemdir/$_example.log" # the item log dir survives the run — no later item wipes it
fi
printf " Slot %d FAILED: %s (%s)\n" "$1" "$_cmd" "$_where" >&2 # live and unconditional, in the pools' FAIL-line style (lib/pool_hooks)
failed=$((failed + 1)) # drives the recap, the summary field, and the final exit 1
generated=$((generated - 1)) # the pre-walk counted this item as generated; nothing was produced — keep the summary truthful
failed_items="$failed_items
$_cmd ($_where)" # indented recap entry (newline-separated string, the batch scripts' idiom)
fi
[ -n "${TIME_ELAPSED:-}" ] || return 0 # the timing below is opt-in; the failure check above always runs
_elapsed=$(( $(date +%s) - ${_slot_start[$1]:-$(date +%s)} )) # seconds this item ran (0 if start missing)
printf " Slot %d done (%s): %s\n" "$1" "$(_quibble_format_duration "$_elapsed")" "$_cmd" >&2
printf '%s\t%s\n' "$_elapsed" "$_cmd" >> "$_pool_timing" # record for the sorted summary after the pool
}
# Stamp this run's log directory once (exports QUIBBLE_LOG_DIR — see lib/log_dir), so every
# captured command inherits it instead of stamping one directory per capture, and the pool
# workers below can pin per-slot subdirectories under it. PREVIEW runs nothing and must stay
# side-effect-free, so it skips the stamp.
if [ -z "${PREVIEW:-}" ]; then # ${VAR:-} is "" when VAR is unset (works under set -u)
. "$(dirname "$0")"/lib/log_dir # stamp or inherit the per-run log directory
fi
# Dispatch: PREVIEW or parallel<1 (unset/0) takes the serial path; parallel>=1 takes the
# parallel path (one isolated worker per script). PREVIEW forces serial regardless of PARALLEL
# because parallel worker output would interleave unhelpfully when previewing the work plan.
if [ -n "${PREVIEW:-}" ] || [ "$parallel" -lt 1 ]; then
# --- Serial path ---
# Final ordered iteration: early (pinned order) → alphabetical others → late (pinned order).
# `${others[@]+"${others[@]}"}` is the bash 3.2-safe way to expand a possibly-empty array
# under `set -u`: if `others` is empty the expansion is empty; otherwise it's the array
# elements quoted individually. Plain `"${others[@]}"` would error on an empty array.
for script in "${early[@]}" ${others[@]+"${others[@]}"} "${late[@]}"; do
_process_script "$script" # collision check, PREVIEW, dispatch
done
else
# --- Parallel path ---
# Per-script work files keep the (file, cmd) plan that workers will execute.
# Removed at the end on success; on error before cleanup, /tmp's normal cleanup handles it.
work_dir="$(mktemp -d)"
# Pre-walk all scripts in iteration order to centralize collision tracking in the main
# process. Mutates `seen`/`generated`/`skipped` so the summary at the end is correct.
for script in "${early[@]}" ${others[@]+"${others[@]}"} "${late[@]}"; do
_prewalk_script "$script"
done
# Early phase: serial in main process. Builds the shared state (src/, src_save/, ref/,
# cache/) every middle worker depends on. _execute_work_file is a no-op for any script
# without a Usage block, so a missing work file is fine.
for script in "${early[@]}"; do
_execute_work_file "$work_dir/$script.work"
done
# Middle phase: per-Usage-line dynamic pool. Flatten every middle script's pre-walked work
# file into one list of "<file>\t<cmd>" tuples, then dispatch them across $parallel reusable
# slots (lib/run_pool, which prints its own progress and refills each slot the instant its
# item finishes). Per-line (not per-script) granularity lets a script's heavy variants run on
# different slots, so the slowest single command — not the slowest script's total — bounds the
# run. _run_pool_worker (defined above) isolates each slot's state and captures the output.
#
# Order heavy scripts first (lib/heavy_scripts), so the long jobs start at t=0 and the many
# short jobs backfill the tail — keeping every slot busy to the end. Best-effort hint only:
# the pool is correct in any order, so a stale/missing entry just costs a little efficiency.
. "$(dirname "$0")"/lib/heavy_scripts # defines _quibble_heavy_scripts (most-expensive first)
# Per-item timing (TIME_ELAPSED=1 only): _pool_worker_label stamps each slot's start time,
# _pool_reap records each finished item's elapsed time here, and the sorted "Slowest items"
# summary after the pool surfaces the long poles. _slot_start is keyed by the stable 1..parallel
# slot id. The timing file lives in work_dir, so it's removed with everything else on success.
_slot_start=() # _slot_start[S] = epoch seconds when slot S's current item began
_pool_timing="$work_dir/.pool_timing" # one "<elapsed-seconds>\t<cmd>" line per completed item
: > "$_pool_timing" # start empty
# Failure tracking: _pool_reap appends one entry per dead worker for the recap after the
# pool (the live FAILED lines drown in hours of interleaved progress output otherwise).
failed_items="" # newline-separated " <cmd> (see <log>)" entries, appended by _pool_reap
items=() # lib/run_pool reads this ambient array of "<file>\t<cmd>" tuples; _append_work_tuples fills it
for script in ${_quibble_heavy_scripts[@]+"${_quibble_heavy_scripts[@]}"}; do
case " ${others[*]} " in *" $script "*) ;; *) continue ;; esac # only front-load names that are actual middle scripts
_append_work_tuples "$work_dir/$script.work"
done
for script in ${others[@]+"${others[@]}"}; do
case " ${_quibble_heavy_scripts[*]} " in *" $script "*) continue ;; esac # already emitted in the heavy pass above
_append_work_tuples "$work_dir/$script.work"
done
if [ "${#items[@]}" -gt 0 ]; then
. "$(dirname "$0")"/lib/run_pool # dispatch items across $parallel slots via _run_pool_worker
fi
# Sorted slowest-first summary of the pool items (TIME_ELAPSED=1 only): the per-item completion
# lines above are interleaved across slots, so this ranks them to make the long poles obvious.
if [ -n "${TIME_ELAPSED:-}" ] && [ -s "$_pool_timing" ]; then
printf "\nSlowest items:\n" >&2
sort -rn "$_pool_timing" | while IFS=$'\t' read -r _secs _cmd; do
printf " %s\t%s\n" "$(_quibble_format_duration "$_secs")" "$_cmd" >&2
done
fi
# Late phase: serial in main process. Destructive scripts (remove_srcs/remove/remove_all)
# must run only after all middle work is done, otherwise they'd wipe state mid-flight.
for script in "${late[@]}"; do
_execute_work_file "$work_dir/$script.work"
done
# Failed-items recap (mirrors the "Slowest items" block above): the live FAILED lines are
# interleaved with hours of pool progress, so re-list every dead worker's item here. Note a
# full run's late phase just ran remove_all, which deletes log/ — the "see <log>" paths then
# dangle, and the commands themselves (each one is its own regeneration recipe) are the
# durable record.
if [ "$failed" -gt 0 ]; then
printf "\nFailed items:%s\n" "$failed_items" >&2
fi
# Clean up work files on success. On error before this line, the temp dir leaks to /tmp
# (the OS cleans it up eventually); leaving it on error is deliberate and aids postmortem —
# so keep it on pool failures too: it holds the exact work plan the failed run was executing.
if [ "$failed" -eq 0 ]; then
rm -rf "$work_dir"
fi
fi
# Quick-mode pointer, printed just before the summary: name what was deliberately left out
# and the command that regenerates it. Gated on the count, so a full run prints no pointer
# at all.
if [ "$slow" -gt 0 ]; then
echo "$label: skipped $slow slow Usage $(pluralize "$slow" line) (lib/slow_scripts) — ./generate_examples_slow regenerates every example" >&2
fi
# Labeled summary on stderr so it survives the per-file output flood. $SECONDS is bash's
# built-in elapsed-seconds counter (set by the shell, not us — starts at process launch).
# duration_trap's `(Xs)` line still prints last on EXIT when TIME_ELAPSED=1.
# Drop the trailing ", took ..." entirely when TIME_ELAPSED is unset — otherwise the
# format string `took %s` would emit "took \n" with a trailing space and no duration.
# The slow and failed fields likewise appear only when non-zero, so a full run's all-good
# summary keeps the field layout it had before the split and failure tracking existed
# (only the leading label differs, naming the invoked variant).
slow_part="" # summary fragment: stays empty in a full run (no line is skipped as slow)
if [ "$slow" -gt 0 ]; then
slow_part=", slow $slow" # quick run: how many slow Usage lines were skipped
fi
failed_part="" # summary fragment: stays empty in the common all-good case
if [ "$failed" -gt 0 ]; then
failed_part=", failed $failed" # surfaced only when a pool worker actually died
fi
if [ -n "${TIME_ELAPSED:-}" ]; then
printf "%s: generated %d, skipped %d%s%s, took %s\n" \
"$label" "$generated" "$skipped" "$slow_part" "$failed_part" "$(_quibble_format_duration "$SECONDS")" >&2
else
printf "%s: generated %d, skipped %d%s%s\n" \
"$label" "$generated" "$skipped" "$slow_part" "$failed_part" >&2
fi
# A dead pool worker must fail the whole run: the pool deliberately kept going (so the other
# items still regenerated), which makes this final exit — after the recap and the summary —
# the right place for the non-zero status. lib/exit_trap's dispatcher preserves the code
# through the EXIT handlers (duration_trap, inhibit_sleep).
if [ "$failed" -gt 0 ]; then
exit 1
fi