Skip to content

fix(core): let an impairment outlive the flow record that implements it - #53

Merged
donislawdev merged 2 commits into
masterfrom
fix/impairment-outlives-the-flow-table
Jul 27, 2026
Merged

fix(core): let an impairment outlive the flow record that implements it#53
donislawdev merged 2 commits into
masterfrom
fix/impairment-outlives-the-flow-table

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

What and why

Two audit findings: F2 (RST cooldown silently truncated) and F8 (two tooltips describing
counters they do not describe). F2 also closes the tail left over from the NAT fix in #51.

F2 - an impairment expired when its flow record did

_reset_until and _flow_last are _FlowTables that retired a generation every
FLOW_ROTATE_S (30 s), so a record survived 30-60 s. Both tables hold the state that is an
impairment, so two settings were capped by a constant nobody had connected them to:

setting before after
--rst-cooldown 60 / 120 / 300 resets every ~30-60 s regardless every 60 / 120 / 300 s exactly
--nat-timeout 5 blackhole 20 s, then traffic resumed 900+ s, ends only on an outbound packet
--nat-timeout 30 and 120 zero packets dropped 1800 dropped, blackhole holds

The NAT case was worse than capped: a retired record reads back as "never seen", so the next
inbound packet reopened the mapping with nothing sent - at a 30 s timeout the record died at the
rotation just before the first inbound packet arrived, so the impairment never fired at all.

_FlowTable.keep_for(seconds) raises the age window. set_rst passes the cooldown (a fixed
span). set_nat switches ageing OFF for _flow_last while NAT is on: matching the window to the
timeout is not enough and was measured failing exactly as above, because the record has to
outlive the timeout AND the blackhole after it, and that blackhole is meant to last until the
application sends.

Why raising the window is safe: the SIZE ceiling is enforced on every write, independently of
the age window, so a longer window makes the table older and never bigger. Measured through the
real setter: 250k flows at nat_timeout=3600 peaked at 199,999 against the 200,000 ceiling.

F8 - two tooltips that were not true

  • tips.stat_loss said "Dropped" also counted link outages. Those have had their own counter
    since they stopped inflating it, and tips.stat_flap says "Counted separately from loss" in the
    next cell - two tooltips contradicting each other, with the wrong one on the number a tester
    reads first.
  • tips.data_down promised "Hovering also shows how much the app tried to download".
    add_tooltip renders one static string; there is no dynamic path and never was. The offered
    figure exists only as metrics.offered_mb in the repro report, and even that is the sum of both
    directions. Replaced with something true and more useful: dropped packets are not counted in
    this figure, which is exactly what separates it from the connections table's byte columns.

For the reviewer

  • The two behaviour tests were verified by mutation. Making keep_for a no-op reproduces the
    audit's numbers exactly: gaps=[30.3, 60.8, 60.8, ...] for the cooldown and
    dropped=0, passed=720 for NAT.
  • test_the_size_ceiling_holds_even_with_the_age_rotation_switched_off drives the table through
    set_nat on purpose.
    The existing churn test assigns nat_timeout_s directly, so it never
    exercises the ageing-off path - the safety property behind this change would have gone
    unguarded.
  • Prose invalidated by the change was corrected in the same commit. The _FlowTable docstring
    claimed eviction "can lose an impairment, never invent one" as though that covered both paths;
    it covers the SIZE path, and on the AGE path it was not a trade but a silent cap. The step-3
    comment added in fix(core): keep an expired NAT mapping expired until the app sends #51 described the blackhole ending at the first rotation - that is now what the
    fix prevents.
  • No public contract touched: no CLI flag, exit code, NDJSON field or on-disk format changes. The
    memory bound is unchanged.

Checklist

  • python -m pytest tests passes locally (677 tests, 0 failures, elevated shell). python smoke_gui.py passes.
  • New behaviour has tests (three in tests/test_core.py; two verified by mutation).
  • UI text goes through i18n keys, with both lang/en.json and lang/pl.json updated (465 keys, identical sets).
  • User-facing changes noted in CHANGELOG.md; technical ones and new tests in CHANGELOG-INTERNAL.md, under [Unreleased].
  • Commits follow Conventional Commits (type(scope): summary).
  • No version bump - the owner closes a version via VERSION.txt.

🤖 Generated with Claude Code

donislawdev and others added 2 commits July 27, 2026 20:47
_reset_until and _flow_last retired a generation every FLOW_ROTATE_S
(30 s), so a record survived 30-60 s - and both tables hold the state
that IS an impairment. Two settings were capped by a constant nobody had
connected them to:

- rst_cooldown accepts up to 3600 s; measured at 120 s the same flow was
  reset again after 30.3, then 60.8, then 60.8 s.
- NAT expiry was worse than capped: a retired record reads back as
  "never seen", so the next inbound packet reopens the mapping with
  nothing sent. A 30 s and a 120 s timeout dropped ZERO packets, because
  the record died just before the first inbound packet arrived.

- core: _FlowTable.keep_for(seconds) raises the age window. set_rst
  passes the cooldown; set_nat switches ageing OFF for _flow_last while
  NAT is on, because matching the window to the timeout is not enough -
  the record must outlive the timeout AND the blackhole after it, which
  lasts until the application sends.
- core: the size ceiling is untouched and still checked on every write,
  so a longer window makes the table older, never bigger. Measured
  through the real setter: 250k flows at nat_timeout=3600 peaked at
  199,999 against the 200,000 ceiling.
- core: corrected the prose this invalidates - the _FlowTable docstring
  claimed eviction "can lose an impairment, never invent one" as if that
  covered both paths (it covers SIZE; on AGE it was a silent cap), and
  the step-3 comment from the F1 fix described the blackhole ending at
  the first rotation.
- tests: three in test_core.py; the two behaviour tests verified by
  mutation, which reproduces the audit numbers exactly (gaps=[30.3,
  60.8, 60.8, ...] and dropped=0, passed=720).
- both changelogs (convention 39).

After: cooldowns of 60/120/300 s reset at exactly those intervals; a NAT
blackhole held the full 900 s probe at 5, 30 and 120 s timeouts and ended
on the first outbound packet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…escribe

Audit F8. Both user-visible, in both languages, and neither catchable by
a test - true-sounding prose next to correct code.

- tips.stat_loss claimed "Dropped" also counted link outages. Those have
  had their own counter (drop_flap) since they stopped inflating it, and
  tips.stat_flap says "Counted separately from loss" in the next cell.
  The wrong tooltip was on the number a tester reads first.
- tips.data_down promised "Hovering also shows how much the app tried to
  download". add_tooltip renders one static string; there is no dynamic
  path and never was. The offered figure lives only in the repro report
  as metrics.offered_mb, and even that is the SUM of both directions.
  Replaced with something true: dropped packets are not counted in this
  figure - which is exactly what separates it from the connections
  table's byte columns.

- both changelogs (convention 39; a GUI-visible change goes to the
  user-facing one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit c27c88e into master Jul 27, 2026
8 checks passed
@donislawdev
donislawdev deleted the fix/impairment-outlives-the-flow-table branch July 27, 2026 19:09
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