diff --git a/CHANGELOG.md b/CHANGELOG.md index 4936b04..837fa4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol capture, and a refused START says what to do. The Statistics page and its CSV count packets that went around the tool and those the driver dropped, with a red line while either grows. Its files stay yours, and GitHub and Donate print the address instead of - starting a browser as root. On the Tools tab, TCP health says it is Windows-only for now, - and the row menus grey out Show TCP health. + starting a browser as root. - **Linux, experimental: Diagnostics speaks of Linux.** Its help explains what stops a session there - capture rights, the kernel, BTF and pydivert - and what to do, and @@ -48,6 +47,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol and the `environment.d` folders, also under sudo as yours. **Add network adapters** and **Add proxy settings** in Diagnostics work too. +- **Linux, experimental: TCP health works, without sudo.** It shows what the kernel keeps for a + connection, as `ss -ti` does, also for other users' programs, and **Show TCP health** works + in the row menus. Linux keeps no newest or highest round trip and counts no fast resends or + duplicate ACKs, so those rows are not there. It does count the segments sent again, in a + row of its own. The lowest round trip is the lowest of the last 5 minutes, and timeouts are + counted from Linux 6.7 on. + - **Ping on the Tools tab, to see the session's latency and loss arrive.** Type an address or a name and press **Start**: each ping is a row with its round trip and what came back, the last 60 are charted, and the totals say what was lost. The ping goes on while another tab is in diff --git a/README.md b/README.md index a4cf7e0..f46ce80 100644 --- a/README.md +++ b/README.md @@ -206,14 +206,13 @@ exit code 1, as on Windows without administrator rights. Where Linux differs fro - IP fragments always pass through the tool, even those its filter does not match. - In the window, the files it saves (window state, profiles, CSV) stay yours under `sudo`, and GitHub and Donate print their address instead of starting a browser as root. -- On the Tools tab, Ping, Sockets, Port check, Filter tester and Diagnostics work. Ping works - without `sudo` where the system lets your group send pings (the setting +- On the Tools tab, Ping, Sockets, Port check, Filter tester, TCP health and Diagnostics work. + Ping works without `sudo` where the system lets your group send pings (the setting `net.ipv4.ping_group_range`: Ubuntu 26.04 allows it, 24.04 does not). With `sudo` it sends through a raw socket, which needs the capability CAP_NET_RAW: root has it unless the system takes it away, as some containers do. A ping Linux refuses shows its own word for it: `EPERM` usually means a firewall rule on this computer. - A zone of an IPv6 address can be the interface's name (`fe80::1%eth0`). TCP health says that - it works on Windows only for now, and so do the row menu entries that open it. The + A zone of an IPv6 address can be the interface's name (`fe80::1%eth0`). The Diagnostics help explains the Linux checks: capture rights, the kernel, BTF and pydivert. - **"Reset this connection"** on the Connections page works on Linux for TCP over IPv4 and IPv6, as root or with the capability CAP_NET_ADMIN. The program using the connection gets @@ -236,6 +235,15 @@ exit code 1, as on Windows without administrator rights. Where Linux differs fro not read. The desktop's settings are read with `gsettings`, as you, also under `sudo`: this is the one program the tool starts, and it sends nothing. There is no proxy for services as on Windows (WinHTTP). +- **TCP health** on the Tools tab works on Linux, without `sudo`, also for connections of other + users' programs: the kernel keeps these numbers for every TCP connection from the moment it + opens, as `ss -ti` shows them. Linux keeps no newest single round trip and no highest one, + and does not count fast resends or duplicate ACKs, so those rows are not shown. It does + count the segments sent again, which is a row of its own there. The lowest round trip is + the lowest of the last 5 minutes. Timeouts are counted from Linux 6.7 on and count every + time the resend timer ran out. Linux kept measuring when the round trip of an open + connection jumped by 200 ms, so there is no "not measured" note on Linux. The bytes received + do not count data received twice. A connection whose program has closed it reads as Ended. ## The window @@ -328,7 +336,8 @@ monitor is gone it returns to the centre of the current screen. right-click a row to target its program, leave it alone, limit to or block its remote address, watch a TCP connection in **TCP health** or ping its far end in **Ping**. Program names are shown without administrator rights. - - **TCP health** - what Windows measures for a TCP connection: the round-trip time + - **TCP health** - what Windows measures for a TCP connection (on Linux, what the kernel + keeps: see the Linux section): the round-trip time (smoothed, newest, lowest and highest, charted for the last minute), the bytes sent again, timeouts, duplicate ACKs, the segment size and the bytes sent and received (data sent again included). Windows measures a round trip only from data it sent once: when @@ -1622,14 +1631,16 @@ beantester/ the implementation package proxy.py the proxy this account and this machine use (winhttp/ctypes and the registry; on Linux gsettings and the environment files), read only, for the Tools tab - tcpestats.py what Windows' TCP stack measures for one connection (EStats, - iphlpapi/ctypes), for the Tools tab's TCP health + tcpestats.py what the TCP stack measures for one connection (EStats, + iphlpapi/ctypes; on Linux the kernel's tcp_info), for the Tools + tab's TCP health tcpreset.py reset one TCP connection (iphlpapi/ctypes; on Linux the kernel's SOCK_DESTROY), for the Connections page's row menu tcprow.py one TCP connection the way Windows names it: the row layouts and finding it in the socket table, without calling Windows sockdiag.py the kernel's own list of TCP sockets on Linux (sock_diag over - netlink), read only: where a reset finds the socket it destroys + netlink), read only: where a reset finds the socket it destroys, + and one socket's tcp_info for TCP health rtnetlink.py the kernel's tables of adapters, addresses and routes on Linux, and a route lookup (what ip route get asks), read only, for the adapters netlink.py the message socket to the Linux kernel under sockdiag and rtnetlink: diff --git a/beantester/capabilities.py b/beantester/capabilities.py index cfdfd91..fbe0b09 100644 --- a/beantester/capabilities.py +++ b/beantester/capabilities.py @@ -32,7 +32,9 @@ (``why_not()``, asked when it is there): Ping, the first one, needs the ping socket for the user's group or sudo (``icmp.why_not``). Reset on Linux needs a kernel built with ``CONFIG_INET_DIAG_DESTROY`` (WSL's is not); it has no words of its own, because -its only reader is a row menu, which greys the entry out - the README says why. +its only reader is a row menu, which greys the entry out - the README says why. TCP +health on Linux needs a kernel that lists TCP sockets through sock_diag (every one +measured does); without it the shared words are said. """ from types import ModuleType from typing import NamedTuple, Optional diff --git a/beantester/gui/toolbox/tcphealth.py b/beantester/gui/toolbox/tcphealth.py index 61308d6..3d96713 100644 --- a/beantester/gui/toolbox/tcphealth.py +++ b/beantester/gui/toolbox/tcphealth.py @@ -1,13 +1,14 @@ -"""Tools tab: TCP health - what Windows' TCP stack measures for a watched connection. +"""Tools tab: TCP health - what the system's TCP stack measures for a watched connection. The numbers are ``tcpestats``'s; the list, the differences and the words are ``nettools/tcphealth.py``'s. "Does the stack see my 200 ms as 200 ms of round trip, my 5% loss as resent segments?" A connection is put on the list from a row menu - the socket table beside this tool, or the Connections page of a session (the owner's choice, -2026-10-19: no second socket table here). Turning counting on for it needs -administrator rights; without them the tool says so in a line of its own, like -"Clean up the driver" does. +2026-10-19: no second socket table here). On Windows turning counting on for it +needs administrator rights; without them the tool says so in a line of its own, +like "Clean up the driver" does. Linux needs none, and has its own "?" sheet, tips +and chart legend (its stack keeps no newest single round trip). Layout (the owner's choice of the rendered two, 2026-10-19): the list above, the chosen connection's details below with its round trips charted BESIDE them - stacked @@ -20,6 +21,7 @@ after the last one answered: a run waiting behind another is REPLACED by a newer one (``ToolJob``), so asking twice could have lost the first request. """ +import sys import time import tkinter as tk import weakref @@ -44,6 +46,17 @@ "remote": "tools.tcphealth.col.remote", "state": "tools.tcphealth.col.state", "rtt": "tools.tcphealth.col.rtt", "resent": "tools.tcphealth.col.resent"} TIPS = {column: f"tips.tools_tcphealth_col_{column}" for column in COLUMNS} +# What Linux says in its own words: the whole "?" sheet (it is about what Windows +# measures and when), the two column tips and the Stop tip that name Windows. +HELP, HELP_LINUX = "tools.tcphealth.help_body", "tools.tcphealth.help_linux" +TIPS_LINUX = {**TIPS, "state": "tips.tools_tcphealth_col_state_linux", + "resent": "tips.tools_tcphealth_col_resent_linux"} +STOP_TIP, STOP_TIP_LINUX = "tips.tools_tcphealth_stop", "tips.tools_tcphealth_stop_linux" +# The chart's legend: (swatch style, key) of each line, the newest single round trip +# only where the stack keeps one. +LEGEND = (("Swatch.Down.TLabel", "tools.tcphealth.legend_smoothed"), + ("Swatch.Up.TLabel", "tools.tcphealth.legend_sample")) +LEGEND_LINUX = LEGEND[:1] MIN_CHARS = {"process": CHARS["process"], "local": CHARS["address"], "remote": CHARS["address"], "state": CHARS["tcp_state"], "rtt": CHARS["rtt"], "resent": CHARS["resent"]} @@ -70,6 +83,29 @@ def watchlist(app): return _LISTS.setdefault(app, tcphealth.Watchlist()) +def _is_linux(platform=None): + return (platform or sys.platform).startswith("linux") + + +def help_key(platform=None): + """The "?" sheet of this system.""" + return HELP_LINUX if _is_linux(platform) else HELP + + +def tips(platform=None): + """The list's column tips on this system.""" + return TIPS_LINUX if _is_linux(platform) else TIPS + + +def stop_tip(platform=None): + return STOP_TIP_LINUX if _is_linux(platform) else STOP_TIP + + +def legend_lines(platform=None): + """The chart legend's lines on this system.""" + return LEGEND_LINUX if _is_linux(platform) else LEGEND + + def render(watch): return tuple(say(cell) for cell in tcphealth.cells(watch)) @@ -104,8 +140,7 @@ def __init__(self, app, parent): bar = ttk.Frame(self.frame) bar.pack(fill="x", padx=space("page"), pady=(space("row"), 0)) - self.stop_btn = self._button(bar, "tools.tcphealth.stop", "tips.tools_tcphealth_stop", - self.stop) + self.stop_btn = self._button(bar, "tools.tcphealth.stop", stop_tip(), self.stop) self.copy_btn = self._button(bar, "tools.tcphealth.copy_report", "tips.tools_tcphealth_copy", self.copy_report) self.background = tk.BooleanVar(value=self.memory.get("background") == "1") @@ -113,8 +148,7 @@ def __init__(self, app, parent): variable=self.background, command=self._background_ticked) box.pack(side="left", padx=(0, space("inline"))) add_tooltip(box, "tips.tools_tcphealth_background") - dialogs.help_button(bar, app.root, "tools.tcphealth.help_title", - "tools.tcphealth.help_body", + dialogs.help_button(bar, app.root, "tools.tcphealth.help_title", help_key(), "tips.tools_tcphealth_help").pack(side="right") # Asked once: a running process does not gain or lose administrator rights. @@ -132,7 +166,7 @@ def __init__(self, app, parent): top.pack(fill="x", padx=space("page"), pady=(space("tight"), 0)) self.table = SortableTree( top, COLUMNS, sort={"col": "", "reverse": False}, on_sort=lambda _s: self._show(), - height=ROWS["watched"], min_chars=MIN_CHARS, tips=TIPS, tags=HEALTH_COLORS, + height=ROWS["watched"], min_chars=MIN_CHARS, tips=tips(), tags=HEALTH_COLORS, selectmode="browse", empty_text="tools.tcphealth.empty") self.table.bind_select(self._on_select) @@ -146,8 +180,7 @@ def __init__(self, app, parent): # not its legend (rendered 2026-10-19 without the note's room: the legend went). legend = ttk.Frame(box) legend.pack(side="bottom", fill="x", padx=space("tight"), pady=(0, space("tight"))) - for style, key in (("Swatch.Down.TLabel", "tools.tcphealth.legend_smoothed"), - ("Swatch.Up.TLabel", "tools.tcphealth.legend_sample")): + for style, key in legend_lines(): ttk.Label(legend, text=" ", style=style).pack(side="left") ttk.Label(legend, text=T(key)).pack(side="left", padx=(space("tight"), space("row"))) self.canvas = chart_canvas(box, size("side_chart"), size("chart")) diff --git a/beantester/nettools/tcphealth.py b/beantester/nettools/tcphealth.py index e60923d..da5e035 100644 --- a/beantester/nettools/tcphealth.py +++ b/beantester/nettools/tcphealth.py @@ -1,12 +1,15 @@ -"""TCP health: the connections a person watches, and what Windows measured for them. +"""TCP health: the connections a person watches, and what the system measured for them. A connection comes from a row menu - the socket table on this tab, or the Connections page of a session (the owner's choice, 2026-10-19) - and stays on the list until it is taken off, after it has ended too, with its last numbers. The numbers are ``tcpestats``'s; this module keeps them over time and says them: the counters as a difference from the first reading (Learn: counting is never turned -off, so a counter can hold what happened before anyone here watched), the round -trips as they are, and a minute of them for the chart. +off, so a counter can hold what happened before anyone here watched; Linux counts +from the moment a connection opened), the round trips as they are, and a minute of +them for the chart. What a system's stack does not keep (``Sample.absent``) is not +said at all - Linux has no newest single round trip, Windows no count of the +segments sent again. Kept on the UI thread (``Watchlist``); a worker only ever gets a snapshot to work on (``cycle``) and its answer is applied when it lands - so no lock, and a reading @@ -28,7 +31,6 @@ # value only looked current. Three readings: one without a measurement is ordinary (a # quiet second). STALE_S = 3 -_U32, _U64 = 2 ** 32, 2 ** 64 # How asking to watch a connection can end besides being watched. GONE = "gone" # a Connections row whose connection has closed @@ -57,11 +59,11 @@ class Request(NamedTuple): def blocker(): """Why no connection can be watched from this process: an i18n key, "" if one can - (``diagnostics.cleanup_blocker``'s shape). Counting is turned on only with - administrator rights (Learn, measured). ``driver.is_admin``, as the diagnostics - ask it: this module is read by the Tools tab's quiet-tab guard, and ``winenv`` - carries the subprocess of the elevation prompt.""" - if tcpestats.supported() and not driver.is_admin(): + (``diagnostics.cleanup_blocker``'s shape). On Windows counting is turned on only + with administrator rights (Learn, measured); Linux needs none. ``driver.is_admin``, + as the diagnostics ask it: this module is read by the Tools tab's quiet-tab guard, + and ``winenv`` carries the subprocess of the elevation prompt.""" + if tcpestats.needs_rights() and not driver.is_admin(): return "tools.tcphealth.needs_admin" return "" @@ -103,6 +105,7 @@ def __init__(self, connection, process, pid, asked_at): self.code = 0 self.detail = "" self.first = self.previous = self.last = None + self.handle = None # how the system finds it again (``Reading.handle``) self.history = collections.deque(maxlen=HISTORY) # (at, smoothed, sample) # When Windows last measured a round trip, and the bytes sent by then. self.measured_at = None @@ -123,6 +126,8 @@ def take(self, reading, at): reading = tcpestats.Reading(STOPPED) if reading.state == tcpestats.NOT_COUNTING and self.last is not None: reading = tcpestats.Reading(STOPPED) + if reading.handle is not None: + self.handle = reading.handle self.state, self.code, self.detail = reading.state, reading.code, reading.detail if reading.state != tcpestats.OK: return @@ -138,18 +143,25 @@ def take(self, reading, at): sample.smoothed_rtt if sample.sample_rtt is None else sample.sample_rtt)) - def since_watching(self, field, bits=_U32): - """A counter's change since the first reading - modulo its width: Windows' - 32-bit counters wrap.""" + def has(self, field): + """Whether the system's stack keeps ``field`` (``Sample.absent``).""" + return self.last is not None and field not in self.last.absent + + def since_watching(self, field): + """A counter's change since the first reading - modulo its width + (``Sample.widths``): Windows' 32-bit counters wrap, Linux's timeouts at 16.""" if self.last is None: return None - return (getattr(self.last, field) - getattr(self.first, field)) % bits + return (getattr(self.last, field) - getattr(self.first, field)) % _span(self.last, field) def unmeasured_for(self): """Whole seconds since Windows last measured a round trip, when data went out after it and that is ``STALE_S`` or more; else 0. A connection that only receives is not stale: Windows measures on THIS computer's data. Nor is one - no longer read (ended, stopped): its last numbers are final, not old.""" + no longer read (ended, stopped): its last numbers are final, not old. A stack + that keeps no count of its measurements (Linux) is never said to be stale.""" + if not self.has("rtt_samples"): + return 0 if self.state != tcpestats.OK or self.last is None or self.measured_at is None: return 0 waited = round(self.read_at - self.measured_at) @@ -159,7 +171,12 @@ def unmeasured_for(self): def since_previous(self, field): if self.last is None or self.previous is None: return 0 - return (getattr(self.last, field) - getattr(self.previous, field)) % _U32 + return (getattr(self.last, field) - getattr(self.previous, field)) % _span(self.last, field) + + +def _span(sample, field): + """How many values a counter of ``sample`` takes before it wraps.""" + return 2 ** sample.widths.get(field, 32) def _went_back(old, new): @@ -189,7 +206,8 @@ def remove(self, key): self.watches.pop(key, None) def to_read(self): - return [w.connection for w in self.watches.values() if w.reading] + """``(connection, handle)`` of each connection still read.""" + return [(w.connection, w.handle) for w in self.watches.values() if w.reading] def busy(self): """Something to ask Windows: a request to send or a connection to read.""" @@ -221,11 +239,13 @@ class Result(NamedTuple): readings: dict # key -> tcpestats.Reading -def cycle(requests, connections, watch=None, read=None, rows=None): - """On the WORKER: find and turn on what was asked, read what is watched. +def cycle(requests, watched, watch=None, read=None, rows=None): + """On the WORKER: find and turn on what was asked, read what is watched - + ``watched`` being ``Watchlist.to_read``'s pairs. One socket table read for all the Connections rows in this cycle, and none - when there are none. The functions are injectable (tests).""" + when there are none - the system's table (``tcpestats.socket_rows``). The + functions are injectable (tests).""" watch, read = watch or tcpestats.watch, read or tcpestats.read table = None added = [] @@ -233,11 +253,11 @@ def cycle(requests, connections, watch=None, read=None, rows=None): connection, kind = request.connection, WATCHING if connection is None: if table is None: - table = _table(rows or portmap.socket_rows) + table = _table(rows or tcpestats.socket_rows) connection, kind = _find(table, request.target) added.append((request, connection, kind, watch(connection) if kind == WATCHING else None)) - return Result(tuple(added), {key_of(c): read(c) for c in connections}) + return Result(tuple(added), {key_of(c): read(c, handle=handle) for c, handle in watched}) def _table(socket_rows): @@ -350,42 +370,61 @@ def details(watch): last = watch.last if last is None: return out - sent, received = (watch.since_watching("bytes_out", _U64), - watch.since_watching("bytes_in", _U64)) + sent, received = watch.since_watching("bytes_out"), watch.since_watching("bytes_in") unmeasured = watch.unmeasured_for() # The Note column says one thing: that the round trip is not measured. What a row # means is the help's - every other note was cut at the smallest window. - out += [Detail("srtt", Words("tools.tcphealth.item.srtt"), ms(last.smoothed_rtt), - (Words("tools.tcphealth.note.not_measured", unmeasured),) if unmeasured - else (), tag="stale" if unmeasured else ""), - Detail("sample", Words("tools.tcphealth.item.sample"), ms(last.sample_rtt)), - Detail("range", Words("tools.tcphealth.item.range"), - Words("tools.tcphealth.value.no_rtt") - if last.min_rtt is None or last.max_rtt is None - else f"{rtt_number(last.min_rtt)} / {rtt_number(last.max_rtt)} ms"), - Detail("rttvar", Words("tools.tcphealth.item.rttvar"), ms(last.rtt_var)), - Detail("rto", Words("tools.tcphealth.item.rto"), f"{last.rto} ms"), - Detail("resent_bytes", Words("tools.tcphealth.item.resent_bytes"), - _counter(watch, "resent_bytes", " B")), - Detail("timeouts", Words("tools.tcphealth.item.timeouts"), - _counter(watch, "timeouts")), - Detail("fast_resends", Words("tools.tcphealth.item.fast_resends"), - _counter(watch, "fast_resends")), - Detail("dupacks", Words("tools.tcphealth.item.dupacks"), - _counter(watch, "duplicate_acks")), - Detail("mss", Words("tools.tcphealth.item.mss"), f"{last.mss} B"), - Detail("data", Words("tools.tcphealth.item.data"), f"{sent} / {received} B"), - Detail("since", Words("tools.tcphealth.item.since"), - time.strftime("%H:%M:%S", time.localtime(watch.since)))] + rows = [(("smoothed_rtt",), Detail( + "srtt", Words("tools.tcphealth.item.srtt"), ms(last.smoothed_rtt), + (Words("tools.tcphealth.note.not_measured", unmeasured),) if unmeasured + else (), tag="stale" if unmeasured else "")), + (("sample_rtt",), Detail("sample", Words("tools.tcphealth.item.sample"), + ms(last.sample_rtt))), + (("min_rtt", "max_rtt"), Detail( + "range", Words("tools.tcphealth.item.range"), + Words("tools.tcphealth.value.no_rtt") + if last.min_rtt is None or last.max_rtt is None + else f"{rtt_number(last.min_rtt)} / {rtt_number(last.max_rtt)} ms")), + # A stack without the highest round trip (Linux) says the lowest alone. + (("min_rtt", "!max_rtt"), Detail("lowest", Words("tools.tcphealth.item.lowest"), + ms(last.min_rtt))), + (("rtt_var",), Detail("rttvar", Words("tools.tcphealth.item.rttvar"), + ms(last.rtt_var))), + (("rto",), Detail("rto", Words("tools.tcphealth.item.rto"), f"{last.rto} ms")), + (("resent_bytes",), Detail("resent_bytes", Words("tools.tcphealth.item.resent_bytes"), + _counter(watch, "resent_bytes", " B"))), + (("resent_segments",), Detail( + "resent_segments", Words("tools.tcphealth.item.resent_segments"), + _counter(watch, "resent_segments"))), + (("timeouts",), Detail("timeouts", Words("tools.tcphealth.item.timeouts"), + _counter(watch, "timeouts"))), + (("fast_resends",), Detail("fast_resends", Words("tools.tcphealth.item.fast_resends"), + _counter(watch, "fast_resends"))), + (("duplicate_acks",), Detail("dupacks", Words("tools.tcphealth.item.dupacks"), + _counter(watch, "duplicate_acks"))), + (("mss",), Detail("mss", Words("tools.tcphealth.item.mss"), f"{last.mss} B")), + (("bytes_out", "bytes_in"), Detail("data", Words("tools.tcphealth.item.data"), + f"{sent} / {received} B"))] + out += [detail for fields, detail in rows if _kept(watch, fields)] + out.append(Detail("since", Words("tools.tcphealth.item.since"), + time.strftime("%H:%M:%S", time.localtime(watch.since)))) return out +def _kept(watch, fields): + """Whether a row is said: every field it shows is kept, and none of those it stands in + for (``!name``) is.""" + return all(watch.has(f) if not f.startswith("!") else not watch.has(f[1:]) for f in fields) + + def chart(watch, now=None): - """(smoothed, sample, seconds the points span) for the RTT chart.""" + """(smoothed, sample, seconds the points span) for the RTT chart. A stack that keeps + no newest single round trip (Linux) has the smoothed line alone.""" points = list(watch.history) if watch is not None else [] now = time.time() if now is None else now span = round(now - points[0][0]) if points else 0 - return [p[1] for p in points], [p[2] for p in points], span + sample = [p[2] for p in points] if watch is None or watch.has("sample_rtt") else [] + return [p[1] for p in points], sample, span # -- the report (English, like the other tools') ------------------------------------- # @@ -405,23 +444,34 @@ def report_lines(watchlist): if last is None: continue unmeasured = watch.unmeasured_for() - lines.append(f" round trip: smoothed {_rtt(last.smoothed_rtt)}, last " - f"{_rtt(last.sample_rtt)}, lowest {_rtt(last.min_rtt)}, highest " - f"{_rtt(last.max_rtt)}, variation {_rtt(last.rtt_var)}, resend timer " - f"{last.rto} ms, segment size {last.mss} B, measured {last.rtt_samples} " - "times" + (f", none for {unmeasured} s while data went out" - if unmeasured else "")) - lines.append( - " since watching (" - + time.strftime("%H:%M:%S", time.localtime(watch.since)) + "): resent " - f"{watch.since_watching('resent_bytes')} B, timeouts " - f"{watch.since_watching('timeouts')}, fast resends " - f"{watch.since_watching('fast_resends')}, duplicate ACKs " - f"{watch.since_watching('duplicate_acks')}, sent " - f"{watch.since_watching('bytes_out', _U64)} B, received " - f"{watch.since_watching('bytes_in', _U64)} B") + trip = [("smoothed_rtt", f"smoothed {_rtt(last.smoothed_rtt)}"), + ("sample_rtt", f"last {_rtt(last.sample_rtt)}"), + ("min_rtt", f"lowest {_rtt(last.min_rtt)}"), + ("max_rtt", f"highest {_rtt(last.max_rtt)}"), + ("rtt_var", f"variation {_rtt(last.rtt_var)}"), + ("rto", f"resend timer {last.rto} ms"), + ("mss", f"segment size {last.mss} B"), + ("rtt_samples", f"measured {last.rtt_samples} times")] + lines.append(" round trip: " + ", ".join(_said(watch, trip)) + + (f", none for {unmeasured} s while data went out" if unmeasured else "")) + segments = watch.since_watching("resent_segments") + counts = [("resent_bytes", f"resent {watch.since_watching('resent_bytes')} B" + + (f" ({segments} segments)" if watch.has("resent_segments") else "")), + ("timeouts", f"timeouts {watch.since_watching('timeouts')}"), + ("fast_resends", f"fast resends {watch.since_watching('fast_resends')}"), + ("duplicate_acks", f"duplicate ACKs {watch.since_watching('duplicate_acks')}"), + ("bytes_out", f"sent {watch.since_watching('bytes_out')} B"), + ("bytes_in", f"received {watch.since_watching('bytes_in')} B")] + lines.append(" since watching (" + + time.strftime("%H:%M:%S", time.localtime(watch.since)) + "): " + + ", ".join(_said(watch, counts))) return lines +def _said(watch, parts): + """The report's parts for the fields the stack keeps.""" + return [text for field, text in parts if watch.has(field)] + + def report(watchlist): return "\n".join(report_lines(watchlist)) diff --git a/beantester/sockdiag.py b/beantester/sockdiag.py index 7d33987..76cdd54 100644 --- a/beantester/sockdiag.py +++ b/beantester/sockdiag.py @@ -25,6 +25,22 @@ while the id keeps the kernel's form - the kernel finds the socket by it. * Both lists, asked for every state but LISTEN, took 0.2 ms in WSL and about 3 ms on the VM, where ``psutil.net_connections`` took 6.6 and 19 ms. + +TCP health reads one socket at a time (``lookup``): the socket's id from the list, its +``tcp_info`` asked for. MEASURED 2026-10-27 (``internal_tools`` probes +``2026-10-27-linux-tcphealth``, the same VM and WSL with 6.18): + +* ``tcp_info`` is 280 bytes on both, the same bytes ``getsockopt(TCP_INFO)`` gives the + socket's own program, and every field read here equals what ``ss -tin`` printed. An + older kernel sends a shorter one - fields are only ever added at its end - so a field + is read only when it is inside the length (``tcp_info``). +* An ordinary user gets it for root's sockets too. +* 🔴 Asked for by its addresses and ports alone, a connection that has gone is answered + by the LISTENER on its local port, with the listener's numbers. With the cookie the + answer is ENOENT, also for a new connection that took the same ports. +* A socket in TIME_WAIT, or one its program has closed (an orphan in FIN_WAIT2), is + answered without ``tcp_info``: the kernel keeps none for it. +* One lookup took about 0.1 ms, a new netlink socket included. """ import errno import ipaddress @@ -45,7 +61,12 @@ EVERY_STATE = 0xFFFFFFFF # A listener has no far end, so no Connections row can mean one. CONNECTED = EVERY_STATE & ~(1 << TCP_LISTEN) +NO_STATES = 0 # a list of nothing: "is sock_diag for TCP here?" NO_COOKIE = (0xFFFFFFFF, 0xFFFFFFFF) # INET_DIAG_NOCOOKIE: "any socket with this id" +# The attribute of an answer that holds the socket's ``struct tcp_info``, and the bit that +# asks for it (``idiag_ext``: bit N-1 for attribute N). +INET_DIAG_INFO = 2 +INFO = 1 << (INET_DIAG_INFO - 1) # The kernel's TCP states (``include/net/tcp_states.h``) in ``portmap.TCP_STATES``' words, # so a state reads the same on both systems. 12 is the kernel's own: a connection request @@ -62,6 +83,14 @@ MESSAGE = 72 # inet_diag_msg; attributes may follow it _ID_AT = 4 # family, state, timer, retransmits, then the id +# ``struct tcp_info`` (``include/uapi/linux/tcp.h``): the fields TCP health reads, at the +# offsets that header gives them, in the machine's own byte order. Times in microseconds. +TCP_INFO_FIELDS = { + "rto": (8, "I"), "snd_mss": (16, "I"), "rtt": (68, "I"), "rttvar": (72, "I"), + "total_retrans": (100, "I"), "bytes_received": (128, "Q"), "min_rtt": (148, "I"), + "bytes_sent": (200, "Q"), "bytes_retrans": (208, "Q"), "total_rto": (240, "H")} +_TCP_INFO = {name: (at, struct.Struct("=" + kind)) for name, (at, kind) in TCP_INFO_FIELDS.items()} + class Socket(NamedTuple): """One TCP socket in the kernel's list. The first five fields are what @@ -82,9 +111,10 @@ def sockid(sport=0, dport=0, src=b"", dst=b"", interface=0, cookie=NO_COOKIE): + _TAIL.pack(interface, *cookie)) -def request(kind, flags, family, sid, states=EVERY_STATE): - """One ``inet_diag_req_v2`` for TCP, in its netlink header.""" - body = _REQUEST.pack(family, IPPROTO_TCP, 0, 0, states) + sid +def request(kind, flags, family, sid, states=EVERY_STATE, extensions=0): + """One ``inet_diag_req_v2`` for TCP, in its netlink header. ``extensions``: the + attributes asked for besides the message (``INFO``).""" + body = _REQUEST.pack(family, IPPROTO_TCP, extensions, 0, states) + sid return netlink.message(kind, flags, body) @@ -130,3 +160,42 @@ def tcp_sockets(talk=exchange): raise OSError(code, "sock_diag: the kernel refused the list") found += [_socket(payload) for kind, payload in replies if kind == SOCK_DIAG_BY_FAMILY] return found + + +def lists(talk=exchange): + """Whether this kernel lists TCP sockets with their ``tcp_info``: a list of no state at + all, which needs no rights and walks nothing. A kernel without sock_diag's TCP part + refuses it; one without sock_diag at all fails to open the socket (OSError).""" + return error_of(talk(request(SOCK_DIAG_BY_FAMILY, NLM_F_REQUEST | NLM_F_DUMP, LINUX_AF_INET, + sockid(), NO_STATES, INFO))) == 0 + + +class Found(NamedTuple): + """The kernel's answer for one socket: its state, and its ``tcp_info`` - None for a + socket that keeps none (TIME_WAIT, or one whose program has closed it).""" + state: str + info: bytes | None + + +def lookup(kernel_family, sid, talk=exchange): + """The one socket ``sid`` names, with its ``tcp_info``: ``(0, Found)``, or the errno the + kernel answered and None - ENOENT when there is no such socket. ``sid`` carries the + cookie the list gave: without it a connection that has gone is answered by the + listener on its port.""" + replies = talk(request(SOCK_DIAG_BY_FAMILY, NLM_F_REQUEST | NLM_F_ACK, kernel_family, sid, + EVERY_STATE, INFO)) + error = error_of(replies) + if error: + return error, None + answers = [payload for kind, payload in replies if kind == SOCK_DIAG_BY_FAMILY] + if len(answers) != 1: + raise OSError(errno.EBADMSG, f"sock_diag: {len(answers)} sockets for one id") + info = [a.value for a in netlink.attributes(answers[0], MESSAGE) if a.kind == INET_DIAG_INFO] + return 0, Found(_socket(answers[0]).state, info[0] if info else None) + + +def tcp_info(raw): + """The fields of ``TCP_INFO_FIELDS`` that ``raw`` holds, by name: a field past the end + of an older kernel's shorter struct is left out, never read as 0.""" + return {name: shape.unpack_from(raw, at)[0] for name, (at, shape) in _TCP_INFO.items() + if at + shape.size <= len(raw)} diff --git a/beantester/tcpestats.py b/beantester/tcpestats.py index e7da184..19b2648 100644 --- a/beantester/tcpestats.py +++ b/beantester/tcpestats.py @@ -1,4 +1,5 @@ -"""What Windows' TCP stack measures for one connection - the Tools tab's TCP health. +"""What the system's TCP stack measures for one connection - the Tools tab's TCP health. +Windows first; Linux at the end of this text. "Does the stack see my 200 ms latency as 200 ms of round trip, and my 5% loss as data sent again?" A second opinion on the impairments, from the side of the program @@ -57,12 +58,41 @@ session's end), so resends are said in bytes and the segment count is not read. * ``DataBytesOut`` / ``DataBytesIn`` count data sent again and received twice too (19 800 B for 15 000 B the application sent). + +On Linux the kernel keeps ``struct tcp_info`` for every TCP socket, from the moment it +opened, and gives it through sock_diag (``sockdiag.lookup``) - nothing is turned on, and +no rights are needed, also for another user's connection. MEASURED 2026-10-27 +(``internal_tools`` probes ``2026-10-27-linux-tcphealth``: Ubuntu 26.04.1 with kernel 7.0, +WSL with 6.18), and the reason for each rule: + +* A connection is found once in the kernel's list (``sockdiag.tcp_sockets``) and read + after that by the id the list gave, its cookie in it (``Handle``): asked by its numbers + alone, a connection that has gone is answered by the listener on its port. No such + socket (ENOENT), TIME_WAIT, and a socket its program has closed (answered without + ``tcp_info``) are all "ended". +* Round trips are kept in microseconds; they are said in whole milliseconds here, as + Windows and Ping say them. A round trip of 0 is none measured yet. +* It measured on through a round trip that jumped from 0.4 to 200 ms on an open + connection (an echo every 100 ms, ``netem`` on a veth of a namespace of the probe's + own), with TCP timestamps on and off: the smoothed value was at 198 ms after 6 s - no + stall like Windows' above. It keeps no count of its measurements, so "not measured + for N s" is never said for it. +* It keeps no newest single measurement, no highest round trip, no count of fast resends + or duplicate ACKs (``LINUX_ABSENT``); its lowest round trip is the lowest of a window + (``net.ipv4.tcp_min_rtt_wlen``, 300 s by default). Timeouts are counted from kernel 6.7 + on and count every expiry of the resend timer, the first one or not. +* It counts the segments sent again (``total_retrans``), which Windows never fills: 120 + segments of 1448 B for 173 760 resent bytes through 5% loss, and the resent bytes + equal the bytes sent minus the bytes acknowledged to the byte. The bytes received do + not count data received twice. """ import ctypes +import ipaddress import sys -from typing import NamedTuple +import types +from typing import Mapping, NamedTuple -from . import crashlog, tcprow, winenv +from . import crashlog, portmap, sockdiag, tcprow, winenv # TCP_ESTATS_TYPE (Learn, tcpestats.h). DATA, PATH = 1, 3 @@ -110,29 +140,47 @@ class _DataRod(ctypes.Structure): ("RcvNxt", ctypes.c_uint32), ("ThruBytesReceived", ctypes.c_uint64)] +# What a system's stack does not keep, by ``Sample`` field. Such a field is never shown: +# a round trip of None is otherwise "not measured yet", a counter of 0 "nothing happened". +WINDOWS_ABSENT = frozenset({"resent_segments"}) # PktsRetrans: never filled (above) +LINUX_ABSENT = frozenset({"sample_rtt", "max_rtt", "fast_resends", "duplicate_acks", + "rtt_samples"}) +# Each counter's width in bits: a difference of two readings is taken modulo it. +WINDOWS_WIDTHS = types.MappingProxyType({ + "resent_bytes": 32, "timeouts": 32, "fast_resends": 32, "duplicate_acks": 32, + "resent_segments": 32, "bytes_out": 64, "bytes_in": 64}) +LINUX_WIDTHS = types.MappingProxyType({ + "resent_bytes": 64, "timeouts": 16, "fast_resends": 32, "duplicate_acks": 32, + "resent_segments": 32, "bytes_out": 64, "bytes_in": 64}) + + class Sample(NamedTuple): - """One reading's numbers. Round trips in ms, None while Windows has none.""" + """One reading's numbers. Round trips in ms, None while the system has none.""" smoothed_rtt: int | None sample_rtt: int | None min_rtt: int | None max_rtt: int | None rtt_var: int | None rto: int # the resend timer, ms - resent_bytes: int # counters: since counting was turned on, 32-bit + resent_bytes: int # counters: since counting was turned on (``widths``) timeouts: int fast_resends: int duplicate_acks: int mss: int - bytes_out: int # 64-bit counters + bytes_out: int bytes_in: int rtt_samples: int = 0 # CountRtt: how many round trips Windows has measured + resent_segments: int = 0 # Linux's total_retrans + absent: frozenset = WINDOWS_ABSENT + widths: Mapping = WINDOWS_WIDTHS class Reading(NamedTuple): state: str sample: Sample | None = None - code: int = 0 # what Windows answered, when it said no + code: int = 0 # what the system answered, when it said no detail: str = "" # FAILED: the exception, program text + handle: object = None # how the system finds this connection again (Linux) def _sample(path, data): @@ -175,9 +223,28 @@ def _declared(lib): def supported(): + """Windows has the EStats calls; Linux has ``tcp_info`` when its kernel lists TCP + sockets through sock_diag (``_Linux.can_list``). Asked through ``capabilities``.""" + if sys.platform.startswith("win"): + return True + return sys.platform.startswith("linux") and _Linux().can_list() + + +def needs_rights(): + """Whether watching needs administrator rights here: on Windows turning counting on + does (above); Linux counts for every socket and lets any user read it.""" return sys.platform.startswith("win") +def socket_rows(): + """The socket table a Connections row is looked up in, as ``portmap.socket_rows`` + answers. On Linux the kernel's own list: it writes a socket opened as IPv6 for an + IPv4 address with the IPv4 one, as the row does, where psutil writes ``::ffff:``.""" + if sys.platform.startswith("linux"): + return _Linux().rows() + return portmap.socket_rows() + + class _System: """What a reading asks of Windows. Injectable, so it all runs in tests anywhere.""" @@ -185,6 +252,12 @@ def __init__(self, iphlpapi=None, is_admin=None): self.iphlpapi = iphlpapi if iphlpapi is not None else _iphlpapi() self.is_admin = is_admin or winenv.is_admin + def watch(self, connection): + return _watch(tcprow.row_for(connection), self) + + def read(self, connection, _handle=None): + return _read(tcprow.row_for(connection), self) + def enable(self, row, kind): """Turn counting ON (never off - Learn). Version 0 and offset 0, as Learn says.""" call = (self.iphlpapi.SetPerTcpConnectionEStats if isinstance(row, tcprow.TcpRow) @@ -229,19 +302,141 @@ def _watch(row, system): return _read(row, system) -def _guard(work, connection, system): +# -- Linux --------------------------------------------------------------------------- # +US_PER_MS = 1000 +ENOENT = 2 +# Answers that hold no connection to read: TIME_WAIT and CLOSED keep no tcp_info, and a +# LISTEN answer is the listener on the port, never the connection (the cookie prevents +# it; this is the second lock). +_NOT_OPEN = frozenset({"TIME_WAIT", "CLOSED", "LISTEN"}) +# Sample field -> the tcp_info field it is read from: one an older kernel's shorter struct +# lacks goes to ``absent`` for that reading. +_FROM_KERNEL = {"smoothed_rtt": "rtt", "min_rtt": "min_rtt", "rtt_var": "rttvar", "rto": "rto", + "resent_bytes": "bytes_retrans", "timeouts": "total_rto", "mss": "snd_mss", + "bytes_out": "bytes_sent", "bytes_in": "bytes_received", + "resent_segments": "total_retrans"} + + +class Handle(NamedTuple): + """A socket's id in the kernel's words: the list it is in and ``inet_diag_sockid`` + with the cookie.""" + kernel_family: int + sockid: bytes + + +def _bare(ip): + """An address as one value: without its zone, an IPv4 address mapped into IPv6 as + the IPv4 one (psutil writes a dual-stack socket that way, the kernel's list not).""" + address = ipaddress.ip_address(str(ip).split("%")[0]) + return getattr(address, "ipv4_mapped", None) or address + + +def _same(socket, connection): + try: + return (socket.local_port == connection.local_port + and socket.remote_port == connection.remote_port + and _bare(socket.local_ip) == _bare(connection.local_ip) + and _bare(socket.remote_ip) == _bare(connection.remote_ip)) + except ValueError: + return False + + +def _ms(value): + return value // US_PER_MS + + +def _linux_sample(info): + """A ``Sample`` from ``sockdiag.tcp_info``'s fields. Whole milliseconds, rounded down, + so "< 1" means under one; a round trip of 0 is none measured yet, and then neither + is its variation (the kernel starts it at 250 ms before any measurement).""" + measured = bool(info.get("rtt")) + + def rtt(name): + value = info.get(name) + return _ms(value) if measured and value is not None and value != UNSET else None + absent = LINUX_ABSENT | {field for field, name in _FROM_KERNEL.items() if name not in info} + return Sample(rtt("rtt"), None, rtt("min_rtt"), None, rtt("rttvar"), + _ms(info.get("rto", 0)), info.get("bytes_retrans", 0), + info.get("total_rto", 0), 0, 0, info.get("snd_mss", 0), + info.get("bytes_sent", 0), info.get("bytes_received", 0), + resent_segments=info.get("total_retrans", 0), absent=frozenset(absent), + widths=LINUX_WIDTHS) + + +class _Linux: + """What a reading asks of Linux: the kernel's list (``sockdiag``) to find a + connection once, then that one socket's ``tcp_info`` by its id. ``talk`` sends one + request as ``sockdiag.exchange`` does - a test brings its own.""" + + def __init__(self, talk=None): + self.talk = talk or sockdiag.exchange + + def rows(self): + try: + return sockdiag.tcp_sockets(self.talk), [] + except OSError as exc: + raise portmap.SocketTableUnavailable("missing", str(exc)) from exc + + def lookup(self, handle): + return sockdiag.lookup(handle.kernel_family, handle.sockid, self.talk) + + def can_list(self): + try: + return sockdiag.lists(self.talk) + except OSError: + return False # no sock_diag at all, or netlink refused to this process + + def watch(self, connection): + """Find ``connection`` in the kernel's list by its four numbers, then read it. + Two sockets with them (a link-local address on two links) are not guessed at. A + list the kernel would not give is said in its words - not a fault of the program.""" + try: + rows, _failed = self.rows() + except portmap.SocketTableUnavailable as exc: + return Reading(FAILED, detail=str(exc)) + found = [s for s in rows if _same(s, connection)] + if not found: + return Reading(ENDED, code=ENOENT) + if len(found) > 1: + return Reading(FAILED, detail=f"{len(found)} sockets have these addresses and ports") + return self.read(connection, Handle(found[0].kernel_family, found[0].sockid)) + + def read(self, connection, handle): + """Read the socket ``handle`` names. A watch that found none to read (a list the + kernel would not give, two sockets with its numbers) carried no handle: it is + looked for again, as Windows asks again after a failure.""" + if handle is None: + return self.watch(connection) + code, found = self.lookup(handle) + if code == ENOENT or (code == 0 and (found.info is None or found.state in _NOT_OPEN)): + return Reading(ENDED, code=code, handle=handle) + if code: + return Reading(FAILED, code=code, detail=f"sock_diag answered errno {code}", + handle=handle) + return Reading(OK, _linux_sample(sockdiag.tcp_info(found.info)), handle=handle) + + +def _system_class(): + """The system a reading asks here. Off Windows and Linux it is never reached: + ``supported`` says no first.""" + return _Linux if sys.platform.startswith("linux") else _System + + +def _guard(work): try: - return work(tcprow.row_for(connection), system or _System()) + return work() except Exception as exc: crashlog.note(exc, "tcpestats") return Reading(FAILED, detail=f"{type(exc).__name__}: {exc}") def watch(connection, system=None): - """Turn counting on for ``connection`` and read it. On a worker; never raises.""" - return _guard(_watch, connection, system) + """Turn counting on for ``connection`` (Windows; Linux always counts) and read it. + On a worker; never raises.""" + return _guard(lambda: (system or _system_class()()).watch(connection)) -def read(connection, system=None): - """Read ``connection``; counting must have been turned on. Never raises.""" - return _guard(_read, connection, system) +def read(connection, system=None, handle=None): + """Read ``connection``: counting turned on (Windows), or the socket ``handle`` names - + the one its watch reading carried (Linux). Never raises.""" + return _guard(lambda: (system or _system_class()()).read(connection, handle)) diff --git a/lang/en.json b/lang/en.json index b34a187..019047e 100644 --- a/lang/en.json +++ b/lang/en.json @@ -727,12 +727,15 @@ "tips.tools_tcphealth_col_process": "The program that owns the connection.", "tips.tools_tcphealth_col_remote": "The other end's address and port.", "tips.tools_tcphealth_col_resent": "Bytes Windows sent again since watching began.", + "tips.tools_tcphealth_col_resent_linux": "Bytes Linux sent again since watching began.", "tips.tools_tcphealth_col_rtt": "The smoothed round-trip time. < 1 ms: under a millisecond.", "tips.tools_tcphealth_col_state": "Open while Windows counts. Ended when the connection has closed.", + "tips.tools_tcphealth_col_state_linux": "Open while the connection is open. Ended when it has closed.", "tips.tools_tcphealth_col_value": "The newest value. In brackets: how much a counter grew since the last read.", "tips.tools_tcphealth_copy": "Copies the numbers of every connection on the list, in English. The text contains this computer's addresses.", "tips.tools_tcphealth_help": "What the numbers mean", "tips.tools_tcphealth_stop": "Takes the chosen connection off the list. Windows keeps counting for it until it closes.", + "tips.tools_tcphealth_stop_linux": "Takes the chosen connection off the list. Linux keeps counting for it until it closes.", "tips.up_limit": "Max throughput of OUTGOING traffic (upload) in KB/s. 0 = unlimited.", "tools.adapters.adapter_logged": "{name} details", "tools.adapters.col.internet": "internet", @@ -1050,16 +1053,19 @@ "tools.tcphealth.empty": "No connection is watched. Right-click a TCP connection on the Sockets tab or the Connections page and choose Show TCP health.", "tools.tcphealth.empty_details": "Choose a connection above.", "tools.tcphealth.help_body": "What Windows measures for one TCP connection: how long a round trip takes and how much data has to be sent again.\n\nWindows measures a round trip only from data it sent once. When the round trip of a connection that is already open suddenly grows by about 200 ms or more - as when you start a session on it - Windows sends each piece of data again after about 200 ms, before the confirmation can arrive, and measures nothing until the delay goes down. The details then say for how long the round trip has not been measured, and the connection's row is highlighted. A connection opened while the session runs is measured exactly. So start the session first, then open the connection (reload the page, reconnect).\n\nTo watch a connection, right-click it on the Sockets tab or on the Connections page and choose Show TCP health. It stays on the list until you press Stop watching, also after it has ended, with its last numbers. Up to 20 connections.\n\nWatching needs administrator rights: Windows counts these numbers for a connection only after a program with those rights asks it to, and then counts until the connection closes. Nothing is sent.\n\nThe numbers are read once a second while this tool is on screen. Tick Keep reading when hidden to read them while you work on another page. The counters count from the moment watching began, and a number in brackets is how much a counter grew since the last read.\n\nRows:\n RTT - round-trip time: how long the other end takes to confirm data. Smoothed is the average Windows keeps, last is the newest measurement. Windows measures whole milliseconds, so < 1 ms is a round trip shorter than one.\n Resend timer - how long Windows waits before it sends data again. Timeouts - how often that timer ran out.\n Resent bytes - data Windows sent again: lost, not confirmed in time, or sent again before its confirmation could arrive.\n Fast resends - data sent again at once because the other end reported a gap.\n Duplicate ACKs - confirmations the other end repeated, a sign that a packet was lost or came out of order.\n Segment size (MSS) - the largest piece of data in one packet.\n Data sent / received - every byte, data sent again included.\n\nWindows measures a round trip when data from this computer is confirmed, so a connection that only receives has no measurements. No longer counted means the connection's port was taken by a new connection, or another program turned counting off.\n\nRight-click the remote address in the details to limit the impairments to it. Copy report copies every watched connection, in English.", + "tools.tcphealth.help_linux": "What Linux measures for one TCP connection: how long a round trip takes and how much data has to be sent again.\n\nThe Linux kernel keeps these numbers for every TCP connection from the moment it opens, and any user may read them: watching needs no sudo, and nothing is sent.\n\nTo watch a connection, right-click it on the Sockets tab or on the Connections page and choose Show TCP health. It stays on the list until you press Stop watching, also after it has ended, with its last numbers. Up to 20 connections.\n\nThe numbers are read once a second while this tool is on screen. Tick Keep reading when hidden to read them while you work on another page. The counters count from the moment watching began, and a number in brackets is how much a counter grew since the last read.\n\nRows:\n RTT - round-trip time: how long the other end takes to confirm data. Smoothed is the average the kernel keeps. Lowest is the shortest round trip of the last 5 minutes. Whole milliseconds are shown, so < 1 ms is a round trip shorter than one.\n Resend timer - how long Linux waits before it sends data again. Timeouts - how often that timer ran out, the first time and each time after. Linux counts them from 6.7 on, and with an older kernel this row is not shown.\n Resent bytes and Resent segments - data Linux sent again: lost, not confirmed in time, or sent again before its confirmation could arrive.\n Segment size (MSS) - the largest piece of data in one packet.\n Data sent / received - every byte sent, data sent again included, and every byte received once.\n\nEnded means the connection has closed, or the program that had it closed it.\n\nRight-click the remote address in the details to limit the impairments to it. Copy report copies every watched connection, in English.", "tools.tcphealth.help_title": "TCP health", "tools.tcphealth.item.data": "Data sent / received", "tools.tcphealth.item.dupacks": "Duplicate ACKs received", "tools.tcphealth.item.fast_resends": "Fast resends", "tools.tcphealth.item.local": "Local address", + "tools.tcphealth.item.lowest": "RTT, lowest", "tools.tcphealth.item.mss": "Segment size (MSS)", "tools.tcphealth.item.process": "Program", "tools.tcphealth.item.range": "RTT, lowest / highest", "tools.tcphealth.item.remote": "Remote address", "tools.tcphealth.item.resent_bytes": "Resent bytes", + "tools.tcphealth.item.resent_segments": "Resent segments", "tools.tcphealth.item.rto": "Resend timer", "tools.tcphealth.item.rttvar": "RTT variation", "tools.tcphealth.item.sample": "RTT, last", diff --git a/lang/pl.json b/lang/pl.json index ad97bf8..302ed12 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -727,12 +727,15 @@ "tips.tools_tcphealth_col_process": "Program, do którego należy połączenie.", "tips.tools_tcphealth_col_remote": "Adres i port drugiej strony.", "tips.tools_tcphealth_col_resent": "Bajty, które Windows wysłał ponownie od początku obserwacji.", + "tips.tools_tcphealth_col_resent_linux": "Bajty, które Linux wysłał ponownie od początku obserwacji.", "tips.tools_tcphealth_col_rtt": "Wygładzony czas w obie strony. < 1 ms: poniżej milisekundy.", "tips.tools_tcphealth_col_state": "Otwarte, dopóki Windows liczy. Zakończone, gdy połączenie się zamknęło.", + "tips.tools_tcphealth_col_state_linux": "Otwarte, dopóki połączenie jest otwarte. Zakończone, gdy się zamknęło.", "tips.tools_tcphealth_col_value": "Najnowsza wartość. W nawiasie: o ile licznik urósł od poprzedniego odczytu.", "tips.tools_tcphealth_copy": "Kopiuje liczby wszystkich połączeń z listy, po angielsku. Tekst zawiera adresy tego komputera.", "tips.tools_tcphealth_help": "Co znaczą te liczby", "tips.tools_tcphealth_stop": "Usuwa wybrane połączenie z listy. Windows liczy dla niego dalej, aż się zamknie.", + "tips.tools_tcphealth_stop_linux": "Usuwa wybrane połączenie z listy. Linux liczy dla niego dalej, aż się zamknie.", "tips.up_limit": "Maks. przepustowość ruchu WYCHODZĄCEGO (wysyłanie) w KB/s. 0 = bez limitu.", "tools.adapters.adapter_logged": "szczegóły „{name}”", "tools.adapters.col.internet": "internet", @@ -1050,16 +1053,19 @@ "tools.tcphealth.empty": "Żadne połączenie nie jest obserwowane. Kliknij prawym przyciskiem połączenie TCP na karcie Gniazda albo na stronie Połączenia i wybierz Pokaż zdrowie TCP.", "tools.tcphealth.empty_details": "Wybierz połączenie powyżej.", "tools.tcphealth.help_body": "Co Windows mierzy dla jednego połączenia TCP: ile trwa droga w obie strony i ile danych trzeba wysłać ponownie.\n\nWindows mierzy drogę w obie strony tylko na danych wysłanych raz. Gdy czas już otwartego połączenia nagle wydłuży się o około 200 ms lub więcej - na przykład gdy uruchomisz na nim sesję - Windows po około 200 ms wysyła każdy kawałek danych jeszcze raz, zanim potwierdzenie zdąży przyjść, i nic nie mierzy, dopóki opóźnienie nie spadnie. Szczegóły mówią wtedy, od ilu sekund czasu nie zmierzono, a wiersz połączenia jest wyróżniony. Połączenie otwarte w trakcie sesji jest mierzone dokładnie. Dlatego najpierw uruchom sesję, a potem otwórz połączenie (odśwież stronę, połącz się ponownie).\n\nŻeby obserwować połączenie, kliknij je prawym przyciskiem na karcie Gniazda albo na stronie Połączenia i wybierz Pokaż zdrowie TCP. Zostaje na liście, dopóki nie naciśniesz Przestań obserwować, także po zakończeniu, z ostatnimi liczbami. Najwyżej 20 połączeń.\n\nObserwowanie wymaga uprawnień administratora: Windows liczy te dane dla połączenia dopiero wtedy, gdy poprosi o to program z takimi uprawnieniami, i liczy do zamknięcia połączenia. Nic nie jest wysyłane.\n\nLiczby są odczytywane co sekundę, gdy to narzędzie jest na ekranie. Zaznacz Odczytuj też w tle, żeby odczytywać je, gdy pracujesz na innej stronie. Liczniki liczą od początku obserwacji, a liczba w nawiasie mówi, o ile licznik urósł od poprzedniego odczytu.\n\nWiersze:\n RTT - czas w obie strony: ile druga strona potrzebuje, żeby potwierdzić dane. Wygładzony to średnia, którą prowadzi Windows, ostatni to najnowszy pomiar. Windows mierzy w pełnych milisekundach, więc < 1 ms to czas krótszy niż jedna.\n Zegar ponowień - ile Windows czeka, zanim wyśle dane jeszcze raz. Przekroczenia czasu - ile razy ten zegar się skończył.\n Ponowione bajty - dane, które Windows wysłał jeszcze raz: zgubione, niepotwierdzone na czas albo wysłane ponownie, zanim potwierdzenie mogło przyjść.\n Szybkie ponowienia - dane wysłane od razu jeszcze raz, bo druga strona zgłosiła lukę.\n Powtórzone ACK - potwierdzenia, które druga strona powtórzyła, znak, że pakiet zginął albo przyszedł nie po kolei.\n Rozmiar segmentu (MSS) - największy kawałek danych w jednym pakiecie.\n Dane wysłane / odebrane - wszystkie bajty, razem z danymi wysłanymi ponownie.\n\nWindows mierzy drogę w obie strony, gdy dane z tego komputera zostaną potwierdzone, więc połączenie, które tylko odbiera, nie ma pomiarów. Już nieliczone znaczy, że port połączenia zajęło nowe połączenie albo inny program wyłączył liczenie.\n\nKliknij prawym przyciskiem adres zdalny w szczegółach, żeby ograniczyć do niego zakłócenia. Kopiuj raport kopiuje wszystkie obserwowane połączenia, po angielsku.", + "tools.tcphealth.help_linux": "Co Linux mierzy dla jednego połączenia TCP: ile trwa droga w obie strony i ile danych trzeba wysłać ponownie.\n\nJądro Linuksa prowadzi te dane dla każdego połączenia TCP od chwili jego otwarcia i każdy użytkownik może je odczytać: obserwowanie nie wymaga sudo i nic nie jest wysyłane.\n\nŻeby obserwować połączenie, kliknij je prawym przyciskiem na karcie Gniazda albo na stronie Połączenia i wybierz Pokaż zdrowie TCP. Zostaje na liście, dopóki nie naciśniesz Przestań obserwować, także po zakończeniu, z ostatnimi liczbami. Najwyżej 20 połączeń.\n\nLiczby są odczytywane co sekundę, gdy to narzędzie jest na ekranie. Zaznacz Odczytuj też w tle, żeby odczytywać je, gdy pracujesz na innej stronie. Liczniki liczą od początku obserwacji, a liczba w nawiasie mówi, o ile licznik urósł od poprzedniego odczytu.\n\nWiersze:\n RTT - czas w obie strony: ile druga strona potrzebuje, żeby potwierdzić dane. Wygładzony to średnia, którą prowadzi jądro. Najniższy to najkrótszy czas z ostatnich 5 minut. Pokazywane są pełne milisekundy, więc < 1 ms to czas krótszy niż jedna.\n Zegar ponowień - ile Linux czeka, zanim wyśle dane jeszcze raz. Przekroczenia czasu - ile razy ten zegar się skończył, za pierwszym razem i za każdym kolejnym. Linux liczy je od wersji 6.7, a ze starszym jądrem tego wiersza nie ma.\n Ponowione bajty i Ponowione segmenty - dane, które Linux wysłał jeszcze raz: zgubione, niepotwierdzone na czas albo wysłane ponownie, zanim potwierdzenie mogło przyjść.\n Rozmiar segmentu (MSS) - największy kawałek danych w jednym pakiecie.\n Dane wysłane / odebrane - wszystkie wysłane bajty, razem z danymi wysłanymi ponownie, i każdy bajt odebrany raz.\n\nZakończone znaczy, że połączenie się zamknęło albo zamknął je program, który je miał.\n\nKliknij prawym przyciskiem adres zdalny w szczegółach, żeby ograniczyć do niego zakłócenia. Kopiuj raport kopiuje wszystkie obserwowane połączenia, po angielsku.", "tools.tcphealth.help_title": "Zdrowie TCP", "tools.tcphealth.item.data": "Dane wysłane / odebrane", "tools.tcphealth.item.dupacks": "Odebrane powtórzone ACK", "tools.tcphealth.item.fast_resends": "Szybkie ponowienia", "tools.tcphealth.item.local": "Adres lokalny", + "tools.tcphealth.item.lowest": "RTT najniższy", "tools.tcphealth.item.mss": "Rozmiar segmentu (MSS)", "tools.tcphealth.item.process": "Program", "tools.tcphealth.item.range": "RTT najniższy / najwyższy", "tools.tcphealth.item.remote": "Adres zdalny", "tools.tcphealth.item.resent_bytes": "Ponowione bajty", + "tools.tcphealth.item.resent_segments": "Ponowione segmenty", "tools.tcphealth.item.rto": "Zegar ponowień", "tools.tcphealth.item.rttvar": "Zmienność RTT", "tools.tcphealth.item.sample": "RTT ostatni", diff --git a/lang/zh.json b/lang/zh.json index 1d8fbb0..34ecead 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -727,12 +727,15 @@ "tips.tools_tcphealth_col_process": "拥有此连接的程序。", "tips.tools_tcphealth_col_remote": "对端的地址和端口。", "tips.tools_tcphealth_col_resent": "从开始观察以来 Windows 重传的字节数。", + "tips.tools_tcphealth_col_resent_linux": "从开始观察以来 Linux 重传的字节数。", "tips.tools_tcphealth_col_rtt": "平滑往返时间。< 1 ms:不到一毫秒。", "tips.tools_tcphealth_col_state": "Windows 统计期间显示“已建立”。连接关闭后显示“已结束”。", + "tips.tools_tcphealth_col_state_linux": "连接打开期间显示“已建立”。连接关闭后显示“已结束”。", "tips.tools_tcphealth_col_value": "最新的值。括号中:计数器自上次读取以来增加了多少。", "tips.tools_tcphealth_copy": "复制列表中所有连接的数据(英文)。文本包含本机的地址。", "tips.tools_tcphealth_help": "这些数字的含义", "tips.tools_tcphealth_stop": "将所选连接从列表中移除。Windows 会继续为它统计,直到连接关闭。", + "tips.tools_tcphealth_stop_linux": "将所选连接从列表中移除。Linux 会继续为它统计,直到连接关闭。", "tips.up_limit": "出站流量(上传)的最大吞吐量,单位为 KB/s。0 = 不限制。", "tools.adapters.adapter_logged": "“{name}”的详细信息", "tools.adapters.col.internet": "互联网", @@ -1050,16 +1053,19 @@ "tools.tcphealth.empty": "没有正在观察的连接。在“套接字”选项卡或“连接”页面右键单击一个 TCP 连接,然后选择“显示 TCP 健康状况”。", "tools.tcphealth.empty_details": "请在上方选择一个连接。", "tools.tcphealth.help_body": "Windows 为一个 TCP 连接测得的数据:一次往返需要多长时间,以及有多少数据需要重发。\n\nWindows 只用发送过一次的数据测量往返时间。当一个已打开连接的往返时间突然增加约 200 毫秒或更多时(例如在它上面启动会话时),Windows 会在约 200 毫秒后再次发送每一段数据,而此时确认还来不及到达,于是在延迟降低之前不进行任何测量。此时详细信息会显示已有多少秒没有测量,该连接所在的行也会突出显示。在会话进行中打开的连接会被准确测量。因此请先启动会话,再打开连接(刷新页面、重新连接)。\n\n要观察一个连接,请在“套接字”选项卡或“连接”页面右键单击它,然后选择“显示 TCP 健康状况”。它会一直留在列表中,直到你按下“停止观察”,连接结束后也会保留最后的数据。最多 20 个连接。\n\n观察需要管理员权限:只有具有该权限的程序提出请求后,Windows 才会为连接统计这些数据,并一直统计到连接关闭。不会发送任何内容。\n\n当此工具显示在屏幕上时,每秒读取一次数据。勾选“隐藏时继续读取”,可以在你使用其他页面时继续读取。计数器从开始观察时计算,括号中的数字表示计数器自上次读取以来增加了多少。\n\n各行:\n RTT - 往返时间:对端确认数据所需的时间。平滑值是 Windows 维护的平均值,最近值是最新的一次测量。Windows 以整毫秒计,因此 < 1 ms 表示不到一毫秒。\n 重传计时器 - Windows 再次发送数据前等待的时间。超时 - 该计时器到期的次数。\n 重传的字节 - Windows 再次发送的数据:丢失的、未及时确认的,或在确认到达之前就再次发送的。\n 快速重传 - 因对端报告缺口而立即再次发送的数据。\n 重复 ACK - 对端重复发送的确认,表示有数据包丢失或乱序到达。\n 分段大小 (MSS) - 一个数据包中最大的数据块。\n 已发送 / 已接收数据 - 所有字节,包括再次发送的数据。\n\nWindows 在本机发出的数据被确认时测量往返时间,因此只接收数据的连接没有测量。“已停止统计”表示连接的端口被新连接占用,或其他程序关闭了统计。\n\n在详细信息中右键单击远程地址,可以将网络损伤限制到该地址。“复制报告”会复制所有观察中的连接(英文)。", + "tools.tcphealth.help_linux": "Linux 为一个 TCP 连接测得的数据:一次往返需要多长时间,以及有多少数据需要重发。\n\nLinux 内核从每个 TCP 连接打开时起就为其维护这些数据,任何用户都可以读取:观察不需要 sudo,也不会发送任何内容。\n\n要观察一个连接,请在“套接字”选项卡或“连接”页面右键单击它,然后选择“显示 TCP 健康状况”。它会一直留在列表中,直到你按下“停止观察”,连接结束后也会保留最后的数据。最多 20 个连接。\n\n当此工具显示在屏幕上时,每秒读取一次数据。勾选“隐藏时继续读取”,可以在你使用其他页面时继续读取。计数器从开始观察时计算,括号中的数字表示计数器自上次读取以来增加了多少。\n\n各行:\n RTT - 往返时间:对端确认数据所需的时间。平滑值是内核维护的平均值。最低值是最近 5 分钟内最短的往返时间。显示整毫秒,因此 < 1 ms 表示不到一毫秒。\n 重传计时器 - Linux 再次发送数据前等待的时间。超时 - 该计时器到期的次数,包括第一次和之后的每一次(从 Linux 6.7 起统计,内核较旧时不显示此行)。\n 重传的字节和重传的分段 - Linux 再次发送的数据:丢失的、未及时确认的,或在确认到达之前就再次发送的。\n 分段大小 (MSS) - 一个数据包中最大的数据块。\n 已发送 / 已接收数据 - 发送的所有字节(包括再次发送的数据),以及只计一次的接收字节。\n\n“已结束”表示连接已关闭,或拥有它的程序关闭了它。\n\n在详细信息中右键单击远程地址,可以将网络损伤限制到该地址。“复制报告”会复制所有观察中的连接(英文)。", "tools.tcphealth.help_title": "TCP 健康", "tools.tcphealth.item.data": "已发送 / 已接收数据", "tools.tcphealth.item.dupacks": "收到的重复 ACK", "tools.tcphealth.item.fast_resends": "快速重传", "tools.tcphealth.item.local": "本地地址", + "tools.tcphealth.item.lowest": "RTT(最低)", "tools.tcphealth.item.mss": "分段大小 (MSS)", "tools.tcphealth.item.process": "程序", "tools.tcphealth.item.range": "RTT 最低 / 最高", "tools.tcphealth.item.remote": "远程地址", "tools.tcphealth.item.resent_bytes": "重传的字节", + "tools.tcphealth.item.resent_segments": "重传的分段", "tools.tcphealth.item.rto": "重传计时器", "tools.tcphealth.item.rttvar": "RTT 波动", "tools.tcphealth.item.sample": "RTT(最近)", diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 5d0ba1f..68040ae 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -23,7 +23,7 @@ import pytest -from beantester import capabilities, icmp, icmpsock, paths, tcpreset +from beantester import capabilities, icmp, icmpsock, paths, tcpestats, tcpreset from fakes import check ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -113,26 +113,29 @@ def test_the_answer_follows_the_module_when_it_is_asked(monkeypatch): check("and the reason to show", capabilities.why_not("ping") == capabilities.NOT_HERE) -@pytest.mark.parametrize("platform, sockets, ping, destroys", [ - ("win32", None, True, False), ("linux", icmpsock.DGRAM, True, False), - ("linux", icmpsock.RAW, True, True), ("linux", None, False, True), - ("linux", None, False, False)]) +@pytest.mark.parametrize("platform, sockets, ping, destroys, lists", [ + ("win32", None, True, False, False), ("linux", icmpsock.DGRAM, True, False, True), + ("linux", icmpsock.RAW, True, True, False), ("linux", None, False, True, True), + ("linux", None, False, False, False)]) def test_windows_offers_every_capability_and_linux_ping_when_it_may_send( - monkeypatch, platform, sockets, ping, destroys): + monkeypatch, platform, sockets, ping, destroys, lists): """The predicates read ``sys.platform`` when asked; every answer on either runner. Linux has Ping when this process may open an ICMP socket - ``icmpsock.kind``, stood in, because the real one asks this runner's kernel (on Windows it asks Winsock) - - and Reset when its kernel destroys sockets (``tcpreset._Linux.can_destroy``, stood - in for the same reason). Adapters and Proxy always (rtnetlink and files, nothing to - be refused); TCP health not yet.""" + Reset when its kernel destroys sockets (``tcpreset._Linux.can_destroy``, stood in + for the same reason) and TCP health when it lists TCP sockets with their tcp_info + (``tcpestats._Linux.can_list``, likewise). Adapters and Proxy always (rtnetlink and + files, nothing to be refused).""" monkeypatch.setattr(sys, "platform", platform) monkeypatch.setattr(icmpsock, "kind", lambda: sockets) monkeypatch.setattr(tcpreset._Linux, "can_destroy", lambda _self: destroys) + monkeypatch.setattr(tcpestats._Linux, "can_list", lambda _self: lists) got = {name: capabilities.available(name) for name in capabilities.CAPABILITIES} windows = platform == "win32" want = {name: windows or name in ("adapters", "proxy") or (name == "ping" and ping) - or (name == "tcp_reset" and destroys) for name in capabilities.CAPABILITIES} - check(f"{platform}, {sockets}, {destroys}: {want}", got == want, f"({got})") + or (name == "tcp_reset" and destroys) or (name == "tcp_health" and lists) + for name in capabilities.CAPABILITIES} + check(f"{platform}, {sockets}, {destroys}, {lists}: {want}", got == want, f"({got})") def test_a_linux_without_the_right_to_ping_says_how_to_get_it(monkeypatch): @@ -142,6 +145,7 @@ def test_a_linux_without_the_right_to_ping_says_how_to_get_it(monkeypatch): monkeypatch.setattr(icmpsock, "kind", lambda: None) monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(paths, "running_as_root", lambda: False) + monkeypatch.setattr(tcpestats._Linux, "can_list", lambda _self: False) check("Linux: Ping's own words", capabilities.why_not("ping") == icmp.NOT_HERE_LINUX, f"({capabilities.why_not('ping')})") check("the others keep the shared ones", diff --git a/tests/test_ci_linux_capture_smoke.py b/tests/test_ci_linux_capture_smoke.py index d9f8106..81b4474 100644 --- a/tests/test_ci_linux_capture_smoke.py +++ b/tests/test_ci_linux_capture_smoke.py @@ -6,7 +6,10 @@ of a broken session and must say so, and the clean one must pass. """ import os +import socket +import subprocess import sys +import threading sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tools")) @@ -104,6 +107,71 @@ def test_every_broken_reset_is_named(): f"({failures})") +SS_LINE = ("ESTAB 0 0 192.0.2.2:41000 192.0.2.1:47104 cubic wscale:7,7 rto:201 " + "rtt:0.067/0.038 ato:40 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 " + "bytes_sent:500 bytes_acked:501 bytes_received:515 segs_out:7 segs_in:6") + + +def test_every_broken_tcp_health_reading_is_named(): + """The tool's own numbers against ``ss -tin`` of the same moment, the smoothed round + trip climbing through the session, and ended once closed.""" + watched = {"state": "ok", "srtt": 0, "rto": 201, "mss": 1448, "sent": 500, + "received": 515, "resent": 0, "ss": SS_LINE} + during = {"state": "ok", "srtt": 93, "closed": "ended"} + check("a TCP health reading that did its job passes", + smoke.judge_health(watched, during) == [], f"({smoke.judge_health(watched, during)})") + check("a smoothed value one slow sample pulled up passes too (232 ms on the VM)", + smoke.judge_health(watched, dict(during, srtt=232)) == []) + cases = { + "not watched": ((dict(watched, state="failed"), during), "watching ended"), + "the timer read in microseconds": ((dict(watched, rto=201000), during), "rto"), + "the segment size of another field": ((dict(watched, mss=536), during), "mss"), + "one byte count wrong": ((dict(watched, sent=501), during), "sent"), + "sent and received swapped": ((dict(watched, sent=515, received=500), during), + "received 500, ss said 515"), + "resent bytes where ss saw none": ((dict(watched, resent=1448), during), "resent"), + "a round trip rounded up": ((dict(watched, srtt=1), during), "srtt"), + "no ss line": ((dict(watched, ss=""), during), "ss said no numbers"), + "a slow machine": ((dict(watched, ss=SS_LINE.replace("rtt:0.067", "rtt:30.5"), + srtt=30), during), "30 ms before"), + "the latency not seen": ((watched, dict(during, srtt=1)), "smoothed 1 ms"), + "a round trip in another unit": ((watched, dict(during, srtt=93000)), "smoothed 93000"), + "a session that never ran": ((watched, {}), "smoothed None"), + "still open once closed": ((watched, dict(during, closed="ok")), "closed"), + } + for name, (got, words) in cases.items(): + failures = smoke.judge_health(*got) + check(f"{name}: failed, saying {words!r}", any(words in f for f in failures), + f"({failures})") + check("ss leaves out bytes_retrans while it is 0, and says it once it is not", + smoke.judge_health(dict(watched, resent=2896), during) != [] + and smoke.judge_health(dict(watched, resent=2896, ss=SS_LINE + " bytes_retrans:2896"), + during) == []) + + +def test_the_tcp_health_client_stops_when_the_far_end_closes(): + """``recv`` answers ``b''`` once the far end has closed: counted as data, the read never + ends, and the phase would wait for the client's line until CI's own limit. The client + itself, run against a far end that closes at once.""" + far = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + far.bind(("127.0.0.1", 0)) + far.listen(1) + + def close_at_once(): + conn = far.accept()[0] + conn.close() + threading.Thread(target=close_at_once, daemon=True).start() + try: + done = subprocess.run( + [sys.executable, "-c", smoke.HEALTH_CLIENT, "127.0.0.1", str(far.getsockname()[1]), + "1", str(len(smoke.HEALTH_GREETING))], + cwd=smoke.ROOT, capture_output=True, text=True, timeout=30) + finally: + far.close() + check("it ends, saying why", done.returncode != 0 + and "the far end closed before its greeting" in done.stderr, f"({done.stderr[-300:]})") + + def _adapters_seen(): """What the Adapters phase reads in a namespace that came out right.""" def link(index, name, state, flags, kind="ether", mac="02:00:00:00:00:0a", mtu=1500): diff --git a/tests/test_gui_layout.py b/tests/test_gui_layout.py index 23a7d4d..3fab6e3 100644 --- a/tests/test_gui_layout.py +++ b/tests/test_gui_layout.py @@ -600,6 +600,9 @@ def expect(button, title, body, tip): import sys from beantester.gui.toolbox import adapters as adapters_panel from beantester.gui.toolbox import diagnostics as diagnostics_panel, ping as ping_panel + from beantester.gui.toolbox import tcphealth as tcphealth_panel + # The tools with one whole sheet per system: Linux has its own. + per_system = {"adapters": adapters_panel, "tcphealth": tcphealth_panel} # The sheets composed of paragraphs, one of them about this system. composed = {"diagnostics": diagnostics_panel.help_keys, "ping": ping_panel.help_keys} for tool in TOOLS: # every panel carries one "?" (the skeleton) @@ -608,10 +611,10 @@ def expect(button, title, body, tip): if tool.id in composed: # its sheet still opens with that key assert composed[tool.id]()[0] == body body = composed[tool.id]() - if tool.id == "adapters": # one whole sheet per system: Linux has its own - body = adapters_panel.help_key() - assert body == ("tools.adapters.help_linux" if sys.platform.startswith("linux") - else "tools.adapters.help_body"), body + if tool.id in per_system: + body = per_system[tool.id].help_key() + assert body == (f"tools.{tool.id}.help_linux" if sys.platform.startswith("linux") + else f"tools.{tool.id}.help_body"), body expect(button, f"tools.{tool.id}.help_title", body, f"tips.tools_{tool.id}_help") unplaced = [b for b in helps_in(root) if b not in placed] assert not unplaced, f"{len(unplaced)} '?' button(s) this test cannot place" diff --git a/tests/test_hot_path.py b/tests/test_hot_path.py index 98c4ed1..3086ac4 100644 --- a/tests/test_hot_path.py +++ b/tests/test_hot_path.py @@ -130,6 +130,9 @@ # The Tools tab's TCP health (``tcpestats._System``): counting turned on and read, by # that tool's worker once a second - never by a packet thread. ESTATS_METHODS = ("enable", "read_stats") +# The same on Linux (``tcpestats._Linux``): the kernel's socket list when a connection is +# put on the list, and one socket's tcp_info - over netlink, by the same worker. +LINUX_ESTATS_METHODS = ("rows", "lookup") # The Tools tab's Ping (``icmp._System``): one echo request per call, by that tool's # worker at the pace the person chose - never by a packet thread. ICMP_METHODS = ("echo4", "echo6") @@ -164,6 +167,8 @@ def os_calls_recorded(): getattr(tcpreset._Linux, name) for name in LINUX_RESET_METHODS}) native.update({(tcpestats._System, name, "tcpestats." + name): getattr(tcpestats._System, name) for name in ESTATS_METHODS}) + native.update({(tcpestats._Linux, name, "tcpestats_linux." + name): + getattr(tcpestats._Linux, name) for name in LINUX_ESTATS_METHODS}) native.update({(icmp._System, name, "icmp." + name): getattr(icmp._System, name) for name in ICMP_METHODS}) diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 9264359..c56763a 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -8412,10 +8412,276 @@ "new": " measured = True\n", "test": "test_round_trips_are_unknown_until_windows_measured_one", }, + # TCP health on Linux (2026-10-27): tcp_info through sock_diag, by the list's id. + { + # Measured: an orphan in FIN_WAIT2 is answered without tcp_info. + "label": "tcpestats: on Linux a connection its program closed reads as open", + "file": "beantester/tcpestats.py", + "old": " if code == ENOENT or (code == 0 and (found.info is None or found.state in _NOT_OPEN)):\n", + "new": " if code == ENOENT or (code == 0 and found.state in _NOT_OPEN):\n", + "test": "test_a_reading_is_asked_by_the_handle_alone_and_ends_where_the_kernel_says_so", + }, + { + "label": "tcpestats: on Linux a connection that is gone reads as a failure", + "file": "beantester/tcpestats.py", + "old": " if code == ENOENT or (code == 0 and (found.info is None or found.state in _NOT_OPEN)):\n", + "new": " if code == 0 and (found.info is None or found.state in _NOT_OPEN):\n", + "test": "test_a_reading_is_asked_by_the_handle_alone_and_ends_where_the_kernel_says_so", + }, + { + "label": "tcpestats: on Linux the listener's answer is read as the connection's", + "file": "beantester/tcpestats.py", + "old": "_NOT_OPEN = frozenset({\"TIME_WAIT\", \"CLOSED\", \"LISTEN\"})\n", + "new": "_NOT_OPEN = frozenset({\"TIME_WAIT\", \"CLOSED\"})\n", + "test": "test_a_listener_answering_for_a_connection_is_never_its_numbers", + }, + { + # "< 1 ms" means under one: 0.999 ms rounded to the nearest would say 1. + "label": "tcpestats: on Linux round trips are rounded to the nearest ms", + "file": "beantester/tcpestats.py", + "old": " return value // US_PER_MS\n", + "new": " return round(value / US_PER_MS)\n", + "test": "test_a_round_trip_linux_has_not_measured_yet_is_said_so", + }, + { + "label": "tcpestats: on Linux a round trip of 0 reads as one under a millisecond", + "file": "beantester/tcpestats.py", + "old": " measured = bool(info.get(\"rtt\"))\n", + "new": " measured = True\n", + "test": "test_a_round_trip_linux_has_not_measured_yet_is_said_so", + }, + { + "label": "tcpestats: a field an older kernel lacks reads as 0", + "file": "beantester/tcpestats.py", + "old": " absent = LINUX_ABSENT | {field for field, name in _FROM_KERNEL.items() if name not in info}\n", + "new": " absent = LINUX_ABSENT\n", + "test": "test_an_older_kernel_without_a_field_has_no_row_for_it", + }, + { + "label": "tcpestats: watching on Linux is said to need administrator rights", + "file": "beantester/tcpestats.py", + "old": " return sys.platform.startswith(\"win\")\n", + "new": " return True\n", + "test": "test_linux_is_asked_whether_it_lists_and_never_needs_rights", + }, + { + "label": "tcpestats: Linux has TCP health without its kernel being asked", + "file": "beantester/tcpestats.py", + "old": " return sys.platform.startswith(\"linux\") and _Linux().can_list()\n", + "new": " return sys.platform.startswith(\"linux\")\n", + "test": "test_linux_is_asked_whether_it_lists_and_never_needs_rights", + }, + { + # psutil writes a dual-stack socket as ::ffff:, the row not: GONE. + "label": "tcpestats: a Connections row on Linux is looked up in psutil's table", + "file": "beantester/tcpestats.py", + "old": " return _Linux().rows()\n", + "new": " return portmap.socket_rows()\n", + "test": "test_a_connections_row_is_looked_up_in_the_kernels_list_on_linux", + }, + { + "label": "tcpestats: on Linux two sockets with one connection's numbers: one is guessed", + "file": "beantester/tcpestats.py", + "old": " if len(found) > 1:\n", + "new": " if False:\n", + "test": "test_a_connection_not_in_the_list_or_named_twice_is_not_guessed_at", + }, + { + "label": "tcpestats: a dual-stack socket is not found by psutil's spelling", + "file": "beantester/tcpestats.py", + "old": " return getattr(address, \"ipv4_mapped\", None) or address\n", + "new": " return address\n", + "test": "test_a_socket_opened_as_ipv6_is_found_by_either_spelling", + }, + { + # A failed watch carries no handle and is read again: None.kernel_family was an + # AttributeError recorded as a fault, and the connection never came back. + "label": "tcpestats: on Linux a watch that found no socket is read by no id", + "file": "beantester/tcpestats.py", + "old": " if handle is None:\n", + "new": " if False:\n", + "test": "test_a_watch_that_found_no_socket_looks_for_it_again", + }, + { + "label": "linux smoke: the TCP health client counts a closed far end as data", + "file": "tools/ci_linux_capture_smoke.py", + "old": " \" raise ConnectionError('the far end closed ' + when)\\n\"\n", + "new": " \" break\\n\"\n", + "test": "test_the_tcp_health_client_stops_when_the_far_end_closes", + }, + { + "label": "tcpestats: a list the Linux kernel refused is a fault of the program", + "file": "beantester/tcpestats.py", + "old": " return Reading(FAILED, detail=str(exc))\n", + "new": " raise\n", + "test": "test_what_the_kernel_refuses_is_said_in_its_words_and_never_raised", + }, + { + "label": "sockdiag: a lookup asks without tcp_info", + "file": "beantester/sockdiag.py", + "old": " EVERY_STATE, INFO))\n", + "new": " EVERY_STATE))\n", + "test": "test_one_socket_is_looked_up_by_its_id_and_its_tcp_info_reads_as_ss_read_it", + }, + { + # Measured on route get: no DUMP and no ACK = no end to the answer. + "label": "sockdiag: a lookup asks with no end to its answer", + "file": "beantester/sockdiag.py", + "old": " replies = talk(request(SOCK_DIAG_BY_FAMILY, NLM_F_REQUEST | NLM_F_ACK, kernel_family, sid,\n", + "new": " replies = talk(request(SOCK_DIAG_BY_FAMILY, NLM_F_REQUEST, kernel_family, sid,\n", + "test": "test_one_socket_is_looked_up_by_its_id_and_its_tcp_info_reads_as_ss_read_it", + }, + { + "label": "sockdiag: a field past an older kernel's tcp_info is read", + "file": "beantester/sockdiag.py", + "old": " if at + shape.size <= len(raw)}\n", + "new": " if True}\n", + "test": "test_an_older_kernels_shorter_tcp_info_has_only_the_fields_inside_it", + }, + { + "label": "sockdiag: a socket that keeps no tcp_info reads as one with it", + "file": "beantester/sockdiag.py", + "old": " return 0, Found(_socket(answers[0]).state, info[0] if info else None)\n", + "new": " return 0, Found(_socket(answers[0]).state, info[0] if info else b\"\")\n", + "test": "test_a_socket_that_keeps_no_tcp_info_or_is_gone_is_answered_so", + }, + { + "label": "sockdiag: whether the kernel lists is asked with a list of every socket", + "file": "beantester/sockdiag.py", + "old": " sockid(), NO_STATES, INFO))) == 0\n", + "new": " sockid(), CONNECTED, INFO))) == 0\n", + "test": "test_whether_the_kernel_lists_tcp_sockets_is_asked_with_a_list_of_nothing", + }, + { + "label": "sockdiag: two answers for one id: the first is taken", + "file": "beantester/sockdiag.py", + "old": " if len(answers) != 1:\n", + "new": " if not answers:\n", + "test": "test_a_lookup_that_does_not_add_up_is_an_error", + }, + { + "label": "tcp health: Linux's counters wrap at Windows' 32 bits", + "file": "beantester/nettools/tcphealth.py", + "old": " return 2 ** sample.widths.get(field, 32)\n", + "new": " return 2 ** 32\n", + "test": "test_linux_counters_wrap_at_their_own_widths", + }, + { + "label": "tcp health: what a stack does not keep gets a row anyway", + "file": "beantester/nettools/tcphealth.py", + "old": " return all(watch.has(f) if not f.startswith(\"!\") else not watch.has(f[1:]) for f in fields)\n", + "new": " return True\n", + "test": "test_the_details_and_the_report_say_the_numbers_since_watching", + }, + { + "label": "tcp health: Linux is said not to have measured its round trip", + "file": "beantester/nettools/tcphealth.py", + "old": " if not self.has(\"rtt_samples\"):\n", + "new": " if False:\n", + "test": "test_a_linux_reading_says_only_what_its_kernel_keeps", + }, + { + "label": "tcp health: the report says what Linux does not keep", + "file": "beantester/nettools/tcphealth.py", + "old": " return [text for field, text in parts if watch.has(field)]\n", + "new": " return [text for field, text in parts]\n", + "test": "test_a_linux_reading_says_only_what_its_kernel_keeps", + }, + { + "label": "tcp health: the chart draws a line Linux has no numbers for", + "file": "beantester/nettools/tcphealth.py", + "old": " sample = [p[2] for p in points] if watch is None or watch.has(\"sample_rtt\") else []\n", + "new": " sample = [p[2] for p in points]\n", + "test": "test_a_linux_reading_says_only_what_its_kernel_keeps", + }, + { + "label": "tcp health: the handle a watch gave is not kept", + "file": "beantester/nettools/tcphealth.py", + "old": " self.handle = reading.handle\n", + "new": " pass\n", + "test": "test_the_handle_a_watch_gave_is_what_its_connection_is_read_by", + }, + { + "label": "tcp health: a reading without a handle drops the one kept", + "file": "beantester/nettools/tcphealth.py", + "old": " if reading.handle is not None:\n", + "new": " if True:\n", + "test": "test_the_handle_a_watch_gave_is_what_its_connection_is_read_by", + }, + { + "label": "tcp health: a cycle reads without the handle", + "file": "beantester/nettools/tcphealth.py", + "old": " return Result(tuple(added), {key_of(c): read(c, handle=handle) for c, handle in watched})\n", + "new": " return Result(tuple(added), {key_of(c): read(c) for c, handle in watched})\n", + "test": "test_the_handle_a_watch_gave_is_what_its_connection_is_read_by", + }, + { + "label": "tcp health: Linux is told to start as administrator", + "file": "beantester/nettools/tcphealth.py", + "old": " if tcpestats.needs_rights() and not driver.is_admin():\n", + "new": " if not driver.is_admin():\n", + "test": "test_no_note_about_rights_where_watching_needs_none", + }, + { + "label": "tcp health: a Connections row is looked up in psutil's table on Linux", + "file": "beantester/nettools/tcphealth.py", + "old": " table = _table(rows or tcpestats.socket_rows)\n", + "new": " table = _table(rows or portmap.socket_rows)\n", + "test": "test_a_connections_row_is_looked_up_in_the_systems_own_table", + }, + { + "label": "tcp health: Linux gets Windows' help sheet", + "file": "beantester/gui/toolbox/tcphealth.py", + "old": " return HELP_LINUX if _is_linux(platform) else HELP\n", + "new": " return HELP\n", + "test": "test_linux_has_its_own_sheet_tips_and_legend_and_windows_keeps_its_keys", + }, + { + "label": "tcp health: Linux's legend names a line its chart does not draw", + "file": "beantester/gui/toolbox/tcphealth.py", + "old": " return LEGEND_LINUX if _is_linux(platform) else LEGEND\n", + "new": " return LEGEND\n", + "test": "test_linux_has_its_own_sheet_tips_and_legend_and_windows_keeps_its_keys", + }, + { + "label": "tcp health: Linux's column tips name Windows", + "file": "beantester/gui/toolbox/tcphealth.py", + "old": " return TIPS_LINUX if _is_linux(platform) else TIPS\n", + "new": " return TIPS\n", + "test": "test_linux_has_its_own_sheet_tips_and_legend_and_windows_keeps_its_keys", + }, + { + "label": "tcp health: Linux's Stop tip names Windows", + "file": "beantester/gui/toolbox/tcphealth.py", + "old": " return STOP_TIP_LINUX if _is_linux(platform) else STOP_TIP\n", + "new": " return STOP_TIP\n", + "test": "test_linux_has_its_own_sheet_tips_and_legend_and_windows_keeps_its_keys", + }, + { + "label": "linux smoke: TCP health's numbers are not held against ss", + "file": "tools/ci_linux_capture_smoke.py", + "old": " if watched.get(name) != value:\n", + "new": " if False:\n", + "test": "test_every_broken_tcp_health_reading_is_named", + }, + { + "label": "linux smoke: the latency TCP health saw is not judged", + "file": "tools/ci_linux_capture_smoke.py", + "old": " if during.get(\"state\") != \"ok\" or srtt is None or not 0.6 * added <= srtt <= 5 * added:\n", + "new": " if during.get(\"state\") != \"ok\" or srtt is None:\n", + "test": "test_every_broken_tcp_health_reading_is_named", + }, + { + "label": "linux smoke: a connection still open once closed passes", + "file": "tools/ci_linux_capture_smoke.py", + "old": " if during.get(\"closed\") != \"ended\":\n", + "new": " if False:\n", + "test": "test_every_broken_tcp_health_reading_is_named", + }, { "label": "tcp health: a 32-bit counter that wrapped reads as a huge drop", "file": "beantester/nettools/tcphealth.py", - "old": " return (getattr(self.last, field) - getattr(self.first, field)) % bits\n", + "old": " return (getattr(self.last, field) - getattr(self.first, field)) % _span(self.last, field)\n", "new": " return getattr(self.last, field) - getattr(self.first, field)\n", "test": "test_counters_are_differences_from_the_first_reading_and_wrap_at_32_bits", }, diff --git a/tests/test_nettools_tcphealth.py b/tests/test_nettools_tcphealth.py index 9c12d7c..3b6cfef 100644 --- a/tests/test_nettools_tcphealth.py +++ b/tests/test_nettools_tcphealth.py @@ -121,7 +121,8 @@ def rows(): [Request("x", 1, target=Target(51000, "198.51.100.7", 443)), Request("y", 2, target=Target(51001, "198.51.100.7", 443)), Request("z", 3, connection=C._replace(local_port=60000))], - [C], watch=lambda c: watched.append(c) or ok(), read=lambda c: ok(resent=1), rows=rows) + [(C, None)], watch=lambda c: watched.append(c) or ok(), + read=lambda c, handle=None: ok(resent=1), rows=rows) check("one socket table read for two Connections rows", calls == [1], f"({calls})") kinds = [(r.process, kind) for r, _c, kind, _reading in result.added] check("found, gone, and a known connection that needed no lookup", @@ -131,7 +132,7 @@ def rows(): result.added[0][1] == C and watched == [C, C._replace(local_port=60000)]) check("and what was on the list is read", list(result.readings) == [tcphealth.key_of(C)]) none = tcphealth.cycle([Request("z", 3, connection=C)], [], watch=lambda c: ok(), - read=lambda c: ok(), rows=lambda: calls.append(2)) + read=lambda c, handle=None: ok(), rows=lambda: calls.append(2)) check("no Connections row: no table read", calls == [1] and len(none.added) == 1) @@ -144,8 +145,8 @@ def unavailable(): target = Request("x", 1, target=Target(51000, "198.51.100.7", 443)) for rows in (unavailable, lambda: ([], ["tcp/v6"]), lambda: (_ for _ in ()).throw( RuntimeError("a fault in the table read on purpose"))): - result = tcphealth.cycle([target], [C], watch=lambda c: ok(), read=lambda c: ok(), - rows=rows) + result = tcphealth.cycle([target], [(C, None)], watch=lambda c: ok(), + read=lambda c, handle=None: ok(), rows=rows) check(f"{rows}: unreadable", result.added[0][2] == tcphealth.UNREADABLE, f"({result.added})") check("and what was watched is still read", list(result.readings) == @@ -172,7 +173,10 @@ def test_the_details_and_the_report_say_the_numbers_since_watching(): rows["remote"].value == "[2001:db8::7]:443" and rows["remote"].address == "2001:db8::7") check("resent in BYTES since watching, and the growth since the last read in brackets", rows["resent_bytes"].value == "2920 B (+2920)", f"({rows['resent_bytes']})") - check("no row for the segment count Windows never fills", "resent" not in rows) + check("no row for the segment count Windows never fills", + "resent" not in rows and "resent_segments" not in rows, f"({list(rows)})") + check("nor Linux's lowest-alone row: Windows has the range", + "lowest" not in rows and "range" in rows) check("a counter that did not grow: no brackets", rows["timeouts"].value == "0" and rows["dupacks"].value == "3 (+3)", f"({rows['timeouts']}, {rows['dupacks']})") check("bytes sent and received since watching", rows["data"].value == "2000 / 4000 B") @@ -263,3 +267,128 @@ def test_a_round_trip_under_a_millisecond_is_said_so_not_as_none(): and tcphealth.rtt_number(214) == "214") check("not measured stays not measured", tcphealth.ms(None) == tcphealth.Words( "tools.tcphealth.value.no_rtt")) + + +# -- Linux: what its kernel keeps (tcpestats._linux_sample) ----------------------------- # +def linux(**kernel): + """A Linux reading from ``tcp_info``'s fields, in the kernel's units (microseconds). + A field given as None is one an older kernel's shorter struct does not have.""" + info = {"rto": 201000, "snd_mss": 1448, "rtt": 210500, "rttvar": 3200, "total_retrans": 0, + "bytes_received": 5000, "min_rtt": 180250, "bytes_sent": 1000, "bytes_retrans": 0, + "total_rto": 0, **kernel} + return tcpestats.Reading(tcpestats.OK, tcpestats._linux_sample( + {name: value for name, value in info.items() if value is not None}), handle="id") + + +def test_a_linux_reading_says_only_what_its_kernel_keeps(): + """No newest single round trip, no highest one, no fast resends or duplicate ACKs, no + count of measurements - so none of those is said, not even as "not measured yet". It + does count the segments sent again, which Windows never fills.""" + watch = Watch(C, "app", 7, 0.0) + watch.take(linux(total_retrans=3, bytes_retrans=4344), 1.0) + for second in range(2, 9): + watch.take(linux(total_retrans=5, bytes_retrans=7240, bytes_sent=1000 + 500 * second), + float(second)) + rows = {d.key: d for d in tcphealth.details(watch)} + check("what Linux keeps, in its order", + list(rows) == ["process", "local", "remote", "state", "srtt", "lowest", "rttvar", "rto", + "resent_bytes", "resent_segments", "timeouts", "mss", "data", "since"], + f"({list(rows)})") + check("whole milliseconds, rounded down", rows["srtt"].value == "210 ms" + and rows["lowest"].value == "180 ms" and rows["rttvar"].value == "3 ms" + and rows["rto"].value == "201 ms", f"({rows['srtt']}, {rows['lowest']})") + check("the segments sent again since watching, as a counter", + rows["resent_segments"].value == "2" and rows["resent_bytes"].value == "2896 B", + f"({rows['resent_segments']}, {rows['resent_bytes']})") + check("never said to be unmeasured: Linux keeps no count of its measurements", + watch.unmeasured_for() == 0 and tcphealth.tag_of(watch) == "" + and rows["srtt"].note == ()) + text = tcphealth.report(_one(watch)) + check("the report says the same and no more", + "smoothed 210 ms, lowest 180 ms, variation 3 ms, resend timer 201 ms, segment size " + "1448 B" in text and "resent 2896 B (2 segments), timeouts 0, sent 4000 B, received 0 B" + in text and not any(word in text for word in ("last", "highest", "measured", + "fast resends", "duplicate ACKs")), + "\n" + text) + smoothed, sample, _span = tcphealth.chart(watch, 9.0) + check("the chart: the smoothed line alone", len(smoothed) == 8 and sample == [], + f"({smoothed}, {sample})") + + +def test_linux_counters_wrap_at_their_own_widths(): + """The kernel's timeout count is 16 bits, its resent bytes 64: a difference taken at + Windows' 32 would be a huge jump in one and a wrap that never was in the other.""" + watch = Watch(C, "app", 7, 0.0) + watch.take(linux(total_rto=65534, bytes_retrans=1), 1.0) + watch.take(linux(total_rto=2, bytes_retrans=2 ** 32 + 5), 2.0) + check("timeouts across the 16-bit wrap", watch.since_watching("timeouts") == 4 + and watch.since_previous("timeouts") == 4, f"({watch.since_watching('timeouts')})") + check("resent bytes past 4 GiB", watch.since_watching("resent_bytes") == 2 ** 32 + 4, + f"({watch.since_watching('resent_bytes')})") + + +def test_an_older_kernel_without_a_field_has_no_row_for_it(): + """Fields are added at the end of ``tcp_info``: Linux before 6.7 has no timeout count + (Ubuntu 22.04). The row goes, never a 0 that was not counted.""" + watch = Watch(C, "app", 7, 0.0) + watch.take(linux(total_rto=None), 1.0) + rows = {d.key: d for d in tcphealth.details(watch)} + check("no timeouts row", "timeouts" not in rows and "resent_segments" in rows, f"({list(rows)})") + check("nor in the report", "timeouts" not in tcphealth.report(_one(watch))) + + +def test_a_round_trip_linux_has_not_measured_yet_is_said_so(): + """A round trip of 0 us is none yet (a connection still opening, measured 2026-10-27); + its variation is then the kernel's starting value, not a measurement.""" + watch = Watch(C, "app", 7, 0.0) + watch.take(linux(rtt=0, rttvar=250000, min_rtt=0xFFFFFFFF), 1.0) + rows = {d.key: d for d in tcphealth.details(watch)} + none = tcphealth.Words("tools.tcphealth.value.no_rtt") + check("not measured yet", rows["srtt"].value == none and rows["rttvar"].value == none + and rows["lowest"].value == none, f"({rows['srtt']}, {rows['rttvar']})") + under = Watch(C, "app", 7, 0.0) + under.take(linux(rtt=999, min_rtt=14), 1.0) + check("measured, 0.999 ms: rounded down, < 1 ms - under one, as it was", + tcphealth.cells(under)[4] == "< 1 ms", f"({tcphealth.cells(under)})") + + +def test_a_connections_row_is_looked_up_in_the_systems_own_table(monkeypatch): + """The system's table (``tcpestats.socket_rows``): on Linux the kernel's list, which + writes a dual-stack socket's address as the row does, where psutil does not.""" + asked = [] + + def system_rows(): + asked.append(1) + return ([SocketRow("TCP", 4, "192.0.2.10", 51000, "198.51.100.7", 443, "ESTABLISHED", + 7)], []) + monkeypatch.setattr(tcpestats, "socket_rows", system_rows) + result = tcphealth.cycle([Request("x", 1, target=Target(51000, "198.51.100.7", 443))], [], + watch=lambda c: ok()) + check("the system's table, read once", asked == [1] + and result.added[0][2] == tcphealth.WATCHING, f"({asked}, {result.added})") + + +def test_the_handle_a_watch_gave_is_what_its_connection_is_read_by(): + """Linux reads a connection by the kernel's id of its socket, cookie in it - the one + the watch found. A reading without one (Windows) keeps none.""" + watchlist = Watchlist() + watchlist.apply(tcphealth.Result(((Request("a", 1, connection=C), C, tcphealth.WATCHING, + linux()),), {}), 1.0) + check("kept", watchlist.to_read() == [(C, "id")], f"({watchlist.to_read()})") + watchlist.apply(tcphealth.Result((), {tcphealth.key_of(C): ok()}), 2.0) + check("a reading without one does not lose it", watchlist.to_read() == [(C, "id")]) + asked = [] + result = tcphealth.cycle([], watchlist.to_read(), watch=lambda c: ok(), + read=lambda c, handle=None: asked.append((c, handle)) or ok()) + check("and the cycle reads by it", asked == [(C, "id")] + and list(result.readings) == [tcphealth.key_of(C)], f"({asked})") + + +def test_no_note_about_rights_where_watching_needs_none(monkeypatch): + monkeypatch.setattr(tcphealth.driver, "is_admin", lambda: False) + monkeypatch.setattr(tcpestats, "needs_rights", lambda: True) + check("Windows without rights: said", tcphealth.blocker() == "tools.tcphealth.needs_admin") + monkeypatch.setattr(tcpestats, "needs_rights", lambda: False) + check("Linux: nothing to say", tcphealth.blocker() == "") + monkeypatch.setattr(tcpestats.sys, "platform", "linux") + check("Linux needs none", tcpestats.needs_rights() is False) diff --git a/tests/test_sockdiag.py b/tests/test_sockdiag.py index 3e8a153..3993f3f 100644 --- a/tests/test_sockdiag.py +++ b/tests/test_sockdiag.py @@ -60,6 +60,93 @@ DESTROY_ANSWER = bytes.fromhex( "24000000020000010100000028c500000000000048000000150005000100000000000000") +# TCP health's lookups, recorded 2026-10-27 by probe ``2026-10-27-linux-tcphealth`` +# (``tcpinfo_probe.py golden``) on the same kernel, in a namespace of its own again: one +# connection, a client 127.0.0.1:40172 to a listener on 127.0.0.1:53325, that sent 3000 +# bytes and received 500. ``ss -tin`` said of the client then: rto:201 rtt:0.067/0.038 +# mss:48128 bytes_sent:3000 bytes_received:500 minrtt:0.014. Each answer is that kernel's +# to the request named beside it, through the connection's life: open, the client closed +# by its program, the listener's side in CLOSE_WAIT, the client in TIME_WAIT, the +# listener's side gone - and that one asked for again WITHOUT its cookie. +HEALTH_LIST4 = bytes.fromhex( + "7c00000014000200010000008e1c000002010000d04d9cec7f000001000000000000000000000000" + "7f000001000000000000000000000000000000000340000000000000000000000000000000000000" + "000000004ed80000050008000000000008000f00000000000c001500185300000000000006001600" + "520000007c00000014000200010000008e1c0000020100009cecd04d7f0000010000000000000000" + "000000007f0000010000000000000000000000000000000004400000000000000000000000000000" + "00000000000000004dd80000050008000000000008000f00000000000c0015001853000000000000" + "0600160052000000") +HEALTH_DONE = bytes.fromhex("1400000003000200010000008e1c000000000000") +CAN_LIST_REQUEST = bytes.fromhex( + "48000000140001030100000000000000020602000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000ffffffffffffffff") +LOOKUP_ACK = bytes.fromhex( + "2400000002000001010000008e1c00000000000048000000140005000100000000000000") +# The client's id, cookie in it: asked open, closed by its program, in TIME_WAIT. +CLIENT_REQUEST = bytes.fromhex( + "4800000014000500010000000000000002060200ffffffff9cecd04d7f0000010000000000000000" + "000000007f000001000000000000000000000000000000000440000000000000") +OPEN_ANSWER = bytes.fromhex( + "9801000014000000010000008e1c0000020100009cecd04d7f000001000000000000000000000000" + "7f000001000000000000000000000000000000000440000000000000000000000000000000000000" + "000000004dd80000050008000000000008000f00000000000c001500185300000000000006001600" + "520000001c010200010000000007aa0128110300409c000000bc0000180200000000000000000000" + "00000000000000000000000055010000000000005501000055010000ffff0000d7ff000043000000" + "26000000ffffff7f0a000000cbff00000300000000000000d7ff000000000000174a8c5303000000" + "ffffffffffffffffb90b000000000000f4010000000000000400000003000000000000000e000000" + "01000000010000006d5be7cc00000000000000000000000000000000000000000000000000000000" + "0200000000000000b80b000000000000000000000000000000000000000000000000000000780100" + "00000100000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000") +CLOSED_BY_ITS_PROGRAM_ANSWER = bytes.fromhex( + "6000000014000000010000008e1c0000020503009cecd04d7f000001000000000000000000000000" + "7f000001000000000000000000000000000000000440000000000000f7e900000000000000000000" + "000000000000000008000f0000000000") +TIME_WAIT_ANSWER = bytes.fromhex( + "6000000014000000010000008e1c0000020603009cecd04d7f000001000000000000000000000000" + "7f0000010000000000000000000000000000000004400000000000007ae900000000000000000000" + "000000000000000008000f0000000000") +# The listener's side of it, cookie in its id: in CLOSE_WAIT, then gone. +SERVED_REQUEST = bytes.fromhex( + "4800000014000500010000000000000002060200ffffffffd04d9cec7f0000010000000000000000" + "000000007f000001000000000000000000000000000000000340000000000000") +CLOSE_WAIT_ANSWER = bytes.fromhex( + "9801000014000000010000008e1c000002080000d04d9cec7f000001000000000000000000000000" + "7f000001000000000000000000000000000000000340000000000000000000000100000000000000" + "000000004ed80000050008000100000008000f00000000000c001500185300000000000006001600" + "520000001c010200080000000007aa0128110300409c000000800000b80b00000000000000000000" + "0000000000000000000000002f020000000000002f020000ca000000ffff00005676010018000000" + "0d000000ffffff7f0a000000cbff00000300000000000000cbff0000000000006f67482a06000000" + "fffffffffffffffff401000000000000b90b00000000000003000000050000000000000009000000" + "0100000001000000388e03d900000000000000000000000000000000000000000000000000000000" + "0200000000000000f401000000000000000000000000000000000000000000000000000000000100" + "00780100000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000") +GONE_ANSWER = bytes.fromhex( + "5c00000002000000010000008e1c0000feffffff4800000014000500010000000000000002060200" + "ffffffffd04d9cec7f0000010000000000000000000000007f000001000000000000000000000000" + "000000000340000000000000") +# The listener's side once gone, asked for by its numbers alone: the LISTENER answers. +NO_COOKIE_REQUEST = bytes.fromhex( + "4800000014000500010000000000000002060200ffffffffd04d9cec7f0000010000000000000000" + "000000007f00000100000000000000000000000000000000ffffffffffffffff") +LISTENER_ANSWER = bytes.fromhex( + "9801000014000000010000008e1c0000020a0000d04d00007f000001000000000000000000000000" + "00000000000000000000000000000000000000000540000000000000000000000000000080000000" + "000000004cd80000050008000000000008000f00000000000c001500185300000000000006001600" + "520000001c0102000a00000000000000000000000000000000000000000000000000000080000000" + "00000000000000000000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000a0000000000000003000000000000000000000000000000ffffffffffffffff" + "ffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000") +# What ss said of the open client, in the kernel's units (microseconds). +OPEN_INFO = {"rto": 201000, "snd_mss": 48128, "rtt": 67, "rttvar": 38, "total_retrans": 0, + "bytes_received": 500, "min_rtt": 14, "bytes_sent": 3000, "bytes_retrans": 0, + "total_rto": 0} + # (local, local port, remote, remote port) of each socket, as Python's sockets said. IN_V4 = {("127.0.0.1", 46112, "127.0.0.1", 46669), # the IPv4 client ("127.0.0.1", 46669, "127.0.0.1", 46112), # the listener's side of it @@ -187,6 +274,87 @@ def test_the_states_read_in_the_socket_tables_words(): check("the states a reset sends a reset in are among them", tcpreset.RESETS <= words) +def _health_client(): + found = sockdiag.tcp_sockets(kernel((HEALTH_LIST4, HEALTH_DONE), (HEALTH_DONE,))) + return [s for s in found if s.local_port == 40172][0] + + +def test_one_socket_is_looked_up_by_its_id_and_its_tcp_info_reads_as_ss_read_it(): + """TCP health's request for the open client is byte for byte the one that kernel + answered, its cookie and the tcp_info bit in it; the answer's tcp_info reads as the + numbers ``ss -tin`` printed and the bytes the client's own socket sent.""" + client = _health_client() + talk = kernel((OPEN_ANSWER, LOOKUP_ACK)) + code, found = sockdiag.lookup(client.kernel_family, client.sockid, talk) + check("the request that kernel answered", talk.sent == [CLIENT_REQUEST], + f"({talk.sent[0].hex()})") + check("open, with its tcp_info of 280 bytes", code == 0 and found.state == "ESTABLISHED" + and len(found.info) == 280, f"({code}, {found and found.state})") + check("the fields as ss read them", sockdiag.tcp_info(found.info) == OPEN_INFO, + f"({sockdiag.tcp_info(found.info)})") + + +def test_a_socket_that_keeps_no_tcp_info_or_is_gone_is_answered_so(): + """Through the connection's life, as that kernel answered: a socket its program + closed (an orphan in FIN_WAIT2) and one in TIME_WAIT come without tcp_info, the + other side in CLOSE_WAIT still with it, a socket that is gone is ENOENT.""" + cases = ((CLOSED_BY_ITS_PROGRAM_ANSWER, 0, "FIN_WAIT_2", False), + (CLOSE_WAIT_ANSWER, 0, "CLOSE_WAIT", True), + (TIME_WAIT_ANSWER, 0, "TIME_WAIT", False)) + for answer, want_code, state, info in cases: + code, found = sockdiag.lookup(2, b"id", kernel((answer, LOOKUP_ACK))) + check(f"{state}: tcp_info {'kept' if info else 'none'}", code == want_code + and found.state == state and (found.info is not None) is info, + f"({code}, {found})") + code, found = sockdiag.lookup(2, b"id", kernel((GONE_ANSWER,))) + check("gone: ENOENT and nothing", code == 2 and found is None, f"({code}, {found})") + + +def test_without_its_cookie_a_gone_connection_is_answered_by_the_listener(): + """MEASURED, the reason ``lookup`` is always given the list's id: asked by its + numbers alone, the listener's side of a connection that had gone was answered by the + LISTENER on that port - with numbers that are not the connection's.""" + code, found = sockdiag.lookup(2, b"id", kernel((LISTENER_ANSWER, LOOKUP_ACK))) + check("the listener, with its own tcp_info", code == 0 and found.state == "LISTEN" + and found.info is not None) + check("the request was the gone side's numbers, no cookie", + NO_COOKIE_REQUEST[-8:] == bytes.fromhex("ffffffffffffffff") + and NO_COOKIE_REQUEST[:-8] == SERVED_REQUEST[:-8]) + + +def test_an_older_kernels_shorter_tcp_info_has_only_the_fields_inside_it(): + """Fields are only ever added at the end of ``struct tcp_info``: one past an older + kernel's length is left out, never read as 0 (Linux 6.7 added the timeout count at + 240, 4.19 the byte counts at 200).""" + raw = sockdiag.lookup(2, b"id", kernel((OPEN_ANSWER, LOOKUP_ACK)))[1].info + check("6.8's 248 bytes: all of them", sockdiag.tcp_info(raw[:248]) == OPEN_INFO) + check("5.15's 232: no timeout count", set(OPEN_INFO) - set(sockdiag.tcp_info(raw[:232])) + == {"total_rto"}) + check("before 4.19: no byte counts either", + set(OPEN_INFO) - set(sockdiag.tcp_info(raw[:200])) + == {"total_rto", "bytes_sent", "bytes_retrans"}) + check("nothing at all: nothing", sockdiag.tcp_info(b"") == {}) + + +def test_a_lookup_that_does_not_add_up_is_an_error(): + with pytest.raises(OSError, match="2 sockets for one id"): + sockdiag.lookup(2, b"id", kernel((OPEN_ANSWER, OPEN_ANSWER, LOOKUP_ACK))) + with pytest.raises(OSError, match="longer than its message"): + sockdiag.lookup(2, b"id", kernel((OPEN_ANSWER[:-8].replace( + bytes.fromhex("98010000"), bytes.fromhex("90010000"), 1), LOOKUP_ACK))) + + +def test_whether_the_kernel_lists_tcp_sockets_is_asked_with_a_list_of_nothing(): + """A list of no state with tcp_info asked: no rights, nothing walked, DONE - that + kernel's answer. A kernel without sock_diag's TCP part refuses it.""" + talk = kernel((HEALTH_DONE,)) + check("listed", sockdiag.lists(talk) is True) + check("the request that kernel answered", talk.sent == [CAN_LIST_REQUEST], + f"({talk.sent[0].hex()})") + refused = NOBODY_ANSWER[:16] + bytes.fromhex("feffffff") + NOBODY_ANSWER[20:] # -2 + check("refused: no", sockdiag.lists(kernel((refused,))) is False) + + @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="sock_diag is Linux's") def test_a_loopback_connection_of_this_process_is_in_the_real_list(): # pragma: no cover - Linux """The list needs no rights (measured as a user): a connection this test opens is in diff --git a/tests/test_tcpestats_linux.py b/tests/test_tcpestats_linux.py new file mode 100644 index 0000000..25c5575 --- /dev/null +++ b/tests/test_tcpestats_linux.py @@ -0,0 +1,236 @@ +"""TCP health on Linux (``tcpestats._Linux``): a connection found once in the kernel's list, +then read by the id the list gave - a real kernel's answers byte for byte. + +The answers are ``test_sockdiag.py``'s: Ubuntu 26.04.1, kernel 7.0.0-38, recorded +2026-10-27 by ``internal_tools`` probe ``2026-10-27-linux-tcphealth`` in a namespace of +its own - a client 127.0.0.1:40172 to a listener on 127.0.0.1:53325 that sent 3000 bytes +and received 500, through its life. What the list does with the readings over time is +``test_nettools_tcphealth.py``'s. +""" +import socket +import sys +import time + +import pytest + +from beantester import crashlog, sockdiag, tcpestats +from beantester.tcprow import Connection +from fakes import check +from test_sockdiag import (CLIENT_REQUEST, CLOSE_WAIT_ANSWER, CLOSED_BY_ITS_PROGRAM_ANSWER, + GONE_ANSWER, HEALTH_DONE, HEALTH_LIST4, LIST4_REQUEST, LIST6_REQUEST, + LISTENER_ANSWER, LOOKUP_ACK, NOBODY_ANSWER, OPEN_ANSWER, + TIME_WAIT_ANSWER, kernel) + +CLIENT = Connection("127.0.0.1", 40172, "127.0.0.1", 53325) +LIST = ((HEALTH_LIST4, HEALTH_DONE), (HEALTH_DONE,)) + + +def linux(*answers): + talk = kernel(*answers) + return tcpestats._Linux(talk), talk + + +def watched(): + """The client watched: its reading and the handle in it.""" + system, _talk = linux(*LIST, (OPEN_ANSWER, LOOKUP_ACK)) + return tcpestats.watch(CLIENT, system) + + +def test_watching_finds_the_connection_in_the_list_and_reads_it_by_the_id_given(): + system, talk = linux(*LIST, (OPEN_ANSWER, LOOKUP_ACK)) + got = tcpestats.watch(CLIENT, system) + check("both lists, then the lookup that kernel answered", + talk.sent == [LIST4_REQUEST, LIST6_REQUEST, CLIENT_REQUEST], + f"({[m.hex() for m in talk.sent]})") + check("open", got.state == tcpestats.OK, f"({got})") + sample = got.sample + check("the numbers as ss read them, in whole milliseconds rounded down", + (sample.smoothed_rtt, sample.min_rtt, sample.rtt_var, sample.rto, sample.mss) + == (0, 0, 0, 201, 48128), f"({sample})") + check("the bytes the client's socket sent and received", + (sample.bytes_out, sample.bytes_in, sample.resent_bytes, sample.resent_segments, + sample.timeouts) == (3000, 500, 0, 0, 0), f"({sample})") + check("what Linux keeps no number for, said absent", + sample.absent == tcpestats.LINUX_ABSENT and sample.widths == tcpestats.LINUX_WIDTHS) + check("the handle: the list's id, cookie in it", + got.handle == tcpestats.Handle(sockdiag.LINUX_AF_INET, CLIENT_REQUEST[-48:]), + f"({got.handle})") + + +def test_a_reading_is_asked_by_the_handle_alone_and_ends_where_the_kernel_says_so(): + """Through the connection's life as that kernel answered: still read while the other + side has it open (CLOSE_WAIT); ended once its program closed it, in TIME_WAIT, and + once it is gone (ENOENT) - each final, the numbers before it staying.""" + handle = watched().handle + system, talk = linux((CLOSE_WAIT_ANSWER, LOOKUP_ACK)) + check("CLOSE_WAIT: still open", tcpestats.read(CLIENT, system, handle).state == tcpestats.OK) + check("one lookup, by the handle: no list read", talk.sent == [CLIENT_REQUEST], + f"({[m.hex() for m in talk.sent]})") + for name, answer in (("closed by its program", (CLOSED_BY_ITS_PROGRAM_ANSWER, LOOKUP_ACK)), + ("TIME_WAIT", (TIME_WAIT_ANSWER, LOOKUP_ACK)), ("gone", (GONE_ANSWER,))): + got = tcpestats.read(CLIENT, linux(answer)[0], handle) + check(f"{name}: ended", got.state == tcpestats.ENDED and got.sample is None, f"({got})") + + +def test_a_listener_answering_for_a_connection_is_never_its_numbers(): + """The cookie keeps the listener out (measured: without it the listener answers for + a connection that has gone); a LISTEN answer is still ended, never read.""" + got = tcpestats.read(CLIENT, linux((LISTENER_ANSWER, LOOKUP_ACK))[0], watched().handle) + check("ended, no numbers", got.state == tcpestats.ENDED and got.sample is None, f"({got})") + + +def test_a_connection_not_in_the_list_or_named_twice_is_not_guessed_at(): + got = tcpestats.watch(Connection("127.0.0.1", 40173, "127.0.0.1", 53325), linux(*LIST)[0]) + check("not there: ended, nothing looked up", got.state == tcpestats.ENDED and got.handle is None, + f"({got})") + twice = HEALTH_LIST4[:124] * 2 + got = tcpestats.watch(CLIENT._replace(local_port=53325, remote_port=40172), + linux((twice, HEALTH_DONE), (HEALTH_DONE,))[0]) + check("two sockets with its numbers: a failure that says so", + got.state == tcpestats.FAILED and "2 sockets" in got.detail, f"({got})") + + +def test_a_socket_opened_as_ipv6_is_found_by_either_spelling(): + """psutil (the Sockets tab) writes a socket opened as IPv6 for an IPv4 address as + ``::ffff:127.0.0.1``, the kernel's list and a Connections row as ``127.0.0.1``.""" + system = linux(*LIST, (OPEN_ANSWER, LOOKUP_ACK))[0] + got = tcpestats.watch(Connection("::ffff:127.0.0.1", 40172, "::ffff:127.0.0.1", 53325), system) + check("found", got.state == tcpestats.OK, f"({got})") + + +def test_what_the_kernel_refuses_is_said_in_its_words_and_never_raised(monkeypatch): + noted = [] + monkeypatch.setattr(crashlog, "note", lambda exc, area: noted.append(area)) + refused = NOBODY_ANSWER[:16] + bytes.fromhex("f3ffffff") + NOBODY_ANSWER[20:] # -13 + got = tcpestats.read(CLIENT, linux((refused,))[0], watched().handle) + check("another errno: a failure with it", got.state == tcpestats.FAILED and got.code == 13 + and "errno 13" in got.detail, f"({got})") + got = tcpestats.watch(CLIENT, linux((refused,))[0]) + check("a list the kernel refused: a failure, not 'ended'", + got.state == tcpestats.FAILED and "refused" in got.detail, f"({got})") + check("neither is a fault of the program", noted == [], f"({noted})") + + def broken(_message): + raise RuntimeError("a fault in the talk on purpose") + got = tcpestats.read(CLIENT, tcpestats._Linux(broken), watched().handle) + check("a fault: a failure, noted", got.state == tcpestats.FAILED and noted == ["tcpestats"], + f"({got}, {noted})") + + +def test_a_watch_that_found_no_socket_looks_for_it_again(monkeypatch): + """A failed watch carries no handle and stays on the list (a failure is read again): + the next reading looks the connection up anew - found, it is read; still not + listable, the same failure in the kernel's words. Never a fault of the program.""" + noted = [] + monkeypatch.setattr(crashlog, "note", lambda exc, area: noted.append(area)) + refused = NOBODY_ANSWER[:16] + bytes.fromhex("f3ffffff") + NOBODY_ANSWER[20:] # -13 + first = tcpestats.watch(CLIENT, linux((refused,))[0]) + check("the watch failed, with no handle", first.state == tcpestats.FAILED + and first.handle is None, f"({first})") + again = tcpestats.read(CLIENT, linux((refused,))[0], first.handle) + check("still not listable: the same failure", again.state == tcpestats.FAILED + and "refused" in again.detail, f"({again})") + system, talk = linux(*LIST, (OPEN_ANSWER, LOOKUP_ACK)) + found = tcpestats.read(CLIENT, system, first.handle) + check("listable now: found and read, with its handle", found.state == tcpestats.OK + and found.handle is not None + and talk.sent == [LIST4_REQUEST, LIST6_REQUEST, CLIENT_REQUEST], f"({found})") + check("no fault noted", noted == [], f"({noted})") + + +def test_linux_is_asked_whether_it_lists_and_never_needs_rights(monkeypatch): + def no_socket(_message): + raise OSError(97, "Address family not supported by protocol") + check("no sock_diag at all: cannot list", tcpestats._Linux(no_socket).can_list() is False) + check("a list of nothing answered: can", tcpestats._Linux(kernel((HEALTH_DONE,))).can_list()) + monkeypatch.setattr(tcpestats.sys, "platform", "linux") + monkeypatch.setattr(tcpestats._Linux, "can_list", lambda self: True) + check("a kernel that lists: supported", tcpestats.supported() is True) + monkeypatch.setattr(tcpestats._Linux, "can_list", lambda self: False) + check("one that does not: not", tcpestats.supported() is False) + check("rights: none needed", tcpestats.needs_rights() is False) + monkeypatch.setattr(tcpestats.sys, "platform", "win32") + check("Windows: supported, rights needed", + tcpestats.supported() is True and tcpestats.needs_rights() is True) + + +def test_a_connections_row_is_looked_up_in_the_kernels_list_on_linux(monkeypatch): + """psutil would write a dual-stack socket as ``::ffff:`` and the row would not find + it; the kernel's list writes the address the row has.""" + monkeypatch.setattr(tcpestats.sys, "platform", "linux") + system = tcpestats._Linux(kernel(*LIST)) + monkeypatch.setattr(tcpestats, "_Linux", lambda: system) + rows, failed = tcpestats.socket_rows() + check("the kernel's list", failed == [] and {(r.local_port, r.remote_port) for r in rows} + == {(40172, 53325), (53325, 40172)}, f"({rows})") + monkeypatch.setattr(tcpestats.sys, "platform", "win32") + monkeypatch.setattr(tcpestats.portmap, "socket_rows", lambda: (["windows"], [])) + check("Windows: the socket table", tcpestats.socket_rows() == (["windows"], [])) + + +def test_linux_has_its_own_sheet_tips_and_legend_and_windows_keeps_its_keys(): + """The "?" sheet is about what Windows measures and when; two column tips and the + Stop tip name Windows; Linux keeps no newest single round trip to chart.""" + from beantester.gui.toolbox import tcphealth as panel + check("Windows: today's sheet, tips, Stop tip and both lines", + (panel.help_key("win32"), panel.tips("win32"), panel.stop_tip("win32"), + panel.legend_lines("win32")) + == ("tools.tcphealth.help_body", panel.TIPS, "tips.tools_tcphealth_stop", + panel.LEGEND) and len(panel.LEGEND) == 2) + linux_tips = panel.tips("linux") + check("Linux: its own sheet, the state and resent tips, its Stop tip", + panel.help_key("linux") == "tools.tcphealth.help_linux" + and {k for k in panel.TIPS if panel.TIPS[k] != linux_tips[k]} == {"state", "resent"} + and panel.stop_tip("linux") == "tips.tools_tcphealth_stop_linux") + check("Linux: the smoothed line alone", + [key for _style, key in panel.legend_lines("linux")] == ["tools.tcphealth.legend_smoothed"]) + + +def test_the_linux_words_have_a_text_in_every_language(): + import json + import os + keys = {"tools.tcphealth.help_linux", "tools.tcphealth.item.lowest", + "tools.tcphealth.item.resent_segments", "tips.tools_tcphealth_col_state_linux", + "tips.tools_tcphealth_col_resent_linux", "tips.tools_tcphealth_stop_linux"} + for lang in ("en", "pl", "zh"): + with open(os.path.join(os.path.dirname(__file__), "..", "lang", f"{lang}.json"), + encoding="utf-8") as f: + texts = json.load(f) + missing = sorted(k for k in keys if not texts.get(k)) + check(f"{lang}: every Linux word has a text", not missing, f"({missing})") + check(f"{lang}: the Linux sheet never says Windows", + "Windows" not in texts["tools.tcphealth.help_linux"]) + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="sock_diag is Linux's") +def test_a_real_loopback_connection_counts_exactly_the_bytes_it_sent(): # pragma: no cover - Linux + """On this runner's kernel, without rights: watched, the bytes its socket sent and + received; closed, ended.""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("127.0.0.1", 0)) + server.listen(1) + client = socket.create_connection(server.getsockname(), timeout=5) + peer, _ = server.accept() + try: + client.sendall(b"x" * 12345) + got = 0 + while got < 12345: + got += len(peer.recv(65536)) + peer.sendall(b"y" * 678) + got = 0 + while got < 678: + got += len(client.recv(65536)) + time.sleep(0.2) + connection = Connection(*client.getsockname(), *client.getpeername()) + reading = tcpestats.watch(connection) + check("open", reading.state == tcpestats.OK, f"({reading})") + check("its bytes, exactly", (reading.sample.bytes_out, reading.sample.bytes_in) + == (12345, 678), f"({reading.sample})") + client.close() + peer.close() + time.sleep(0.2) + check("closed: ended", tcpestats.read(connection, handle=reading.handle).state + == tcpestats.ENDED) + finally: + for sock in (client, peer, server): + sock.close() diff --git a/tests/test_toolbox.py b/tests/test_toolbox.py index f72c1f1..0e97e2f 100644 --- a/tests/test_toolbox.py +++ b/tests/test_toolbox.py @@ -1013,9 +1013,11 @@ class Ev: # -- TCP health ---------------------------------------------------------------------- # # The socket table above, and Windows' EStats stood in for at ``tcpestats.watch`` and # ``tcpestats.read`` - what those answer is test_tcpestats.py's. ``ANSWER[0]`` is what -# every reading says; ``admin`` what the process's token says. +# every reading says; ``admin`` what the process's token says. Windows' rules on either +# runner: rights needed, and a Connections row looked up in ``portmap``'s table (a test +# stands that one in) - on the Linux runner the system's would be the kernel's own. TCPH = SOCK + """ -from beantester import driver, tcpestats +from beantester import driver, portmap, tcpestats from beantester.nettools import tcphealth as th from beantester.tcprow import Connection from beantester.gui.toolbox.tcphealth import render as health_cells @@ -1030,11 +1032,13 @@ def est_watch(connection, system=None): watched.append(connection) return ANSWER[0] -def est_read(connection, system=None): +def est_read(connection, system=None, handle=None): readings.append(connection) return ANSWER[0] tcpestats.supported = lambda: True +tcpestats.needs_rights = lambda: True +tcpestats.socket_rows = lambda: portmap.socket_rows() tcpestats.watch = est_watch tcpestats.read = est_read driver.is_admin = lambda: admin[0] diff --git a/tools/ci_linux_capture_smoke.py b/tools/ci_linux_capture_smoke.py index 6546330..50742ba 100644 --- a/tools/ci_linux_capture_smoke.py +++ b/tools/ci_linux_capture_smoke.py @@ -32,6 +32,12 @@ other. And a connection that closed after the socket list was read: its destroy ends as "gone", and the listener on its port lives on (by its numbers alone, without the cookie, the kernel took the listener - measured 2026-10-26). +* The Tools tab's TCP health, its own ``tcpestats.watch`` and ``read``, on a TCP + connection from the namespace: its numbers equal what ``ss -tin`` says of that socket + at the same moment (resend timer, segment size, round trip, bytes sent, received and + sent again); during ``--latency 50`` on its port the smoothed round trip it reports + climbs toward both directions' 50 ms - the tool's promise, "does the stack see my + latency"; closed, the connection reads as ended. * The Tools tab's Adapters, its own ``adapters.read``, against ``ip -j`` of the same tables: in the namespace, given an address with a lease, a deprecated one and a duplicate (the same IPv6 address on both ends of a veth pair, so DAD finds it), each @@ -145,6 +151,56 @@ " listener = type(exc).__name__\n" "print(json.dumps({'found': len(served), 'stale': stale, 'listener': listener}))\n") +HEALTH_PORT = 47104 +HEALTH_ROUNDS = 20 # echoes of ~2 x 50 ms + 0.1 s each under the session: about 4 s +# What the far end says first: an echo alone sends as many bytes as it receives, and the +# two counters swapped would pass. +HEALTH_GREETING = b"bnt says hello\n" + +# Run INSIDE the namespace, from the checkout: one TCP connection to the far end's echo, +# watched by the Tools tab's own TCP health and read by `ss -tin` at the same moment; +# then, at a line on stdin, an echo every 0.1 s (a session adds its latency meanwhile) +# and a read; then closed, and read once more. +HEALTH_CLIENT = ( + "import json, socket, subprocess, sys, time\n" + "from beantester import tcpestats\n" + "from beantester.tcprow import Connection\n" + "host, port, rounds, greeting = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])\n" + "s = socket.create_connection((host, port), timeout=5)\n" + "s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)\n" + "def heard(n, when):\n" + " got = 0\n" + " while got < n:\n" + " part = s.recv(n - got)\n" + " if not part:\n" + " raise ConnectionError('the far end closed ' + when)\n" + " got += len(part)\n" + "heard(greeting, 'before its greeting')\n" + "def echo(n):\n" + " for _ in range(n):\n" + " s.sendall(b'h' * 100)\n" + " heard(100, 'during an echo')\n" + " time.sleep(0.1)\n" + "def said(r):\n" + " x = r.sample\n" + " return {'state': r.state, 'srtt': x and x.smoothed_rtt, 'rto': x and x.rto,\n" + " 'mss': x and x.mss, 'sent': x and x.bytes_out, 'received': x and x.bytes_in,\n" + " 'resent': x and x.resent_bytes}\n" + "echo(5)\n" + "time.sleep(0.3)\n" + "c = Connection(s.getsockname()[0], s.getsockname()[1], host, port)\n" + "r = tcpestats.watch(c)\n" + "ss = subprocess.run(['ss', '-tinH', 'state', 'all', '( sport = :%d )' % c.local_port],\n" + " capture_output=True, text=True, timeout=10).stdout\n" + "print(json.dumps({**said(r), 'ss': ss}), flush=True)\n" + "sys.stdin.readline()\n" + "echo(rounds)\n" + "during = said(tcpestats.read(c, handle=r.handle))\n" + "s.close()\n" + "time.sleep(0.3)\n" + "during['closed'] = tcpestats.read(c, handle=r.handle).state\n" + "print(json.dumps(during), flush=True)\n") + # Run INSIDE the namespace (and once outside it), from the checkout: the Adapters tool's # own read (adapters.read), and what `ip -j` says of the same tables. @@ -346,6 +402,54 @@ def judge_adapters(seen, where, states=False, private=False): return failures +SS_FIELDS = ("rto", "rtt", "mss", "bytes_sent", "bytes_received", "bytes_retrans") + + +def _ss_numbers(text): + """The fields of an ``ss -tin`` line TCP health is judged by, as ``ss`` wrote them.""" + out = {} + for token in text.split(): + key, sep, value = token.partition(":") + if sep and key in SS_FIELDS: + out[key] = value + return out + + +def judge_health(watched, during, latency_ms=LATENCY_MS): + """Failures (empty = pass) for the TCP health phase. ``watched`` is the reading when + the connection was put on the list, with ``ss -tin``'s line of the same moment; + ``during`` the reading after the echoes through ``--latency``, and ``closed`` the + state once the connection was closed. Whole milliseconds rounded down, as the tool + says them; ``ss`` leaves out bytes_retrans while it is 0. The far end's greeting + makes the bytes received differ from the bytes sent.""" + if watched.get("state") != "ok": + return [f"tcp health: watching ended {watched.get('state')!r}, not 'ok'"] + ss = _ss_numbers(watched.get("ss") or "") + failures = [] + try: + want = {"rto": int(float(ss["rto"])), "srtt": int(float(ss["rtt"].split("/")[0])), + "mss": int(ss["mss"]), "sent": int(ss["bytes_sent"]), + "received": int(ss["bytes_received"]), "resent": int(ss.get("bytes_retrans", 0))} + except (KeyError, ValueError): + return [f"tcp health: ss said no numbers for the connection: {watched.get('ss')!r}"] + for name, value in want.items(): + if watched.get(name) != value: + failures.append(f"tcp health: {name} {watched.get(name)}, ss said {value}") + added = 2 * latency_ms # each direction carries the latency + if want["srtt"] >= latency_ms / 2: + failures.append(f"tcp health: {want['srtt']} ms before the session - the machine is " + "too slow to tell") + # A smoothed value, not one round trip: one slow sample pulls it up for seconds (232 ms + # through this session on the VM, 2026-10-27), so the top only catches a wrong unit. + srtt = during.get("srtt") + if during.get("state") != "ok" or srtt is None or not 0.6 * added <= srtt <= 5 * added: + failures.append(f"tcp health: smoothed {srtt} ms ({during.get('state')}) through the " + f"session, expected toward {added} ms") + if during.get("closed") != "ended": + failures.append(f"tcp health: closed, it read {during.get('closed')!r}, not 'ended'") + return failures + + def skip_reason(platform, euid, library_loaded): """Why this machine cannot run the check, or None.""" if not platform.startswith("linux"): @@ -603,6 +707,58 @@ def _reset_phase(): far.close() +def start_tcp_echo(port): + """A TCP echo in the root namespace, in THIS process: the far end TCP health watches.""" + far = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + far.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + far.bind((HOST, port)) + far.listen(4) + + def serve(): + try: + conn = far.accept()[0] + except OSError: # closed before anyone connected + return + with conn: + conn.sendall(HEALTH_GREETING) + while data := conn.recv(4096): + conn.sendall(data) + threading.Thread(target=serve, daemon=True).start() + return far + + +def _health_phase(): + far = start_tcp_echo(HEALTH_PORT) + client = None + try: + client = subprocess.Popen( + in_namespace(sys.executable, "-c", HEALTH_CLIENT, HOST, str(HEALTH_PORT), + str(HEALTH_ROUNDS), str(len(HEALTH_GREETING))), + cwd=ROOT, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True) + watched = json.loads(client.stdout.readline() or "{}") + + def during(): + client.stdin.write("go\n") + client.stdin.flush() + return json.loads(client.stdout.readline() or "{}") + result, lines, code = run_session( + ["--dst-ip", HOST, "--dst-port", str(HEALTH_PORT), "--latency", str(LATENCY_MS), + "--duration", "10"], during) + failures = _session_failures("tcp health", result, lines, code) + failures += judge_health(watched, result or {}) + print("tcp health:", json.dumps({"watched": {k: watched.get(k) for k in + ("state", "srtt", "rto", "mss")}, + "during": result})) + return failures + finally: + if client is not None: + if client.poll() is None: + client.kill() + client.wait(10) + far.close() + + def _read_adapters(argv): r = subprocess.run([*argv, sys.executable, "-c", ADAPTERS_CLIENT], cwd=ROOT, capture_output=True, text=True, timeout=60) @@ -667,6 +823,7 @@ def main(argv=None, environment=None): failures += _loss_phase() failures += _ping_phase() failures += _reset_phase() + failures += _health_phase() failures += _adapters_phase() # last: it gives the namespace a default route except Exception as exc: # a phase that could not run is a failure failures.append(f"{type(exc).__name__}: {exc}")