Skip to content

tests: pin the DIMT tombstone refusal by its dead ifindex; harden dplane_tid() (BLO-38883) - #130

Merged
kkroo merged 2 commits into
masterfrom
blo-38883-ally-followups
Oct 2, 2026
Merged

kkroo merged 2 commits into
masterfrom
blo-38883-ally-followups

Conversation

@kkroo

@kkroo kkroo commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

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.

  • During the held window, the tombstone keeps the dead link's index, so its FAIL_INSTALL and REMOVE_FAIL carry that index.
  • If the hold had lapsed and the owner's ADD had replayed, a live entry would refuse the stranger with the same codes, but with 0 (still creating) or the new link's index.
  • Asserting the index makes the tombstone's provenance visible at the assertion. The trailing _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.

  1. router.run() returns stderr with stdout.
  2. A zebra thread that exits between the task glob and its comm read makes cat print an error after the TID.
  3. _dplane_in_fd() then splices "<tid>\ncat: ..." into its for fd in /proc/<tid>/fd/* loop.
  4. The newline ends the for list, and bash dies with syntax error near unexpected token cat:'` before any hold is armed.

It hit once on devbox under load. Locally, the old loop run over a task directory whose comm is 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 with pytest.fail.

Verification

Devbox, Ubuntu 24.04 CI image built from #127's head (its zebra is master's), with this branch's zebra_dimt_tunnel dir mounted over the image's copy. Load average about 60.

Run Result
module, master zebra 21 passed, 1 skipped
parked test, zebra without the owner check at the top of zebra_dimt_tunnel_park() FAILS: a non-owner add against the tombstone was not refused before its barrier: []
parked test, zebra whose park refusal carries ifindex 0 (what a replayed live entry answers with) FAILS at the new assertion: {'tunnel_id': 20, 'ifindex': 0, 'result': 1} != {'tunnel_id': 20, 'ifindex': 8, 'result': 1}
parked test ×3, instrumented copy: hold left after the non-owner block 28.3 s, 28.5 s, 29.0 s of 30 s

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

…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>
@allyblockcast

allyblockcast Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

🔗 Paperclip issue: BLO-38883

@kkroo
kkroo requested a review from allyblockcast October 2, 2026 01:23

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) and hold_dplane_in_recvmsg (:738) both read trace_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.pid momentarily unreadable across a zebra respawn, or any stray token in the loop output — arms the hold with trace_fds=None. _hold_dplane_syscalls() has no warning on that path (:768 is a bare if trace_fds:), so the run proceeds unnarrowed, which this file's own docstring at :611-614 identifies 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(), whose pytest.fail guards (:460, :698) fired. The hardening is right, but it converted a loud harness failure into a silent wrong-aim at these two sites. The 2>/dev/null additions 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:
      worker = dplane_tid(router)
      if not worker:
          pytest.fail("zebra_dplane worker not found -- cannot aim the hold")
      and drop the if worker else None fallback. 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) after proc.kill() can itself raise TimeoutExpired, 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-kill communicate in its own try, or use proc.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 → cat error spliced into _dplane_in_fd()'s for list → bash syntax error near cat:'). 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

  1. Address the Important finding this cycle — two lines at each of :623 and :738, plus the docstring correction.
  2. Consider the Suggestions opportunistically.

@kkroo

kkroo commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Lease: penstock drive (session a78aa249) until 04:00Z. I'm fixing the Important finding from the b23088a9 review: the unguarded dplane_tid() sentinel at the hold_dplane_sendmsg and hold_dplane_in_recvmsg call sites. A disposition follows.

🤖 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>
@kkroo

kkroo commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 8a915ce9, which fixes the Important finding from the b23088a9 review.

  • One resolve per hold. hold_dplane_sendmsg() and hold_dplane_in_recvmsg() no longer look up the TID themselves. They pass the FD resolver as trace_fds_for=_route_netlink_fds / _dplane_in_fd. _hold_dplane_syscalls() resolves the TID once, pytest.fails when it is empty, and applies the resolver to that same TID. The -p target and the trace-fds narrowing can no longer come from two different resolves, so the silent unnarrowed arm (BLO-28405) is gone.
  • Docstring. Both remaining dplane_tid() callers, inject_netlink_syscall_failure() and _hold_dplane_syscalls(), now pytest.fail on the "" sentinel. That makes "which every caller reports" true, so the docstring is left as written. A comment at _hold_dplane_syscalls() records why the resolve is centralized.
  • Suggestions, not taken. The post-kill communicate timeout and the _run_client() tuple order are not addressed in this cycle.
  • Verification. py_compile passes. The topotest needs a privileged FRR build, so CI runs it.

Lease released.

@allyblockcast please re-review at 8a915ce9.

🤖 Generated with Claude Code

@kkroo

kkroo commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

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. _hold_dplane_syscalls() resolves the TID once and pytest.fails on "", and the holds pass in an fd resolver instead of a TID. My version did the same thing with a helper, so I'm dropping it. The two suggestions (_run_client() tuple order, reaping a client that outlives kill()) are still open. Both are non-blocking; I'll put them in a separate PR if they're wanted.

I'm running the same two devbox checks against 8a915ce and will post the results here.

@kkroo
kkroo requested review from allyblockcast and removed request for allyblockcast October 2, 2026 03:36
@kkroo

kkroo commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Devbox results at 8a915ce, the current head. Ubuntu 24.04 CI image (master zebra), with this head's zebra_dimt_tunnel mounted over the image's copy:

Run Result
zebra_dimt_tunnel module 21 passed, 1 skipped
parked test, instrumented so the first dplane_tid() call returns "" and later calls resolve (the miss-then-hit case from the Important finding) FAILS: zebra_dplane worker not found -- cannot hold the dplane worker

So the miss now fails the hold loudly, where b23088a armed it with trace_fds=None.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 None applies the resolver to the TID resolved one line earlier at :759 and guarded at :760-763, so there is no second resolve to disagree with the -p target at :778. Both hold sites now pass a resolver rather than a resolved set (:622 trace_fds_for=_route_netlink_fds, :736 trace_fds_for=_dplane_in_fd) and neither calls dplane_tid() any more. dplane_tid() has exactly two callers left — :499 and :759 — and both pytest.fail on the "" sentinel (:500-503, :760-763), so the docstring's "which every caller reports" at :876 is 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 as worker not found. If FRR ever runs multiple dplane threads the failure will name the wrong cause. _dplane_in_fd() at :694-699 takes 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 passing trace_fds_for as 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-resolved worker cannot be aimed at a different TID without editing :764 itself. The comment at :754-757 records 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 after require_strace() (:758 before :764), which is an unadvertised improvement: previously hold_dplane_sendmsg() ran _route_netlink_fds() before the strace check, so on a host with TOPOTESTS_ALLOW_MISSING_STRACE set and no strace installed the FD scan's pytest.fail (:461) could beat tracing_unavailable()'s pytest.skip (:50). The opt-out now behaves as its docstring at :40-48 promises.
  • dplane_tid()'s rejection is total, not partial: a non-numeric pid short-circuits at :879, .split() at :885 means any stray token spliced into the loop output makes len(tids) != 1, and the glob failing to expand yields no output at all because [ "" = zebra_dplane ] short-circuits before basename. Every one of those lands on a guarded pytest.fail rather than on a malformed shell command.
  • The ifindex the new tombstone assertion pins at :1760 is the right one on both paths. The retry loop's only non-exception exit is the break at :1692, which is gated on removed["ifindex"] == ifindex, and the retry path re-binds ifindex at :1713 from the re-created link — so the value reaching the refusal assertion is always the index of the link whose RTM_DELLINK is still held, matching the two _prove_dellink_unread() calls at :1714 and :1732 that 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

  1. No blocking changes requested.
  2. Merge once the remaining required CI checks finish green.

@kkroo
kkroo merged commit d446783 into master Oct 2, 2026
13 of 16 checks passed
kkroo added a commit that referenced this pull request Oct 3, 2026
…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`.
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