Skip to content

ci(e2e): bound backgrounded agent waits with diagnostics-on-timeout - #62

Merged
Thecave3 merged 2 commits into
mainfrom
60-e2e-watchdog-timeout
Aug 19, 2026
Merged

ci(e2e): bound backgrounded agent waits with diagnostics-on-timeout#62
Thecave3 merged 2 commits into
mainfrom
60-e2e-watchdog-timeout

Conversation

@Thecave3

@Thecave3 Thecave3 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Root cause of the intermittent E2E/Topologies hangs (issue [Bug]: no CI job timeouts + swallowed asn1c install errors cause multi-hour hangs; stacked PRs get no build signal #60 item 1): the dApp side of each agent/dApp pairing is already wrapped in timeout, but the backgrounded agent process is only ever sent kill -INT and then waited unbounded — if it ignores the signal or is genuinely deadlocked, that wait blocks until the 15/20-minute job timeout.
  • Add scripts/wait_with_diagnostics.sh: a bounded wait replacement that dumps process state (ps stat/wchan, /proc/<pid>/status, open fds) and SIGKILLs a stuck peer instead of hanging, wired into all 5 unbounded agent waits across e2e-dapp-test.yml/e2e-topologies.yml.
  • Escalate the existing dApp-side timeout calls (5 sites) to also SIGKILL after a grace period (-k 10), closing the same hole on the other side of each pairing.

Type of change

  • Bug fix
  • New feature / enhancement
  • New Service Model
  • Refactor (no behavior change)
  • Documentation
  • Test / CI / packaging
  • Other (explain):

Linked issue

Refs #60 (the actual deadlock fix). Deliberately not "Closes #60" — same reasoning as #61, which covers the rest of #60's CI-hardening bullets. Closing #60 is left to a maintainer once everything lands.

Mandatory test checklist

  • ./build_libe3 -c -d build -j $(nproc) -r -t passes (Release build + tests) — verified locally, 17/17 tests passed
  • ./build_libe3 -c -d build -j $(nproc) -g -t passes (Debug build + tests) — verified locally, 17/17 tests passed
  • cd build && ctest --output-on-failure is clean
  • MPMC queue benchmark shows no regression vs main — ran locally; this PR doesn't touch include/libe3/mpmc_queue.hpp
  • VERSION bumped — N/A, no public API/ABI change
  • ./build_libe3 --docs — N/A, no include/ changes
  • New build dependencies — N/A, no new dependencies added
  • libe3.pc interface — N/A, unchanged

CI checklist

  • Unit Tests workflow is green — this PR doesn't change anything pr-tests.yml builds/tests differently; will confirm it stays green
  • Commit policy workflow is green — will confirm once this PR's own run completes
  • MPMC Queue Benchmark — N/A, doesn't run on this diff (mpmc_queue.hpp untouched)

Twin-repo coordination

  • This PR does not change the E3 wire protocol or public ABI, OR a paired PR exists in each affected twin repo (link below).

Paired PR(s): none needed — CI test-harness change only.

Workflow confirmation

  • My branch is a linear, fast-forward-able descendant of main (rebased if main moved), with no merge commits.
  • Every commit builds and passes tests on its own (atomic, git bisect-safe) with a descriptive message.
  • I have read and followed CONTRIBUTING.md.

Observed vs. reasoned

  • Observed: wait_with_diagnostics() tested directly against real backgrounded processes under set -e (matching the workflows' actual shell state) — a quick-exiting process returns cleanly with no diagnostics printed; a process that outlives its timeout gets diagnostics dumped and is SIGKILLed at the measured timeout boundary (verified with date-based timing, not just visual inspection). Also ran the actual e2e-dapp-test.yml agent/dApp pairing logic locally end-to-end against the real example_simple_agent/example_simple_dapp binaries (zmq/ipc): the dApp received all 7 indications and exited 0, the agent stopped cleanly on SIGINT, and wait_with_diagnostics reaped it without needing to fire its kill path.
  • Caught and fixed a real bug during this verification: ((waited++)) is a post-increment that evaluates to 0 on the loop's first pass, which bash's set -e treats as a failing bare command — would have aborted the calling script after one second in any caller lacking a trailing || true (every real call site here has one, but the function needed to be correct standalone). Fixed via ((++waited)) and reverified with a bare, unguarded call.
  • Reasoned, not yet observed: the kill-path actually firing on a genuine hang in live CI — by definition this only fires on an actual deadlock, which is intermittent. The live E2E/Topologies runs on this PR (it touches the new script, so paths-ignore doesn't skip them) will at least confirm the happy path across all matrix legs; will report back once watched.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

⏱️ Full-loop Latency Benchmark (commit 7398166)

Full-loop latency benchmark (N=1049 after 50 warmup)

All values in microseconds (us). Link: zmq, transport: ipc, encoding: ASN.1 APER.

Phase mean p50 p99 max
1. Collect indication data 0.18 0.17 0.32 0.43
2. Create & encode indication 1.07 1.00 1.78 13.35
3. Deliver indication (RAN -> dApp) 110.98 103.09 198.65 523.40
4. Decode indication 0.78 0.73 1.25 15.13
5. Process data 0.03 0.03 0.04 0.17
6. Create & encode control 0.43 0.38 0.71 18.46
7. Deliver control (dApp -> RAN) 120.91 111.92 205.19 449.30
8. Decode & handle control 0.66 0.64 1.02 14.80
Total round-trip 235.04 201.30 359.54 939.04

Benchmarked on ubuntu-latest, Release build, ZMQ + IPC, ASN.1 APER.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🔀 E2E Topologies — multi-dApp / multi-RAN (commit 7398166)

zmq/ipc

  • 1 RAN - 1 dApp: indications=5
    • dapp peer=t11 ran=ran-solo sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=1 max=1 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]
  • 1 RAN - 2 dApps: dApp#1 ind=5 sub=1, dApp#2 ind=6 sub=2, RAN saw 2 dApps
    • dapp1 peer=t12 ran=ran-shared sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=1 max=1 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]
    • dapp2 peer=t12 ran=ran-shared sub=2 indications=6 seq=[0..5] dropped=0 (0%) age_ms(avg=1 max=1 @seq=0) hist[<=1:6 2-5:0 6-10:0 >10:0]
  • 2 RANs - 1 dApp: from ran-a ind=5, from ran-b ind=5
    • dapp peer=t2a ran=ran-a sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=0 max=0 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]
    • dapp peer=t2b ran=ran-b sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=0 max=0 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]

zmq/tcp

  • 1 RAN - 1 dApp: indications=5
    • dapp peer=default ran=ran-solo sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=1 max=1 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]
  • 1 RAN - 2 dApps: dApp#1 ind=5 sub=1, dApp#2 ind=6 sub=2, RAN saw 2 dApps
    • dapp1 peer=default ran=ran-shared sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=1 max=1 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]
    • dapp2 peer=default ran=ran-shared sub=2 indications=6 seq=[0..5] dropped=0 (0%) age_ms(avg=1 max=1 @seq=0) hist[<=1:6 2-5:0 6-10:0 >10:0]
  • 2 RANs - 1 dApp: from ran-a ind=5, from ran-b ind=5
    • dapp peer=default ran=ran-a sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=0 max=0 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]
    • dapp peer=off100 ran=ran-b sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=0.6 max=1 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]

example_simple_agent + example_simple_dapp on ubuntu-latest, Release. Indication age is report-only.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🔄 E2E dApp Integration Results (commit 7398166)

posix/ipc

  • dApp exit: 0
  • Indications received: 7

posix/tcp

  • dApp exit: 0
  • Indications received: 7

zmq/ipc

  • dApp exit: 0
  • Indications received: 7

zmq/tcp

  • dApp exit: 0
  • Indications received: 7

example_simple_agent + example_simple_dapp on ubuntu-latest, Release build

The dApp side of each agent/dApp pairing in e2e-dapp-test.yml and
e2e-topologies.yml is already wrapped in `timeout`, but the
backgrounded agent process is only ever sent `kill -INT` and then
`wait`ed with no bound at all. If it ignores the signal or is
genuinely deadlocked, that wait blocks until the job-level timeout -
this is the actual mechanism behind issue #60's intermittent
E2E/Topologies hangs, distinct from and complementary to the
CI-hardening work in the separate PR against #60 (timeouts, asn1c
caching, honest dependency-install failures).

This does not root-cause why a peer occasionally gets stuck - it
makes the harness itself resilient to that hang: add
scripts/wait_with_diagnostics.sh, a bounded replacement for `wait
<pid>` that dumps process state (ps stat/wchan, /proc/<pid>/status,
open fds) and SIGKILLs the process if it hasn't exited within a given
timeout, instead of hanging indefinitely. Wire it into the 5 unbounded
agent waits (e2e-dapp-test.yml's single wait; e2e-topologies.yml's
three: topology 1 and 2's shared-RAN wait, topology 3's two
per-RAN waits). Escalate the existing dApp-side `timeout` calls (5
call sites total) to also SIGKILL after a 10s grace period via `-k
10`, so a dApp that ignores SIGTERM doesn't reopen the same hole on
the other side of the pairing.

The script lives under scripts/, not .github/, deliberately: both E2E
workflows' `paths-ignore` includes `.github/**`, so a PR whose only
changed files lived there would never even trigger them, defeating
this PR's own ability to self-verify live.

These are cleanup/teardown waits, not pass/fail signals - test
verdicts already come from the dApp's exit code and indication counts
- so a watchdog-triggered kill doesn't flip any topology's own
pass/fail logic, it only guarantees the job can't hang on this path.
Once this lands and a real hang recurs in CI, the captured diagnostics
are the first real evidence for root-causing it - worth a follow-up
issue then, with actual data, not now.

Verified wait_with_diagnostics() directly against real backgrounded
processes, under `set -e` matching the workflows' own shell state: a
quick-exiting process returns its real exit status without printing
diagnostics; a process that outlives the timeout gets its diagnostics
dumped and is SIGKILLed at (measured) the exact timeout boundary.
Caught and fixed a real bug in the process - `((waited++))` is a
post-increment that evaluates to 0 on the loop's first pass, which
bash's `set -e` treats as a failing bare command and would have
aborted the whole calling script after one second in any caller that
doesn't append `|| true` (every real call site here does, but the
function needed to be correct on its own); switched to `((++waited))`
and confirmed via a bare, unguarded call that the full timeout window
now elapses correctly. Both workflow files still parse as valid YAML;
the live E2E/Topologies runs on this PR (which touches the new script,
so paths-ignore won't skip them) are the actual test of the happy
path end to end.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Andrea Lacava <a.lacava@northeastern.edu>
@Thecave3
Thecave3 force-pushed the 60-e2e-watchdog-timeout branch from d317377 to cb08be5 Compare August 19, 2026 23:05
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Ready to merge (fast-forward only)

All required checks are green for 7398166. A maintainer can land the reviewed commits with:

git fetch origin
git checkout main && git merge --ff-only 7398166996483b222712b92b67552ef4e8629ff7 && git push origin main

Head: 7398166996483b222712b92b67552ef4e8629ff7 (branch 60-e2e-watchdog-timeout). If --ff-only fails as non-fast-forward, the branch must be rebased on the latest main.

Discovered live on this branch's own rebased CI run, right after
merging #61: the "ready to merge" comment posted while "Build and Test
(ubuntu-latest, Release)" was still in_progress. The polling loop
iterated over whatever check-runs the API happened to return and only
flipped `all_done` to false for entries it found with status !=
completed -- a required context that hasn't been *created* yet at all
(a matrix leg still queued behind another job) never appears in that
response, so the loop silently never touched it and `all_done` stayed
true.

Doesn't affect actual merge safety: the branch ruleset independently
re-checks every required context at push time regardless of what this
comment says, so a premature comment couldn't have caused an unsafe
merge, only a premature or misleading informational one. Still worth
fixing since it undermines the point of gating the comment on real
completion at all, and it was already merged into main via #61.

Fix: iterate over the required list, not over the found check-runs,
and look up each one's current status individually. A required
context missing from the response now correctly counts as "not done"
instead of being silently skipped.

Verified with a stubbed gh covering the exact bug scenario (a required
context absent from two consecutive polls, appearing only on the
third) plus the four scenarios from the original commit (waits then
posts, declines on a real failure, updates an existing comment,
gives up quietly past its deadline) -- all five now correct. Full
local Release build + `ctest` (17/17) still passes.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Andrea Lacava <a.lacava@northeastern.edu>
@Thecave3
Thecave3 merged commit 7398166 into main Aug 19, 2026
26 checks passed
@Thecave3
Thecave3 deleted the 60-e2e-watchdog-timeout branch August 19, 2026 23:24
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.

[Bug]: no CI job timeouts + swallowed asn1c install errors cause multi-hour hangs; stacked PRs get no build signal

1 participant