Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .ci/check-cppcheck.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Static analysis over the C examples.
#
# --enable=warning adds to the error-severity checks rather than selecting
# them: those always run. So this does not exempt the use-after-free the
# manuscript keeps on purpose, and no suppression is needed for it either.
# cppcheck does not see it, because the free and the stale read are in
# different threads rather than along one path through a function. Style and
# portability sweeps stay off, since those would report the deliberate
# defects the book is built around.
set -e -u -o pipefail

mapfile -t SOURCES < <(git ls-files -z -- 'examples/*.c' | tr '\0' '\n')

if [ ${#SOURCES[@]} -eq 0 ]; then
echo "No tracked C source files found."
exit 0
fi

timeout 120 cppcheck \
--platform=unix64 \
--enable=warning \
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
--max-configs=1 --error-exitcode=1 --inline-suppr \
--suppress=checkersReport --suppress=unmatchedSuppression \
--suppress=missingIncludeSystem --suppress=noValidConfiguration \
--suppress=normalCheckLevelMaxBranches \
--suppress=preprocessorErrorDirective \
-D_GNU_SOURCE -D__linux__ \
"${SOURCES[@]}"
39 changes: 39 additions & 0 deletions .ci/check-format.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Verify clang-format conformance for the C examples the book embeds.
#
# The version is pinned because clang-format releases disagree about this
# style, and the examples are printed verbatim in the PDF: a reformat that
# only some contributors reproduce would churn the typeset listings.
set -e -u -o pipefail

if [ -z "${CLANG_FORMAT:-}" ]; then
if command -v clang-format-20 >/dev/null 2>&1; then
CLANG_FORMAT="clang-format-20"
else
echo "Error: clang-format-20 is required (other versions differ in style)" >&2
exit 1
fi
fi

# One scratch file removed on any exit, rather than one per iteration that a
# mid-loop failure would leave behind.
expected=$(mktemp)
trap 'rm -f "$expected"' EXIT

ret=0
while IFS= read -r -d '' file; do
# Keep clang-format's own diagnostics: a parse or config error otherwise
# aborts the run with a bare exit code and nothing to act on.
if ! err=$("$CLANG_FORMAT" --style=file "$file" 2>&1 >"$expected"); then
echo "Error: clang-format failed on $file" >&2
[ -n "$err" ] && printf '%s\n' "$err" >&2
ret=1
continue
fi
[ -n "$err" ] && printf '%s\n' "$err" >&2
if ! diff -u -p --label="$file" --label="expected coding style" "$file" "$expected"; then
ret=1
fi
done < <(git ls-files -z -- 'examples/*.c')

exit $ret
23 changes: 23 additions & 0 deletions .ci/check-newline.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Every tracked C example must end with a newline.
#
# minted reproduces the file as-is, so a missing final newline shows up in the
# typeset listing as well as annoying every diff that touches the last line.
set -e -u -o pipefail

ret=0
while IFS= read -r -d '' f; do
if [ ! -s "$f" ]; then
# tail -c1 of an empty file is empty, which would otherwise read as a
# correctly terminated file.
echo "Warning: empty file $f"
ret=1
elif file --mime-encoding "$f" | grep -qv binary; then
if [ -n "$(tail -c1 <"$f")" ]; then
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
echo "Warning: No newline at end of file $f"
ret=1
fi
fi
done < <(git ls-files -z -- 'examples/*.c')

exit $ret
109 changes: 109 additions & 0 deletions .ci/check-security.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Reject unsafe C idioms in the examples the book prints.
#
# Readers copy this code. The concurrency defects the manuscript keeps on
# purpose are discussed in the text; an unbounded string function smuggled in
# by a later edit would not be, so catch that class here.
set -u -o pipefail

failed=0

banned='(^|[^[:alnum:]_])(gets|sprintf|vsprintf|strcpy|stpcpy|strcat|atoi|atol|atoll|atof|mktemp|tmpnam|tempnam)[[:space:]]*\('
secrets='(password|secret|api_key|private_key|token)[[:space:]]*=[[:space:]]*"[^"]+'
# undef of either guard disables hardening whatever the value; only the define
# side needs to pin _FORTIFY_SOURCE to 0 to be worth reporting.
dangerous_pp='#[[:space:]]*(undef[[:space:]]+(_FORTIFY_SOURCE|__SSP__)|define[[:space:]]+(_FORTIFY_SOURCE[[:space:]]+0|__SSP__))'

# Blank out comments while keeping one output line per input line, so prose
# that names a banned function cannot fail the build and the line numbers
# reported below still refer to the real file.
#
# Two details are what make this safe rather than merely convenient. A comment
# becomes a space, as it does in translation phase 3, because deleting it would
# splice the tokens on either side and "_FORTIFY_SOURCE/**/0" would stop
# looking like a definition of it. And quoted literals are copied out
# untouched, honouring backslash escapes, because a string holding "/*" would
# otherwise put the scanner in a comment and swallow the code after it.
# Literals have to survive anyway: the secret pattern below matches on them.
strip_comments() {
awk '
BEGIN { in_block = 0; buf = ""; held = 0 }
{
# Translation phase 2 first: a backslash-newline is deleted before
# anything is tokenized, whatever precedes it. Doing this ahead of the
# scan is what keeps a spliced string, a spliced // comment, and a
# backslash run at end of line from being read differently here than
# by the compiler. Blank lines stand in for the lines folded away, so
# reported line numbers keep pointing into the file.
buf = buf $0
if (substr($0, length($0), 1) == "\\") {
sub(/\\$/, "", buf)
held++
next
}
emit()
}
END { if (buf != "" || held > 0) emit() }

function emit( k) {
print scan(buf)
for (k = 0; k < held; k++) print ""
buf = ""; held = 0
}

# in_block is deliberately global: a block comment spans logical lines.
# A literal cannot, once phase 2 has run, so quote state is local.
function scan(s, out, i, n, ch, nx, in_str, in_chr) {
out = ""; i = 1; n = length(s); in_str = 0; in_chr = 0
while (i <= n) {
ch = substr(s, i, 1); nx = substr(s, i + 1, 1)
if (in_block) {
if (ch == "*" && nx == "/") { in_block = 0; i += 2 } else { i++ }
continue
}
if (in_str || in_chr) {
out = out ch
if (ch == "\\") { out = out nx; i += 2; continue }
if (in_str && ch == "\"") in_str = 0
if (in_chr && ch == "'"'"'") in_chr = 0
i++
continue
}
if (ch == "\"") { in_str = 1; out = out ch; i++; continue }
if (ch == "'"'"'") { in_chr = 1; out = out ch; i++; continue }
if (ch == "/" && nx == "*") { in_block = 1; out = out " "; i += 2; continue }
if (ch == "/" && nx == "/") break
out = out ch; i++
}
return out
}' "$1"
}

# Here-strings rather than "echo ... | grep -q": grep exits on first match, the
# writer takes SIGPIPE, and under pipefail the condition then reads as false,
# which would silently pass a file that does contain a banned call.
while IFS= read -r -d '' f; do
code=$(strip_comments "$f")

if grep -qE "$banned" <<<"$code"; then
echo "Banned function in $f:"
grep -nE "$banned" <<<"$code"
failed=1
fi
if grep -iqE "$secrets" <<<"$code"; then
echo "Possible hardcoded secret in $f:"
grep -inE "$secrets" <<<"$code"
failed=1
fi
if grep -qE "$dangerous_pp" <<<"$code"; then
echo "Dangerous preprocessor directive in $f:"
grep -nE "$dangerous_pp" <<<"$code"
failed=1
fi
done < <(git ls-files -z -- 'examples/*.c')

if [ $failed -eq 0 ]; then
echo "Security checks passed."
fi

exit $failed
108 changes: 82 additions & 26 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,64 @@ on:
permissions:
contents: read

# Cancel superseded runs on the same ref to save minutes.
# Cancel superseded PR runs to save minutes, but never interrupt a push to
# main: that run publishes the release and moves the "latest" tag.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

defaults:
run:
shell: bash

jobs:
format:
name: Check C formatting (clang-format)
runs-on: ubuntu-latest
timeout-minutes: 5
examples:
name: Check C examples
# Pinned rather than ubuntu-latest so the toolchain this job installs is
# reproducible; each check lives in .ci/ so it can be run locally too.
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Cache apt packages
uses: actions/cache@v6
with:
path: ~/apt-cache
key: apt-lint-${{ runner.os }}-${{ hashFiles('.github/workflows/main.yml') }}

- name: Install clang-format
- name: Install tools
# clang-format-20 specifically: the accepted style is whatever that
# release produces, and the image's default clang-format is a
# different major version.
run: |
mkdir -p ~/apt-cache
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends clang-format
sudo apt-get install -y -o Dir::Cache::Archives="$HOME/apt-cache" \
clang-format-20 cppcheck
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

- name: Format examples/*.c with examples/.clang-format
run: make -C examples format
- name: Check trailing newline
run: .ci/check-newline.sh

- name: Fail if formatting produced changes
- name: Check clang-format
run: |
if ! git diff --exit-code -- examples/; then
.ci/check-format.sh || {
echo "::error::examples/*.c is not formatted; run 'make -C examples format' and commit the result."
exit 1
fi
}

- name: Security checks
run: .ci/check-security.sh

- name: Static analysis (cppcheck)
run: .ci/check-cppcheck.sh

- name: Build and run the examples
# The book embeds these files verbatim, so a program that no longer
# compiles, no longer terminates, or no longer prints what the
# manuscript quotes ships straight into the PDF.
run: make -C examples check

build:
name: Build PDF
Expand All @@ -54,7 +80,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Mark workspace safe for git
# The texlive container runs as root; git refuses to operate on
Expand All @@ -68,16 +94,36 @@ jobs:
apt-get update -qq
apt-get install -y --no-install-recommends wget ca-certificates

- name: Cache TeX intermediates
uses: actions/cache@v5
- name: Cache TeX intermediates and minted highlights
uses: actions/cache@v6
with:
path: |
concurrency-primer.aux
concurrency-primer.fdb_latexmk
concurrency-primer.fls
concurrency-primer.out
concurrency-primer.toc
key: tex-${{ runner.os }}-${{ hashFiles('.github/workflows/main.yml', 'concurrency-primer.tex', 'Makefile', 'lib/**/*.tex', 'examples/**/*') }}
_minted
# The sources hash changes on nearly every push, so the prefix
# fallback below does the real work. A stale aux/toc costs latexmk
# one extra pass; _minted entries are content-addressed, so unmatched
# ones are ignored rather than reused wrongly.
#
# lstlangarm.sty stays out of the cache. The Makefile fetches it from
# a floating master URL, so a cached copy would pin the build to
# whatever revision landed in the cache first.
# The workflow file is in the hash because the texlive digest lives
# there: a new image means a new pygments, and minted keys its
# entries by listing content alone, so stale ones would still match
# and the PDF would mix two highlighting styles.
# The workflow hash is its own key segment, not just folded into the
# whole, so the fallback below can be scoped to it. A bare
# "tex-${{ runner.os }}-" prefix would happily restore _minted built
# by a different pygments after a digest bump, which is the mixed
# highlighting the exact key exists to prevent.
key: tex-${{ runner.os }}-${{ hashFiles('.github/workflows/main.yml') }}-${{ hashFiles('concurrency-primer.tex', 'Makefile', 'lib/**/*.tex', 'examples/**/*.c') }}
restore-keys: |
tex-${{ runner.os }}-${{ hashFiles('.github/workflows/main.yml') }}-

- name: Build PDF
run: make 2>&1 | tee build.log
Expand Down Expand Up @@ -108,19 +154,29 @@ jobs:

release:
name: Publish latest release
needs: build
# Release only on direct push to main. PRs run the build job above for
# validation but never reach this job, so no "main" tag is ever created.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Both, not just build: latexmk typesets the examples from source and
# succeeds whether or not they compile, so only the examples job can stop
# a broken program from shipping inside the PDF.
needs: [build, examples]
# Release on a direct push to main, or on a manual run against main. PRs
# run the build job above for validation but never reach this job, so no
# "main" tag is ever created.
#
# workflow_dispatch is the recovery path: when a push's run is lost, and
# a queue backlog or a cancelled run is enough to lose one, there is
# otherwise no way to publish that commit short of pushing another.
if: >-
(github.event_name == 'push' || github.event_name == 'workflow_dispatch')
&& github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
# A shallow clone suffices; force-moving the tag to HEAD walks no
# history, and the remote already has that commit.
uses: actions/checkout@v7

- name: Download PDF artifact
uses: actions/download-artifact@v8
Expand All @@ -141,7 +197,7 @@ jobs:
Source commit: ${{ github.sha }}

- name: Move latest tag to current commit
# On the first publish, action-gh-release can create `latest` at the
# On the first publish, action-gh-release can create "latest" at the
# current commit via target_commitish. On later publishes, the Releases
# API updates assets/body but leaves an existing tag in place, so move
# the ref only after the upload succeeds.
Expand Down
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,15 @@ concurrency-primer.pdf
*.toc
*.fls
*.fdb_latexmk
*.xdv
draftmark.tex
lstlangarm.sty
.DS_Store

# built by examples/Makefile
examples/rmw_example
examples/rmw_example_aba
examples/simple_aba_example

# build-flag stamp from examples/Makefile
examples/.toolchain*
Loading
Loading