Skip to content

ci: scope the runtime driver, probe the Test::Nginx harness - #29

Merged
eilandert merged 1 commit into
mainfrom
ci/runtime-driver-scope-and-harness-probe
Aug 7, 2026
Merged

eilandert merged 1 commit into
mainfrom
ci/runtime-driver-scope-and-harness-probe

Conversation

@eilandert

Copy link
Copy Markdown
Member

Found while moving runtime cases into the Perl suite on request. The move turned out to be mostly wrong — the interesting part is why — so this PR keeps the cases where they are and fixes what the attempt exposed instead.

TL;DR

A test that reloads the server was sleeping for five seconds on every push to check something it could watch for directly, and it never confirmed the reload had actually happened while requests were being served. It now waits for the event: same assertion, five seconds down to a tenth of one, and it catches a failure the old version could sleep straight through.

Separately, the adoption prompt told adopters to run the test suite and record the result about thirty steps before anything guaranteed the test framework was installed — and the test runner reports success when it finds no test files at all, so a "green" baseline could mean nothing ran.

What changed

ci/tools/test_runtime.py

test_reload_under_load waited 1.0s, sent SIGHUP, waited 1.0s, sent another, and bounded the hammer threads with a 5s deadline. Two problems: the 5s is paid on every push, and nothing checked a reload had completed while requests were in flight. On a loaded build host a SIGHUP still queued when the hammer stopped left the case asserting only "the server answered 403" — which test_marker_is_blocked already proves.

It now counts the master's start worker process lines and proceeds once the expected number appear, failing explicitly if they do not arrive within 10s. The reload becomes a precondition of the assertion instead of a hope.

Status collection moved to a per-thread list merged under a lock. list.append from 8 threads is atomic under the GIL today, but ci-deep.yml also runs the free-threaded build, where it is not.

ci/PROMPT.md

  • Step 3 probes the .t count and location, and whether Test::Nginx imports at all. Step 5 records all three. Step 6 blocks the baseline on them and requires the test count to match.
  • Step 25 gains the boundary between the driver and ci/t/, plus an audit of what adopters added to the driver — with the move proved by a red in the new location, not a green.
  • Notes that Test::Nginx is the openresty suite (AGENT/Test-Nginx-*.tar.gz, AGENT being agentzh). There is no separately-named package, and nothing in the repo said so.
  • Fixes a wrong cross-reference: the installer is ported at step 32, not 36.

ci/linter/install-linters.sh — the same openresty/Test::Nginx note beside the cpan list.

Why the seam cases did not move

--- raw_request with an arrayref looks like it expresses "N requests, one connection each". It does not: Test::Nginx::Socket::get_req_from_block (Socket.pm:337-347) turns an arrayref into one request split into packets. A port of the every-interior-byte split therefore sent one giant request, matched the first status line, and reported 2 assertions for what should have been 18 and 23 splits.

It passed against the keep = 0 seam mutation that the Python driver catches. Written up in the driver docstring and step 25 so the next person does not spend the afternoon rediscovering it.

--- pipelined_requests is not the fallback either — one shared connection, so no case in the batch can carry Connection: close.

Testing

  • Perl suite: 55 tests across 5 files, Result: PASS.
  • Runtime driver: 8 cases, 0 failures. test_reload_under_load 5.01s -> 0.12s; total ~6s -> ~1.1s.
  • Negative control for the rewritten case: injected a reload that swaps the location to skel off (via a Server.reload override — no one-line source edit produces this defect). Caught: 165/349 requests stopped being blocked across a reload. The faster case is not a weaker one.
  • NOTESTS claim verified, not assumed: prove on an empty directory prints Result: NOTESTS and exits 0.
  • ci/linter/run-all.sh: == all linters clean ==. Branch roster pre-pass: the shellcheck and markdownlint findings reproduce on main without this branch; the two MD013 hits this diff introduced are fixed.

test_reload_under_load still has no cheap source mutation — the defect it hunts is a conf-struct lifetime bug across SIGHUP. The control above proves it detects a lost verdict, not that specific bug. Stated in the docstring rather than glossed.

The runtime driver had accreted cases the Perl suite covers, and the
adoption prompt never checked the harness was installed before baselining
against it.

test_runtime.py:
- test_reload_under_load waits for the master's 'start worker process'
  lines instead of sleeping either side of each SIGHUP: 5.01s -> 0.12s,
  and it now asserts the reload actually completed while traffic was in
  flight. The sleeping form never did, so on a loaded host it could pass
  while asserting nothing beyond the baseline case.
- collect statuses per-thread under a lock; list.append across 8 threads
  is GIL-atomic today but not on the free-threaded build ci-deep.yml uses.
- record why the seam cases cannot move to ci/t/: an arrayref raw_request
  is ONE request split into packets, not N requests, so a port of the
  every-interior-byte split passes green against the keep=0 mutation.

PROMPT.md:
- step 3 probes the .t count/location and whether Test::Nginx is present;
  step 5 records all three; step 6 blocks the baseline on them and demands
  the test count match. prove on a path with no .t files exits 0 with
  Result: NOTESTS, and step 10 is what moves t/ -> ci/t/, so a baseline
  taken beforehand is a green that ran nothing.
- step 25 gains the driver-vs-ci/t boundary and an audit of what adopters
  added, with the move proved by a red in the new location.
- note that Test::Nginx IS the openresty suite (AGENT/Test-Nginx-*.tar.gz);
  there is no separately-named package to look for.
- fix a wrong cross-reference: the installer is ported at step 32, not 36.

install-linters.sh: same openresty/Test::Nginx note at the cpan list.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR expands test inventory and baseline validation requirements. It documents Test::Nginx installation and test-case boundaries. It also replaces fixed reload waits with nginx log-based worker-start synchronization during concurrent runtime tests.

Changes

Test validation

Layer / File(s) Summary
Baseline inventory and harness validation
ci/PROMPT.md, ci/linter/install-linters.sh
The inventory records .t test count, test location, and Test::Nginx::Socket availability. Baseline checks validate the harness, reject empty suites and NOTESTS, and compare executed tests with the inventory.
Driver case boundaries and acceptance
ci/PROMPT.md, ci/tools/test_runtime.py
The guidance defines driver coverage, ci/t/ placement, mutation checks, and observed reload events. Runtime documentation records reload coverage and retained seam tests.
Observed worker reload testing
ci/tools/test_runtime.py
test_reload_under_load uses threaded request batches, two SIGHUP cycles, nginx worker-start log events, locked result collection, and a 10-second timeout.

Sequence Diagram(s)

sequenceDiagram
  participant RuntimeTest
  participant RequestThreads
  participant Nginx
  RuntimeTest->>RequestThreads: Start concurrent requests
  RuntimeTest->>Nginx: Send SIGHUP
  Nginx-->>RuntimeTest: Report worker-start event
  RequestThreads-->>RuntimeTest: Return request statuses
  RuntimeTest->>Nginx: Send second SIGHUP
  Nginx-->>RuntimeTest: Report second worker-start event
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the runtime-driver changes and Test::Nginx harness checks.
Description check ✅ Passed The description directly explains the runtime test changes, harness validation, documentation updates, and verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/runtime-driver-scope-and-harness-probe
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch ci/runtime-driver-scope-and-harness-probe

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@eilandert
eilandert merged commit 52aceb2 into main Aug 7, 2026
13 of 14 checks passed
@eilandert
eilandert deleted the ci/runtime-driver-scope-and-harness-probe branch August 7, 2026 22:39

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 96ad0551-316d-4a45-af74-59f4060ea219

📥 Commits

Reviewing files that changed from the base of the PR and between 76138c8 and 133584d.

📒 Files selected for processing (3)
  • ci/PROMPT.md
  • ci/linter/install-linters.sh
  • ci/tools/test_runtime.py

Comment thread ci/PROMPT.md
ls src/*_scan.c src/*_scan.h *_scan.c 2>/dev/null # decision seam? (step 7)
grep -ln 'ngx_http_request_t' src/*_scan.c *_scan.c 2>/dev/null # expect no hits
grep -nE '!\[' README.md # badge row + order
ls ci/t/*.t t/*.t tests/*.t 2>/dev/null | wc -l # how many .t, and WHERE?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Record the .t location, not only the total.

Line 344 sends the matched paths to wc -l, so the probe discards the directory information. Lines 424-428 require both the count and the directory. The workflow at .github/workflows/build-test.yml Lines 311-329 runs prove ci/t/; an aggregate count can describe t/ or tests/ while the baseline runs a different suite. Emit a per-directory count or preserve the matched paths.

Suggested inventory probe
- ls ci/t/*.t t/*.t tests/*.t 2>/dev/null | wc -l
+ for dir in ci/t t tests; do
+   printf '%s: %s\n' "$dir" \
+     "$(find "$dir" -maxdepth 1 -type f -name '*.t' 2>/dev/null | wc -l)"
+ done
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ls ci/t/*.t t/*.t tests/*.t 2>/dev/null | wc -l # how many .t, and WHERE?
for dir in ci/t t tests; do
printf '%s: %s\n' "$dir" \
"$(find "$dir" -maxdepth 1 -type f -name '*.t' 2>/dev/null | wc -l)"
done

Comment thread ci/PROMPT.md
Comment on lines +467 to +469
COUNT beside the verdict, check it against step 5's count, and treat `NOTESTS` as
"could not run", never as a pass — the same rule step 25's `-k` guard and
`run-all.sh`'s `LINT_ONLY` apply. A baseline of zero tests recorded as "green"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate ci/PROMPT.md =="
fd -a 'PROMPT\.md$' . || true

echo "== relevant ci/PROMPT.md lines =="
if [ -f ci/PROMPT.md ]; then
  wc -l ci/PROMPT.md
  sed -n '440,510p' ci/PROMPT.md | nl -ba -v440
fi

echo "== search for count/test inventory/prove/Files/Tests =="
rg -n "step 5|\\.t count|Files=|Tests=|recorded as|green|RUN|run-all|LINT_ONLY|NOTESTS" ci/PROMPT.md ci -S || true

echo "== list ci files =="
git ls-files ci | sed -n '1,120p'

Repository: myguard-labs/nginx-skeleton-module

Length of output: 323


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== inspect ci run scripts for inventory/counting =="
for f in $(git ls-files ci | rg '^(run|.*\.sh)$'); do
  echo "--- $f ---"
  wc -l "$f"
  sed -n '1,260p' "$f" | nl -ba -v1
done

Repository: myguard-labs/nginx-skeleton-module

Length of output: 309


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant ci/PROMPT.md lines =="
sed -n '440,510p' ci/PROMPT.md | awk '{printf "%7d\t%s\n", NR+439, $0}'

echo "== step references in ci/PROMPT.md =="
python3 - <<'PY'
from pathlib import Path
p=Path('ci/PROMPT.md')
for i,line in enumerate(p.read_text(encoding='utf-8').splitlines(),1):
    if 'step 5' in line or 'step 25' in line or '.t count' in line or 'Tests=' in line or 'Files=' in line or 'NOTESTS' in line or 'LINT_ONLY' in line:
        print(f"{i:6d}: {line}")
PY

echo "== ci script inventory candidates =="
find ci -maxdepth 2 -type f \) \( -name '*.sh' -o -path 'ci/*' \) -print 2>/dev/null | sort | awk '$1 ~ /\.sh$|PROMPT\.md$|README|Docker|compose|Makefile|run-all|inventory|test' { printf "%s\n", $0 }' | head -n 200

Repository: myguard-labs/nginx-skeleton-module

Length of output: 6757


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all step 5 references =="
python3 - <<'PY'
from pathlib import Path
text = Path('ci/PROMPT.md').read_text(encoding='utf-8')
lines = text.splitlines()
for i, line in enumerate(lines, 1):
    if 'step 5' in line:
        print(f"{i}: {line}")
PY

echo "== ci file list =="
find ci -maxdepth 2 -type f 2>/dev/null | sort | sed -n '1,240p'

echo "== relevant run-all/linter files =="
for f in ci/run-all.sh ci/linter/run-all.sh; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    sed -n '1,220p' "$f" | awk '{printf "%6d\t%s\n", NR, $0}'
  fi
done

echo "== prove invocation/count-related wording in ci/PROMPT.md =="
python3 - <<'PY'
from pathlib import Path
lines = Path('ci/PROMPT.md').read_text(encoding='utf-8').splitlines()
for i, line in enumerate(lines, 1):
    if any(s in line for s in ['prove', 'NOTESTS', 'File', 'Test', '.t count', 'Tests=', 'Files=', 'zero tests', 'number of tests']):
        print(f"{i}: {line}")
PY

Repository: myguard-labs/nginx-skeleton-module

Length of output: 13537


🌐 Web query:

perforce-prove-output Files= Tests= Test::Nginx assertions per test file "Files=" "Tests="

💡 Result:

The output pattern "Files= Tests=" is a standard summary format produced by the Perl Test::Harness library [1], which is the core engine behind the prove utility [2][1]. When prove finishes running a suite of tests, it outputs a summary line typically in the format: All tests successful. Files=N, Tests=M,... In this format: - Files=N indicates the total number of test files that were executed [1]. - Tests=M indicates the total count of individual test assertions (or sub-tests) run across those files [1]. Regarding the specific terms mentioned: - Test::Nginx: This is a Perl-based testing scaffold used for Nginx C modules and OpenResty-based applications [3][4]. It is designed to work with the standard Perl prove utility [2][5]. Because it leverages the underlying Perl testing infrastructure, it inherits the reporting format produced by prove and Test::Harness [2][1]. - Perforce: The search results indicate that Perforce (p4d) is often used in infrastructure testing environments (such as Git's own internal test suite, which includes git-p4 tests) [6][7]. In those contexts, prove may be used as the test runner [6][8]. The output format itself is not specific to Perforce; it is a general characteristic of the Perl testing ecosystem [1]. If you see this output in your environment, it confirms that your test suite is being executed by the Perl prove harness, regardless of whether you are testing Nginx modules or Perforce-related components [2][1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== step 5 content =="
sed -n '600,650p' ci/PROMPT.md | awk '{printf "%6d\t%s\n", NR+599, $0}'

echo "== .t inventory candidates =="
find ci -maxdepth 2 -type f 2>/dev/null | grep '/t/' | sort | awk '{printf "%s\n", $0}'

echo "== inventory/count exact phrases =="
python3 - <<'PY'
from pathlib import Path
text = Path('ci/PROMPT.md').read_text(encoding='utf-8').splitlines()
for i, line in enumerate(text, 1):
    if any(s in line for s in ['inventory', '.t', 'Test::Nginx', 'count', 'list', 'ls']):
        if i >= 560 and i <= 635 or i >= 150 and i <= 170 or i >= 600 and i <= 625:
            print(f"{i}: {line}")
PY

Repository: myguard-labs/nginx-skeleton-module

Length of output: 3208


Compare the .t file count with prove Files= results.

The suite inventory records .t files, but this wording calls the baseline check “tests” and says the result must match step 5. Since prove emits Files= and Tests= separately, compare the Files= count to the .t count and keep the assertion count separate.

Comment thread ci/PROMPT.md
Comment on lines +1038 to +1040
- **Stays in the driver** — many requests *in flight at once* (concurrency), and
anything driving a signal at the master while traffic runs (reload under
load). Test::Nginx is one request at a time; neither shape survives the move.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/sh
set -eu
perl -MTest::Nginx::Socket -e 'print "$Test::Nginx::Socket::VERSION\n"'
perldoc -T Test::Nginx::Socket | grep -nE 'multiple requests|pipelined_requests|raw_request'

Repository: myguard-labs/nginx-skeleton-module

Length of output: 472


🏁 Script executed:

#!/bin/sh
set -eu
printf '--- ci/PROMPT.md context ---\n'
sed -n '1020,1050p' ci/PROMPT.md

printf '\n--- Test::Nginx references in repo ---\n'
rg -n "Test::Nginx|pipelined_requests|multiple requests|raw_request|concurrent" ci ci/PROMPT.md .github 2>/dev/null || true

Repository: myguard-labs/nginx-skeleton-module

Length of output: 263


🌐 Web query:

Test::Nginx::Socket pipelined_requests multiple requests raw_request documentation

💡 Result:

The Test::Nginx::Socket module provides the pipelined_requests directive to facilitate testing multiple HTTP requests over a single TCP connection, effectively simulating HTTP pipelining [1][2]. Key characteristics and usage guidelines include: 1. Data Structure: The pipelined_requests directive expects an array of request strings [3][2]. In practice, this is commonly implemented using the eval filter, which allows the use of Perl code to construct the array [2][4]. Example syntax: --- pipelined_requests eval ["GET /t", "GET /t"] 2. Sequential Execution: All requests specified in the array are sent over the same keep-alive connection, and Nginx processes them sequentially [3][5]. 3. Limitations and Compatibility: - Raw Request Interaction: The pipelined_requests and raw_request features are distinct and generally cannot be used simultaneously within the same test block [1][6]. - Protocol Restrictions: These features are designed for HTTP/1 protocols [1]. When using advanced test modes such as HTTP/2 or HTTP/3, the pipelined_requests and raw_request sections will still default to using HTTP/1 protocols [1][6]. - Response Verification: When using pipelined_requests, it is often necessary to use the eval filter for corresponding response sections (like response_body) to verify individual responses for each request in the pipeline [2][4]. For further implementation details, the Test::Nginx::Socket documentation on MetaCPAN provides comprehensive guidance on using these features within Nginx and OpenResty test suites [1][7].

Citations:


🏁 Script executed:

#!/bin/sh
set -eu
printf '--- ci/PROMPT.md context ---\n'
sed -n '1020,1050p' ci/PROMPT.md

printf '\n--- Test::Nginx references in repo ---\n'
rg -n "Test::Nginx|pipelined_requests|multiple requests|raw_request|concurrent" ci ci/PROMPT.md .github 2>/dev/null || true

Repository: myguard-labs/nginx-skeleton-module

Length of output: 263


🏁 Script executed:

#!/bin/sh
set -eu

echo '--- ci/PROMPT.md context ---'
sed -n '1020,1050p' ci/PROMPT.md

echo
echo '--- Test::Nginx references in repo ---'
rg -n "Test::Nginx|pipelined_requests|multiple requests|raw_request|concurrent" ci ci/PROMPT.md .github 2>/dev/null || true

echo
echo '--- Read-only repo check ---'
if [ -f ci/PROMPT.md ]; then
  echo "ci/PROMPT.md exists"
else
  echo "ci/PROMPT.md missing"
fi

Repository: myguard-labs/nginx-skeleton-module

Length of output: 11146


Describe the unsupported request shape precisely.

This wording excludes valid Test::Nginx multi-request patterns. The live-server cases cannot express concurrent requests on independent connections or signal delivery while traffic runs, while Test::Nginx can run sequential or pipelined requests on one connection.

Suggested wording
- Test::Nginx is one request at a time; neither shape survives the move.
+ Test::Nginx can issue multiple requests, but it cannot express concurrent
+ requests on independent connections while the master is reloading; neither
+ shape survives the move.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- **Stays in the driver** — many requests *in flight at once* (concurrency), and
anything driving a signal at the master while traffic runs (reload under
load). Test::Nginx is one request at a time; neither shape survives the move.
- **Stays in the driver** — many requests *in flight at once* (concurrency), and
anything driving a signal at the master while traffic runs (reload under
load). Test::Nginx can issue multiple requests, but it cannot express concurrent
requests on independent connections while the master is reloading; neither
shape survives the move.

Comment thread ci/tools/test_runtime.py
Comment on lines 404 to +410
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(hammer) for _ in range(8)]
time.sleep(1.0)
srv.reload()
time.sleep(1.0)
srv.reload()
for f in futures:
f.result()
try:
for n in (1, 2):
# Requests are already in flight (the pool is running), so the
# SIGHUP lands under load, which is the whole point of R3.
srv.reload()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Establish request overlap before each reload.

pool.submit() does not ensure that any hammer() task has reached get() before srv.reload() runs. The main thread can send one or both SIGHUP signals before traffic starts. The final seen check can then pass from requests that complete after the reload window.

Record request intervals and require at least one interval to overlap each SIGHUP-to-worker-start interval before accepting that reload cycle. This makes the stated under-load precondition observable.

Comment thread ci/tools/test_runtime.py
Comment on lines +418 to +423
raise Failure(
f"reload {n} did not bring up new workers within 10s "
f"(saw {worker_starts() - base} starts, want {2 * n}); "
"the reload never completed, so this case would have "
"asserted nothing"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Resolve the reported Ruff TRY003 violation.

Ruff reports TRY003 for the long inline Failure message. If this rule is enabled in CI, the claimed clean lint baseline cannot hold. Move stable text into a helper or apply a narrow suppression if this diagnostic must remain inline.

🧰 Tools
🪛 Ruff (0.16.1)

[warning] 418-423: Avoid specifying long messages outside the exception class

(TRY003)

Source: Linters/SAST tools

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