Skip to content

Refine CI and build to compile/run C examples - #28

Merged
jserv merged 17 commits into
mainfrom
ci-refine
Aug 7, 2026
Merged

jserv merged 17 commits into
mainfrom
ci-refine

Conversation

@jserv

@jserv jserv commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary by cubic

Refined CI and build to compile and run the C examples, rebuild the PDF when examples change, and block releases on failing code. Improved CI diagnostics and tightened security scanning.

  • New Features

    • Added examples CI job on ubuntu-24.04 running .ci/check-newline.sh, .ci/check-format.sh (pinned clang-format-20), .ci/check-security.sh, .ci/check-cppcheck.sh, then make -C examples check; release now depends on build and examples; PR runs can cancel, pushes to main never do; workflow_dispatch can republish.
    • Forced PDF rebuilds when embedded examples change by deriving sources from \inputminted and running latexmk -g; cached TeX and _minted keyed on the pinned container with restore keys; cleaned/ignored generated artifacts.
    • Reworked examples/Makefile: per-target rules with override CFLAGS; detect x86-64 via compiler macros to pass -mcx16; probe -latomic; keep caller CPPFLAGS/LDFLAGS; rebuild when the toolchain/flags change via a signature stamp that includes the compiler version and safely escapes quotes; check asserts exact stdout; format uses CLANG_FORMAT; README documents C11 threads and -mcx16/libatomic.
  • Bug Fixes

    • Queue correctness: never claim the idle sentinel (re-check on each CAS iteration); aligned and aligned-allocated the idle job (and 16-byte v_prev); dropped the stale front link when the queue drains; in the versioned variant, claimed with a guarded loop and published pointer+version in one 16-byte store.
    • Lifecycle/exit status: checked thrd_create and unwound cleanly; freed unclaimed futures (and results) on cancel; waited for workers to publish idle before tpool_destroy; failed runs on init/add_job/result allocation errors and reported via exit status.
    • CI checks: fixed false negatives in .ci/check-security.sh (perform translation phase 2 first, preserve line numbers, replace comments with spaces, no SIGPIPE; detect #undef _FORTIFY_SOURCE/__SSP__); .ci/check-format.sh, .ci/check-newline.sh, .ci/check-cppcheck.sh print actionable errors and clean temp files.
    • Docs/listings: made the ABA demo deterministic with a flag handshake and joins; updated the worker listing with the idle-sentinel guard and correct acquire/release orders (state load and job-pointer load are acquires; CAS failure is an acquire); minor cleanups.

Written for commit 9a714cf. Summary will update on new commits.

Review in cubic

@jserv jserv changed the title Ci refine Refine CI and build to compile/run C examples Aug 6, 2026
cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added a commit that referenced this pull request Aug 6, 2026
Sleeping did not guarantee that B completed both read-modify-writes before
A's compare-exchange, and the exit status added earlier in this series
turned that scheduling variance into a nonzero exit, so an oversubscribed
CI runner could fail the job for no reason.

Let B signal completion and have A wait on it. The interleaving is now
pinned by the two flags rather than by a timeout, the compare-exchange
cannot lose its race, and the four lines the manuscript quotes come out on
every run. Dropping the one-second sleep also takes 300 runs from five
minutes to under two seconds.

Reported by cubic-dev-ai on #28.
jserv added a commit that referenced this pull request Aug 6, 2026
`uname -m` reports the machine running make, so a cross build from x86-64 to
anything else still forced -mcx16 onto the target command line, and the
libatomic probe answered for the host toolchain rather than the one actually
in use. Ask the compiler for __x86_64__ instead, and run the probe with the
same flags the ABA build uses.

Reported by cubic-dev-ai on #28.
jserv added a commit that referenced this pull request Aug 6, 2026
A glob drops a deleted file from the prerequisites, so removing an example
the document still inputs would leave an existing PDF looking up to date and
make would never run latexmk to report the broken \inputminted. Name the
three files the manuscript embeds.

Reported by cubic-dev-ai on #28.
jserv added a commit that referenced this pull request Aug 6, 2026
The comment said clearing `initialized` invites the caller to destroy the
pool, but the same branch sets head to NULL and tpool_destroy dereferences
head->prev unconditionally, so taking that invitation would crash. init
already undoes everything it did; say that instead.

Reported by cubic-dev-ai on #28.
jserv added a commit that referenced this pull request Aug 6, 2026
Rewriting this listing earlier in the series turned its job-pointer load
into a relaxed one; it had been a sequentially consistent atomic_load. The
loop follows what it loads, reading job->prev to compute the next candidate
and dereferencing the job once the claim succeeds, and those fields reach
the worker only through the plain stores the employer made before publishing
the pointer. A release on the successful compare-exchange orders the
claiming thread's later writes, not its earlier reads, so nothing
synchronized with the publisher and the listing described a data race.

Make the load and the failure order acquires, and say why this loop is not
the case where every load can be relaxed.

Reported by cubic-dev-ai on #28.
cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added a commit that referenced this pull request Aug 6, 2026
The previous wording said the fields the loop follows were published by the
thread that added the job. That holds only for a queue of one. add_job
relinks jobs that are already queued, writing head->job.next->prev with an
ordinary store, while head->prev is published atomically only when the queue
was empty. So a worker can acquire head->prev and then read a job->prev that
a later insertion wrote with nothing to synchronize against.

The acquire is still necessary, since a release on the claiming CAS orders
that thread's later writes rather than its earlier reads. It is just not
sufficient, and the example continues to rely on jobs being added only while
the workers are idle. Say that rather than implying the ordering is complete.

Reported by cubic-dev-ai on #28.
cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

@sysprog21 sysprog21 deleted a comment from cubic-dev-ai Bot Aug 7, 2026
cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added 11 commits August 7, 2026 11:44
A failed compare_exchange writes the current value back into the expected
operand, so the reloaded pointer can be the idle sentinel once another worker
drains the queue. That sentinel is allocated with plain malloc and its func
and future fields are never initialized, so claiming it dereferences
indeterminate pointers. Under a sanitizer the previous code aborts in roughly
2 runs out of 30 with a heap-use-after-free.

Rule the sentinel out on every iteration rather than once before the loop,
and update the two listings in the manuscript that quote it. The versioned
variant carries the same defect and is repaired separately, because there it
cannot be separated from how the queue head is published.
Two defects that cannot be separated in this file.

The worker had the same sentinel bug as its unversioned twin: a failed
compare-exchange reloads the expected operand, so the reloaded pointer can be
the idle job, whose func and future are never initialized.

And add_job wrote the pointer and its version as separate stores, even though
the union exists so the pair can be swung with one double-width
compare-and-swap. A worker compare-exchanging between the two observes the new
job against the stale version, which is the state the version number exists to
make unrepresentable, and the increment was itself a plain read-modify-write on
a location workers update concurrently.

Fixing either alone makes the example worse rather than better, which is why
they land together: the new claim loop against the old split publication hangs
in roughly 2 runs out of 40, and the old claim loop against the new atomic
publication fails outright. Together they are clean over the same sample. The
store remains unsynchronized, relying on the employer only adding jobs while
the pool is idle, so the comment says that rather than claiming nothing races
it.
tpool_destroy reports on a pool it cancels while it is still running, and
main reached it as soon as the last future completed, which does not mean the
last worker has published idle. The window is narrow enough that the pool
examples do not print the diagnostic today, but it widens as soon as the
claim loop is touched, and later commits gate CI on this output matching what
the manuscript quotes.

wait_until is already there for the same purpose earlier in main.
Workers pop from the back and free as they go, but nothing ever updates
head->job.next, so once the queue has drained it still names the last job
freed and the next add_job writes through that pointer. It cannot fire while
main adds a single batch, but the ABA scenario in the manuscript narrates
precisely a second batch, so the text describes a flow the code cannot
survive.

A two-batch driver under a sanitizer aborts in 20 runs out of 20 inside
add_job before this change and none after; what remains is the documented
use-after-free in worker, which the book keeps deliberately.
The struct carries a cache line of padding and a comment saying it avoids
false sharing, but it was allocated with plain malloc, so it landed on a
16-byte boundary and the hot pointer shared a line with whatever preceded it.
Padding sizes a struct; only alignment places it.

In the versioned variant the same alignment is what the double-width
compare-and-swap needs. Declaring it makes sizeof a multiple of the
alignment, which is what aligned_alloc requires of its size argument.
thrd_create was unchecked, so a failure left an indeterminate thread
identifier that tpool_destroy would later join. The allocation failure paths
leaked the identifier array and, more awkwardly, returned false with the
initialized flag still set, which wedged the pool object against any retry.

Check every create, join the workers already hired, release the flag, and
leave no dangling pointer behind. Nothing is left for tpool_destroy to
reclaim on this path, so the comment says the caller must not call it rather
than inviting them to.
Cancelling a pool with jobs still queued freed each job but neither the
future it owns nor that future's result. Workers are all joined by this
point, so the queue is single-threaded here and can be drained safely.

The cancellation warning also fired for any non-zero prior state rather than
for a running pool specifically, which is what the manuscript says it
reports.
add_job returns NULL when it cannot allocate, and bbp returns NULL when it
cannot allocate its result; both were then dereferenced unchecked. Guarding
them without also failing would be worse than the crash, because the program
prints a value the manuscript quotes exactly, and a run missing a term would
otherwise report success while contradicting the book. A failed init returns
non-zero too, having previously returned 0 from a run that did no work.

Bailing out mid-loop has to drain the queue first: tpool_destroy frees the
futures of jobs still queued, but a worker that has already claimed one frees
the job and not its future. Wait for the workers to park before destroying
either way, since tpool_destroy reports on a pool it cancels while running
and a completed future does not mean the last worker has published idle.
threadA looped while its compare-exchange kept succeeding, so it ran forever
and the program only terminated because main slept two seconds and returned
out from under it. Whether the fourth line was printed before the process
died was a race, and nothing ordered A's load before B's updates, so B could
finish first and leave no ABA to observe at all.

Two flags pin the interleaving instead: B waits until A has read v, and A
waits until B has changed it and changed it back. The four lines the
manuscript quotes now come out on every run, and dropping the sleep takes 300
runs from five minutes to under two seconds. A compare-exchange that fails
means the run demonstrates nothing, so it reports that and exits non-zero
rather than printing a shorter story. Both creates are checked, since each
thread spins on a flag only the other one sets.
The listing is introduced as the worker from the thread pool section, but it
referenced a job->args field that does not exist, detected idleness by
testing that field against NULL rather than comparing against the sentinel,
and called free on it: in the real program that argument points into an array
on main's stack. It was also C code in a C++ listing environment.

The ordering it showed was wrong in three ways as well. The load of the job
pointer has to be an acquire, because the loop follows what it reads and a
release on the successful compare-exchange orders the claiming thread's
earlier work rather than the reads that follow it. The reload a failed
compare-exchange performs needs the same. And the test for running announces
a queue the employer has just filled, unlike the test for cancelled, so that
load cannot be relaxed either: add_job publishes the head pointer atomically
only for the first job of an empty queue, which leaves the store of running
as the only edge carrying the rest.
A single all target relinked all three programs on every invocation and
hardcoded -mcx16, which does not exist outside x86-64 and fails the build
outright elsewhere. Ask the compiler for its target rather than asking uname
about the host, so a cross build is judged on what it is producing, and say
so when the compiler cannot answer instead of quietly dropping the flag.

-mcx16 does not make libatomic unnecessary either: some versions still route
16-byte atomic load and store through it, which is the link failure the
manuscript footnotes. Probing beats guessing, and the probe carries the flags
of the link it is deciding for.

check now compares stdout against the text the manuscript quotes, since
asserting only the exit status lets the book and its own programs drift apart
while CI stays green. Flags are set with override, and the binaries depend on
a stamp naming the toolchain, so caller flags compose and changing any of
them rebuilds rather than leaving check to assert on stale binaries.
jserv added 5 commits August 7, 2026 11:47
The examples reach the document through inputminted, which hands them to
pygmentize rather than opening them as TeX inputs, so latexmk never records
them as dependencies and examples/ appears nowhere in its database. Editing
one left the PDF untouched and the book shipped a stale copy of its own code.

Naming them as prerequisites is not enough on its own, because latexmk then
consults that database and declares the PDF up to date, so force one pass
when make has decided something changed. The list is read out of the document
rather than globbed or spelled out here, since the inputminted directives are
the only record of what the book actually embeds.
clean removed the minted cache under its minted v2 name while the current
toolchain writes elsewhere, and left the LuaLaTeX intermediate behind, so
roughly a megabyte survived it. The example binaries were neither cleaned nor
ignored.
The programs need a libc with C11 threads, which rules out macOS, and the
versioned example needs a double-width compare-and-swap. Neither was written
down. Also note that the first build fetches a style file over the network.
checkout and cache were a major version behind. The TeX cache had no
restore-keys, so its key changed on nearly every push and it never once
restored; a prefix fallback is what makes it do anything. Add the minted
output to it, which is the expensive part of the build, one pygmentize
process per snippet.

Both the key and that fallback carry the workflow hash, because the container
digest lives there: minted names its entries by listing content alone, so
after an image bump a bare prefix would serve every untouched listing from
the previous pygments and the PDF would mix two highlighting styles. The
style file stays out of the cache entirely, since the Makefile fetches it
from a floating URL and a cached copy would pin the build to whichever
revision landed first.

Superseded pull request runs still cancel, but a push to main no longer does,
since that run publishes the release and moves the tag.
One script per check, each runnable by hand rather than only through CI, and
driven by the file list git already has. Adds a trailing-newline check, a
sweep for banned functions and hardcoded secrets, and cppcheck alongside the
formatting gate.

clang-format is pinned to one release. These files are printed verbatim in
the book, so the accepted style has to be a single version rather than
whichever major a machine happens to carry, and the script says so instead of
silently reformatting to a different one.

The security sweep folds spliced lines before scanning and replaces comments
with a space, matching translation phases 2 and 3, so that a banned name
written in prose cannot fail the build and a definition split by a comment
cannot hide from it. Quoted literals are copied out untouched, since a string
holding a comment opener would otherwise swallow the code after it and the
secret pattern matches on literals anyway.

cppcheck runs with warnings enabled on top of the error checks that always
run. That does not exempt the use-after-free the manuscript keeps on purpose;
cppcheck simply does not see it, because the free and the stale read are in
different threads rather than along one path through a function.
The job only checked formatting, so an example that no longer compiled,
no longer terminated, or no longer printed what the manuscript quotes
would still be typeset into the PDF and published. Run the checks, and
make the release depend on that job: latexmk typesets the examples from
source and succeeds either way, so nothing else can catch it.

The toolchain is installed rather than taken from the runner image, which
means the image no longer decides the accepted style, and the runner is
pinned so what does decide it is reproducible.

The release also accepts a manual run against main. Runs get lost to
queue backlogs and cancellations, and without this the only way to
publish that commit is to push another one. The ref check is what keeps
it honest: dispatching on any other branch still skips the job, exactly
as a pull request does.
@jserv
jserv merged commit 40274a5 into main Aug 7, 2026
4 checks passed
@jserv
jserv deleted the ci-refine branch August 7, 2026 03:57
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.

1 participant