Repository navigation
tests: pin the DIMT tombstone refusal by its dead ifindex; harden dplane_tid() (BLO-38883) - #130
Conversation
…ane_tid() (BLO-38883) Ally's suggestions on #128: - Assert the whole refusal, ifindex included. The tombstone keeps the dead link's index until main processes the RTM_DELLINK, so its FAIL_INSTALL / REMOVE_FAIL carry it. Had the hold lapsed and the owner's ADD replayed, a live entry would refuse a non-owner with the same codes but with 0 (still creating) or the new link's index; before, only the trailing window proof told those apart. With the index asserted that proof is redundant, so it goes, which also takes a vtysh round trip out of the 30 s hold. Measured at load ~60: 28.3-29.0 s of the hold is left after the non-owner block. - _held_delete() and _direct_answer() each carried the same popen / communicate(15) / kill / communicate(5) block; both now use _run_client(). Found while verifying them: dplane_tid() let stderr into its result. router.run() returns stderr with stdout, and a zebra thread that exits between the task glob and its comm read made `cat` print an error after the TID. _dplane_in_fd() then spliced "<tid>\ncat: ..." into its shell loop, which died as "syntax error near unexpected token `cat:'" -- a harness failure before any hold was armed, hit once on devbox under load and reproduced with a task dir whose comm is gone. Each read now drops its own stderr, and anything but one numeric TID is "not found", which every caller already reports. 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: b23088a
Test-only, tightly scoped, and the ifindex-0 mutant in the PR body is a real negative control — it is what makes the new assertion worth more than the window proof it replaces. One finding, in the dplane_tid() half.
Critical Issues (0)
Important Issues (1)
- [code/gstack]
tests/topotests/zebra_dimt_tunnel/test_zebra_dimt_tunnel.py:883— the new""sentinel is unguarded at two of its four call sites, where it silently degrades a hold instead of failing it.dplane_tid()'s docstring states "Anything but one numeric TID is 'not found', which every caller reports." Two callers do not report it.hold_dplane_sendmsg(:623) andhold_dplane_in_recvmsg(:738) both readtrace_fds=_route_netlink_fds(router, worker) if worker else None/_dplane_in_fd(router, worker) if worker else None, then hand that to_hold_dplane_syscalls(), which re-resolves the TID itself (:757) and only fails if that second resolve is also empty.- So a first resolve that misses and a second that succeeds — e.g.
/var/run/frr/zebra.pidmomentarily unreadable across a zebra respawn, or any stray token in the loop output — arms the hold withtrace_fds=None._hold_dplane_syscalls()has no warning on that path (:768is a bareif trace_fds:), so the run proceeds unnarrowed, which this file's own docstring at:611-614identifies as the BLO-28405 mis-aim that synced 2 of 36 CI runs on an ethtool probe. - Before this change the same transient produced a loud failure: a garbage TID reached
_route_netlink_fds()/_dplane_in_fd(), whosepytest.failguards (:460,:698) fired. The hardening is right, but it converted a loud harness failure into a silent wrong-aim at these two sites. The2>/dev/nulladditions make the trigger rarer than it was; they do not remove the path. - Fix: resolve once and fail at the call site, or thread the resolved TID through. Either kills the double-resolve at the same time:
and drop the
worker = dplane_tid(router) if not worker: pytest.fail("zebra_dplane worker not found -- cannot aim the hold")
if worker else Nonefallback. Please also correct the docstring's "every caller reports" claim to match whatever the code ends up doing — as written it tells the next reader the sentinel is handled everywhere.
Suggestions (2)
- [errors]
test_zebra_dimt_tunnel.py:1538—proc.communicate(timeout=5)afterproc.kill()can itself raiseTimeoutExpired, leaving the client unreaped and replacing the real assertion failure with a timeout traceback. Pre-existing in both old copies, but consolidating into_run_client()makes it a one-place fix: wrap the post-killcommunicatein its owntry, or useproc.wait(timeout=5)and accept empty output. - [types]
test_zebra_dimt_tunnel.py:1539—_run_client()returns(stdout, stderr, rc)while both callers return(..., rc, stderr). The two call sites unpack correctly today; the swap across one function boundary is the kind of thing a third caller gets wrong silently, since all three are strings/ints. Returning(stdout, rc, stderr)would match the surrounding convention.
Strengths
- The ifindex assertion is a genuine strengthening, not a rewrite: asserting the refusal dict whole pins the tombstone's provenance at the assertion itself, and it now runs on both loop iterations rather than once at the end — strictly more coverage than the trailing
_prove_dellink_unread()it replaces. The two earlier window proofs (:1711,:1729) are untouched, so the docstring's "before the re-add and again once the re-add's barrier is in hand" still holds verbatim. - The ifindex-0 mutant run is the right evidence to have gone and collected: it demonstrates the old assertion passed a mutant the new one catches, which is the only thing that justifies trading the window proof away.
dplane_tid()'s docstring records the concrete failure chain (thread exits →caterror spliced into_dplane_in_fd()'sforlist → bash syntax error nearcat:'). That is why-not-what, and it is the detail that would otherwise cost the next reader an hour._run_client()removes a genuinely duplicated popen/communicate/kill block without changing either caller's contract —_text()placement,.strip()on stderr, and returncode semantics all verified identical to the originals.
Recommended Action
- Address the Important finding this cycle — two lines at each of
:623and:738, plus the docstring correction. - Consider the Suggestions opportunistically.
|
Lease: penstock drive (session a78aa249) until 04:00Z. I'm fixing the Important finding from the 🤖 Generated with Claude Code |
hold_dplane_sendmsg() and hold_dplane_in_recvmsg() each resolved the zebra_dplane TID to aim trace-fds, then _hold_dplane_syscalls() resolved it again for -p. A first resolve that missed while the second succeeded armed the hold with trace_fds=None and no warning, the unnarrowed BLO-28405 mis-aim. The callers now pass the FD resolver, and _hold_dplane_syscalls() applies it to the one TID it resolves and pytest.fails on. Every dplane_tid() caller now reports the empty sentinel, which makes the docstring's claim true. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Pushed
Lease released. @allyblockcast please re-review at 🤖 Generated with Claude Code |
|
Withdrawn. I posted this before seeing that the penstock drive session (a78aa249) had already fixed the same Important finding in 8a915ce, under a lease that it has since released. 6326512deb was never pushed, so the PR head is 8a915ce, and that is what Ally should review. 8a915ce also closes the double resolve. I'm running the same two devbox checks against 8a915ce and will post the results here. |
|
Devbox results at 8a915ce, the current head. Ubuntu 24.04 CI image (master zebra), with this head's
So the miss now fails the hold loudly, where b23088a armed it with |
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: 8a915ce
The Important finding is fixed, and fixed structurally rather than by adding a second guard. Passing the resolver instead of the resolved FD set means the narrowing and the -p target are no longer two values that should agree — they are one TID used twice, so the unnarrowed arm is unreachable rather than merely unlikely.
Prior Findings Dispositioned (1)
- prior:b23088a important 1 — fixed —
tests/topotests/zebra_dimt_tunnel/test_zebra_dimt_tunnel.py:764—trace_fds = trace_fds_for(router, worker) if trace_fds_for else Noneapplies the resolver to the TID resolved one line earlier at:759and guarded at:760-763, so there is no second resolve to disagree with the-ptarget at:778. Both hold sites now pass a resolver rather than a resolved set (:622trace_fds_for=_route_netlink_fds,:736trace_fds_for=_dplane_in_fd) and neither callsdplane_tid()any more.dplane_tid()has exactly two callers left —:499and:759— and bothpytest.failon the""sentinel (:500-503,:760-763), so the docstring's "which every caller reports" at:876is now true as written and correctly left alone.
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [errors]
test_zebra_dimt_tunnel.py:886—return tids[0] if len(tids) == 1 and tids[0].isdigit() else ""collapses "no zebra_dplane thread" and "more than one" into the same"", and both callers render it asworker not found. If FRR ever runs multiple dplane threads the failure will name the wrong cause._dplane_in_fd()at:694-699takes the opposite approach for the identical none-vs-several split —found {}: {}— and that asymmetry is the only reason this is worth a line. Returning the count, or widening the two caller messages to say "not uniquely resolved", would match the convention the file already sets.
Strengths
- Resolving once inside
_hold_dplane_syscalls()and passingtrace_fds_foras a callable is the stronger of the two fixes offered in the prior review. Threading a resolved TID through would have left two parameters that a future caller could desynchronize; a resolver applied to the locally-resolvedworkercannot be aimed at a different TID without editing:764itself. The comment at:754-757records that intent and names BLO-28405, so the next reader learns why the indirection exists rather than reading it as ceremony. - Moving the FD scan inside
_hold_dplane_syscalls()also puts it afterrequire_strace()(:758before:764), which is an unadvertised improvement: previouslyhold_dplane_sendmsg()ran_route_netlink_fds()before the strace check, so on a host withTOPOTESTS_ALLOW_MISSING_STRACEset and no strace installed the FD scan'spytest.fail(:461) could beattracing_unavailable()'spytest.skip(:50). The opt-out now behaves as its docstring at:40-48promises. dplane_tid()'s rejection is total, not partial: a non-numeric pid short-circuits at:879,.split()at:885means any stray token spliced into the loop output makeslen(tids) != 1, and the glob failing to expand yields no output at all because[ "" = zebra_dplane ]short-circuits beforebasename. Every one of those lands on a guardedpytest.failrather than on a malformed shell command.- The
ifindexthe new tombstone assertion pins at:1760is the right one on both paths. The retry loop's only non-exception exit is thebreakat:1692, which is gated onremoved["ifindex"] == ifindex, and the retry path re-bindsifindexat:1713from the re-created link — so the value reaching the refusal assertion is always the index of the link whoseRTM_DELLINKis still held, matching the two_prove_dellink_unread()calls at:1714and:1732that remain. - The three-key refusal dict matches the shape asserted everywhere else in the file (
:1105,:1232,:1690,:1927), so asserting it whole is consistent rather than a new convention, and_prove_dellink_unread()is still exercised at its two window-proof sites — the removed third call was redundant with the assertion that replaced it.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
…sking one (BLO-38883) (#131) 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", "no `zebra_dplane` thread" and "several `zebra_dplane` threads" 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 now `pytest.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. The `WindowNeverOpened` docstring's list of `pytest.fail()` sites is updated to match. **`_run_client()`: post-kill timeout (Ally, review of b23088a, `[errors]`).** `proc.communicate(timeout=5)` after `proc.kill()` could raise a second `TimeoutExpired`, 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 returncode `None`, 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: | Run | Result | |---|---| | whole module | **21 passed, 1 skipped** (same as #130) | | mutant: `dplane_tid()` matches no thread, hold caller | fails: `zebra_dplane worker not found: zebra 298 has no zebra_dplane thread -- cannot hold the dplane worker` | | same mutant, injection caller | fails: `... has no zebra_dplane thread -- netlink failure injection cannot run` | | mutant: `dplane_tid()` matches every thread | fails: `zebra_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 with `ast` and 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 raises `TimeoutExpired`.
Follow-up to #128 (BLO-38883): Ally's three suggestions there, plus a harness race I hit while verifying them. Test-only.
Changes (
tests/topotests/zebra_dimt_tunnel/test_zebra_dimt_tunnel.py)The refusal is asserted whole, ifindex included.
_prove_dellink_unread()was the only other thing that told the two apart, so it goes. That also takes a vtysh round trip out of the 30 s hold._run_client()._held_delete()and_direct_answer()share it instead of each carrying the same popen / communicate / kill / communicate block.dplane_tid()no longer lets stderr into its result. This is the race found while verifying.router.run()returns stderr with stdout.commread makescatprint an error after the TID._dplane_in_fd()then splices"<tid>\ncat: ..."into itsfor fd in /proc/<tid>/fd/*loop.forlist, and bash dies withsyntax error near unexpected tokencat:'` before any hold is armed.It hit once on devbox under load. Locally, the old loop run over a task directory whose
commis gone reproduces that exact message, and the new loop returns the bare TID. Each read now drops its own stderr, and anything but one numeric TID is "not found", which every caller already reports withpytest.fail.Verification
Devbox, Ubuntu 24.04 CI image built from #127's head (its zebra is master's), with this branch's
zebra_dimt_tunneldir mounted over the image's copy. Load average about 60.zebra_dimt_tunnel_park()a non-owner add against the tombstone was not refused before its barrier: []{'tunnel_id': 20, 'ifindex': 0, 'result': 1} != {'tunnel_id': 20, 'ifindex': 8, 'result': 1}The ifindex-0 row is the new assertion's negative control. Before this change, that mutant passed the whole test: the old assertion ignored the ifindex, and the trailing window proof checks the hold, not the refusal.
🤖 Generated with Claude Code