Repository navigation
tests: name the dplane-worker failure and keep a stuck client from masking one (BLO-38883) - #131
Conversation
…sking one (BLO-38883) Ally's three non-blocking suggestions on #130. dplane_tid() folded "no zebra pid", "no zebra_dplane thread" and "several zebra_dplane threads" into one "" that both callers reported as "worker not found", so a second dplane thread would have been diagnosed as a missing one. It now fails itself and names which, the way _dplane_in_fd() already tells none from several (found N: [...]); each caller passes what it was trying to do. _run_client() could raise a second TimeoutExpired from the post-kill communicate(), which left the client unreaped and replaced the real assertion failure with a traceback. A client still alive 5s after SIGKILL now yields empty output and returncode None, so the caller's assertion on the reply fails on what the client did. It also returns (stdout, rc, stderr), the order both callers return, instead of swapping rc and stderr across one function boundary. Signed-off-by: Omar Ramadan <omar@blockcast.net> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
🔗 Paperclip issue: BLO-38883 |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: dbb922f
Three well-scoped follow-ups to #130, test-only, one file. dplane_tid()'s none-vs-several split and the (stdout, rc, stderr) reorder are both clean and fully propagated — I confirmed all four call sites in the module are updated and that dplane_tid appears in no other file in the repo. One finding on the _run_client() kill path.
Critical Issues (0)
Important Issues (1)
- [errors]
tests/topotests/zebra_dimt_tunnel/test_zebra_dimt_tunnel.py:1559— the new post-killexceptmakes an unkillable client indistinguishable from a dplane-worker hold, which is the attribution this PR exists to sharpen, inverted. Returning("", None, "")sendsremoved=Noneinto_held_before_delete_result()(:1592), whose precondition is "no reply to the DEL and one recvmsg entered and not returned" — and underhold_dplane_in_recvmsg()that recvmsg shape is the expected state, not a rare one. Sofrozencomes back non-Noneand the run fails with"the hold caught the worker before the DEL's result reached main"(:1726), confidently naming zebra for what was a stuck client. Pre-PR this path raisedTimeoutExpired, which was ugly but named the right component; for this one path the change trades a correct loud failure for a plausible wrong one. The raw tell is already printed —rc=Nonewhere a reaped client gives an int and a SIGKILLed one gives-9— but nothing in the message says whatrc=Nonemeans, and the surrounding prose asserts the dplane explanation.- Keep the control flow exactly as is and make the existing
stderr={!r}carry the cause, since bothWindowNeverOpenedmessages (:1721,:1726) already print it: in the inner handler,stdout, stderr = None, "<client did not exit 5s after SIGKILL>". One line, no caller changes, and the misattributed message then contradicts itself out loud. (_text()passesstrthrough unchanged, so.strip()is still fine.)
- Keep the control flow exactly as is and make the existing
Suggestions (2)
- [comments]
tests/topotests/zebra_dimt_tunnel/test_zebra_dimt_tunnel.py:146— the rewrittenWindowNeverOpeneddocstring re-asserts "EVERYpytest.fail()in this module", but the enumeration omits two live sites:_route_netlink_fds()(:462, reached frominject_netlink_syscall_failure()whenroute_netlink_fds=True) and_dplane_in_fd()(:692, reached from_hold_dplane_syscalls()viatrace_fds_for). Pre-existing — the old "bothinject_netlink_syscall_failure()setup paths / both_hold_dplane_syscalls()paths" phrasing missed them too — and the paragraph's actual conclusion still holds, since araises=pytest.fail.Exceptionmarker would absorb those two as well. But this PR rewrites that exact sentence, so it is the cheap moment to make the list match. Worth noting_dplane_in_fd()is the function the newdplane_tid()docstring cites as the none-vs-several precedent. - [errors]
tests/topotests/zebra_dimt_tunnel/test_zebra_dimt_tunnel.py:891—if len(tids) != 1 or not tids[0].isdigit()routes a single non-numeric match into the "not uniquely resolved" message, which then readsfound 1: ['cat:']— a header about cardinality for what is a parse problem. Unreachable in practice (basenameon/proc/<pid>/task/<tid>is always numeric, and the innercatnow discards its own stderr), so this is only about the message a future reader would get from the defensive branch. Splitting the non-digit case out, or softening the header to cover both, would keep the naming as precise as the rest of the function now is.
Strengths
- The none-vs-several split is the right fix and is done properly: each branch names which failure, echoes the observed value (
read {!r},found {}: {}), and threads the caller'spurposeso both sites keep their context. Modelling it on_dplane_in_fd()'s existingfound N: [...]keeps one convention in the module instead of inventing a second. - Making
purposea required positional rather than defaulted means the two call sites could not silently drift into a context-free message — the compiler-equivalent enforcement for a convention that is otherwise easy to forget. - The return-order fix is the kind of latent foot-gun worth spending a diff on:
(stdout, stderr, rc)against two callers returning(..., rc, stderr)is a silent swap the day a third caller is added. Both sites updated in the same commit. - The verification table is genuinely adversarial rather than confirmatory — mutating
dplane_tid()to match no thread and to match every thread, checking both callers' messages separately, and running a positive control on master's_run_client()against the same unkillable fake to show the old code raises. Theast-extraction harness for a path no topotest can reach is a reasonable answer to "how do you test an unkillable process". - Docstrings were updated alongside the code rather than left to rot, including the cross-reference in
WindowNeverOpened.
Recommended Action
- Address the Important finding this cycle — it is a one-line change to the inner handler and needs no caller updates.
- Consider the two Suggestions opportunistically.
|
Lease: kkroo drive session (Omar, 7ee27e), about 30 min, at |
_run_client gave up an unkillable client's output as empty with rc=None. Under hold_dplane_in_recvmsg() that looks exactly like a held dplane worker, so the WindowNeverOpened message blamed zebra for a stuck client. Both of those messages already print stderr, so put the cause there. Signed-off-by: Omar Ramadan <omar@blockcast.net>
|
Lease: kkroo drive session (Omar, 7ee27e), about 30 min, at |
|
Pushed Important ( Checked:
|
|
Lease released (kkroo drive session 7ee27e). Pushed |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 7414b1b
One line of code since the previous head, and it is exactly the remedy the open finding asked for: the control flow is untouched and no caller changed. I re-traced the new value through both _run_client() consumers at this head, including the one the finding did not name.
Prior Findings Dispositioned (1)
- prior:dbb922f important 1 — fixed —
tests/topotests/zebra_dimt_tunnel/test_zebra_dimt_tunnel.py:1562— the inner handler now setsstdout, stderr = None, "<client did not exit 5s after SIGKILL>", so an unkillable client is no longer indistinguishable from a dplane-worker hold. Checked at this head rather than from the patch:_text(None)returns""(:804), so the stdout half is byte-identical to before andjson.loads("")/"".splitlines()behave exactly as they did;proc.returncodeis stillNoneon this path, since the process was never reaped; and both messages that would otherwise blame zebra print the marker through their existingstderr={!r}—:1724and:1731. The misattributing message now contradicts itself out loud, which was the point.- I also checked the path the finding did not cover.
_direct_answer()'s caller assertsrc == 0first (:1778) and carriesstderrin its tuple, sorc=Nonetrips that assert and surfaces the marker; the lateranswersassert (:1780), which omitsstderr, cannot fire ahead of it. Both consumer paths are covered, not just_held_delete().
- I also checked the path the finding did not cover.
Critical Issues (0)
Important Issues (0)
Suggestions (2)
Both carried unchanged from the dbb922f review and both still open — neither blocks, and deferring them again is a reasonable call.
- [comments]
tests/topotests/zebra_dimt_tunnel/test_zebra_dimt_tunnel.py:146— the rewrittenWindowNeverOpenedenumeration now namesdplane_tid()and the strace attach failures, but twopytest.fail()sites remain outside it:_route_netlink_fds()(:462) and_dplane_in_fd()(:692). The paragraph's conclusion is unaffected — araises=pytest.fail.Exceptionmarker absorbs those two as well — so this is only about the list matching the module. - [errors]
tests/topotests/zebra_dimt_tunnel/test_zebra_dimt_tunnel.py:891—if len(tids) != 1 or not tids[0].isdigit()still routes a single non-numeric match into the "not uniquely resolved" message, which then readsfound 1: ['cat:']— a cardinality header for what is a parse problem. Unreachable in practice, so this only affects the message a future reader would get from the defensive branch.
Strengths
- The fix took the narrow option: one assignment inside the existing handler, no change to the return shape, no caller touched. The alternative — signalling the unkillable case through the return tuple — would have rippled into
_held_delete(),_direct_answer()and both of their assertion sites for no added signal. - The docstring was extended in the same commit to say why the marker exists ("a held dplane worker also leaves the DEL unanswered, and the messages that blame it print stderr"), which records the attribution hazard rather than just the behaviour. That sentence is the thing that stops the marker being removed as noise later.
- Choosing a string the existing
{!r}formatters already print, instead of adding a new field to the failure messages, means the fix needed no change at eitherWindowNeverOpenedsite.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
… them red (#134) ## Why frr's Build legs sit in the org-wide `default` queue for 12-37 hours. GitHub hands queued `default` jobs to runners org-wide, and not oldest-first. On 2026-10-02 about 1,300 newer `default` jobs from other repos started while 3 from frr did. The effects: - master CI never finishes; - `buildcache-seed.yml` never completes, so the registry caches (`cache/frr-ci:*-buildcache`) have been stale since #126; - PRs merge with their Build legs cancelled (#124-#131). The ARC capacity owner diagnosed this and proposed this lever: give frr's image jobs a lane outside `default`. It changes no shared ARC template, so there is no lane re-roll. ## What | Workflow | Job | Before | After | |---|---|---|---| | github-ci.yml | Build (u22, u24) | default | arc-dind | | github-ci.yml | Build-LTTng | default | arc-dind | | github-ci.yml | Unit-Test | default | arc-dind | | buildcache-seed.yml | probe / seed / verify / freshness gate | default | arc-dind | | github-ci.yml | Documentation-HTML | default | default (unchanged) | Documentation-HTML stays on `default`. It runs only on doc changes, and it needs sudo apt plus more memory than an arc-light runner has. The remaining `default` workflows are behind-base, conflicts, size-label, stale, base-branch-label, mergifyio_backport and docker-daily-master. All are gated on `github.repository == 'frrouting/frr'`, so they never run on this fork. freeze runs only on labelled PRs. ## Fit on arc-dind Live AutoscalingRunnerSet specs, read 2026-10-03: | | arc-default | arc-dind | |---|---|---| | dind CPU limit | 2 | 4 | | dind memory limit | 8Gi | 16Gi | | dind ephemeral limit | 20Gi | 40Gi | | runner work dir | 40Gi Ceph PVC | node-local emptyDir (runner limit 24Gi) | | maxRunners | 24 | 14 | - arc-dind uses the same runner image, and BuildKit compiles frr inside the dind sidecar, so every build-relevant limit grows. - Build pushes to the registry and writes no tarball, so the smaller work dir only holds the checkout and test results. - frr's `gtm-image-build.yml` and `nbg6817-openwrt-ipk.yml` already run on arc-dind. - arc-dind's queue averaged 2.5 jobs (max 12) over the same 24h, against ~17 for arc-default. ## Testing - YAML parses. - `check-runner-labels.py` passes. - `.github/scripts` unittests pass: 531 tests. Not run: `TestBoundedErrorBodyOverRealSocket`, which hangs on macOS sockets and touches no file in this diff. - `test_buildcache_freshness`'s same-runner check (Build-LTTng mirrors Build) passes. - The real check is this PR's own CI: Build and Unit-Test should start within minutes on arc-dind, not hours.
Ally's three non-blocking suggestions on #130, all in
tests/topotests/zebra_dimt_tunnel/test_zebra_dimt_tunnel.py. Test-only.dplane_tid(): none vs several (Ally, #130 review of 8a915ce,[errors]). It folded "no zebra pid", "nozebra_dplanethread" and "severalzebra_dplanethreads" into one"", and both callers reported that as "worker not found", so a second dplane thread would have been diagnosed as a missing one. It nowpytest.fail()s itself and names which, the way_dplane_in_fd()already tells none from several (found N: [...]). Each caller passes what it was trying to do, so the two messages keep their context. TheWindowNeverOpeneddocstring's list ofpytest.fail()sites is updated to match._run_client(): post-kill timeout (Ally, review of b23088a,[errors]).proc.communicate(timeout=5)afterproc.kill()could raise a secondTimeoutExpired, which left the client unreaped and replaced the real assertion failure with a traceback. A client still alive 5 s after SIGKILL now yields empty output and returncodeNone, so the caller's assertion on the reply fails on what the client did._run_client(): return order (same review,[types]). It returned(stdout, stderr, rc)while both callers return(..., rc, stderr). It now returns(stdout, rc, stderr); both call sites updated.Verification
On devbox, with frr master
d4467836c8's CI image (24.04) and this file mounted over the module:dplane_tid()matches no thread, hold callerzebra_dplane worker not found: zebra 298 has no zebra_dplane thread -- cannot hold the dplane worker... has no zebra_dplane thread -- netlink failure injection cannot rundplane_tid()matches every threadzebra_dplane worker not uniquely resolved: expected one zebra_dplane TID in zebra 298, found 5: ['298', '299', '300', '301', '310'] -- cannot hold the dplane worker(before this PR that case read "worker not found")_run_client()'s kill path cannot be reached from a topotest without a client that survives SIGKILL, so it was checked offline: the functions were extracted withastand run against fakes. Normal, killed-then-exited and unkillable clients return('{"a":1}\n', 0, 'err'),('partial', -9, '')and('', None, ''). Positive control: master's_run_client()against the same unkillable fake raisesTimeoutExpired.🤖 Generated with Claude Code