Skip to content

Commit 53f1e57

Browse files
authored
fix(reassembly): the deferred TCP analyser dispatches on AppType, not bare ports (#2421)
Follow-up to #2418, which made `Deferred` generic over what its analyser dispatches on but left the TCP side of that constraint as `tuple[int, int]`. The owner's note on it: we are still using int tuples for transport ports, so move to AppType tuples. * `TCP.submit` resolves `bufid[1]` / `bufid[3]` through `Transport._make_port` before building the `Deferred`, in a new `TCP._make_ports` helper. A deferred analysis now dispatches on a named service rather than on a number. * `_KT`'s second constraint is `tuple[AppType_TCP, AppType_TCP]` -- the per-transport registry `pcapkit.const.reg.apptype.tcp.TCP`, which is what the resolution really answers with (`type(...) is TCP`, measured), not the base `AppType`. `Transport.analyze` keeps the base, being the one method TCP, UDP and SCTP share, and says so. * `Transport.analyze` tests the chosen port against `__proto__` exactly as it arrived and unwraps it to the registry's `int` key only when it is a member, so a bare `int` caller keeps working -- the method is in v1.4.1 -- and `(None, None)` or `(54321, '80')` still fall back to `Raw` as on mainline rather than raising or dispatching. * `_make_ports` reaches `_make_port` through `TCP` rather than through `self.protocol`: `__protocol_type__` is an extension point whose analyser need only define `analyze`, so `self.protocol._make_port` would be an `AttributeError` for one that is not a `Transport`. Going through `_make_port` rather than calling `AppType.get` directly is what keeps #2287's bool rejection: `AppType.get(True, proto=tcp)` resolves `True` as port 1 and answers `<TCP.tcpmux: 1 [tcp]>`, measured. * `DatagramID`'s ports stay bare `int`s, and its docstring now says why: it is the flow identity split out of `BufferID`, a dict key every toolkit adapter builds from a plain port number and which is dumped as part of the report. * Docs entries for `TCP._make_ports` and for `AppType.__hash__`, both now named in public prose (#1940). All 65,536 ports resolve to a member -- an assignment to its canonical service, an IANA span to that span's label, anything else to an unregistered `unknown` -- none raise and the registry does not grow, so this adds no way for reassembly to raise. For a registered port the member is the one `TCP.src` reads off a parsed header; for an unassigned one the two are equal but not identical and named apart, which the docstrings record rather than overstate. UDP and SCTP need no change: neither has a reassembler, so `Transport.analyze` has no caller but `TCP.submit`. Make an unregistered member survive pickling -------------------------------------------- Dispatching on members rather than numbers put them inside the `Deferred` that `TCP.submit` stores as a `Datagram`'s `packet`, and that made an unresolved datagram unpicklable for most ports. `AppType.get` answers a port it holds no assignment for through `AppType._unregistered_member`, which builds a member deliberately absent from every one of the registry's lookup tables so the lookup does not grow it (#860). `Enum.__reduce_ex__` reduces a member to `(cls, (value,))` -- a value-keyed lookup, i.e. the one table such a member is missing from -- so it pickled fine and then raised on read-back: ValueError: 'unknown [50000 - tcp]' is not a valid TCP Measured before the fix: 59,413 of the 65,536 possible TCP ports failed to round-trip, 90.7% of the space, and not merely the truly unregistered `unknown` ones but every IANA-span label too. All four sample captures with TCP flows failed, every datagram, because a client socket is ephemeral. * `EnumRegistry._make_reducible` installs a per-instance `__reduce_ex__` that rebuilds through `cls._unregistered_member(*args)` instead of through a value lookup, with module-level `_rebuild_unregistered_member` / `_reduce_unregistered_member` so that pickle protocols below 4 can name the callable. This is the technique `EnumField._unregistered_member` already uses for the same class of problem on the *parse* path (#575, #1774). * `AppType._unregistered_member` opts in, reducing to its own `(value, name, proto)` rather than to the crafted `'svc [port - proto]'` string it hands its base -- reducing to the latter would rebuild a member whose value had been formatted twice. It is opt-in per registry for exactly that reason; a registry whose `_missing_` calls the base method directly is unchanged. * Rebuilding re-enters `_unregistered_member` and never `cls.__new__`, so an unpickled member is unregistered just as the original was and no table grows -- `__members__`, `_value2member_map_` and `__registry__` all measured unchanged before and after, on every transport. A restored member is itself reducible, so a datagram read back from a dump can be written out again. After: all 65,536 ports round-trip on every transport (262,144 members), on every pickle protocol 0-5, under `copy.copy`/`copy.deepcopy`, and all four named captures load back -- 231 deferred datagrams across `http.pcap`, `stream.pcap`, `tcp.pcap` and `http6.cap`. A registered member still unpickles to the identical shared singleton, which pins that the reducer went on the unregistered path only. * `tests/const/test_const_apptype_unregistered_pickle_2417_unit.py` pins the member-level round trip over the whole port space, every protocol, and the no-growth property. * `tests/foundation/reassembly/test_datagram_deferred_pickle_2417_unit.py` pins the symptom on a synthesised datagram, unassigned and assigned alike. `test_datagram_deferred_pickle_2417_runtime.py` pins the same thing end to end over the four sample captures, split into its own fixture-dependent module because CI's unit-tests leg never generates them -- the unit file reading a generated capture directly is what broke that leg. Both scope to pickle protocols 2-5: `Deferred` declares `__slots__` and no `__getstate__`, which protocols 0 and 1 cannot serialise at all. That is pre-existing and independent of these members -- it reproduces for an assigned flow and for a bare `Deferred(..., 42, b'x')` holding a plain `int` -- so it is recorded there rather than worked around, and wants its own issue. * The #1774 canary asserted that `get`'s miss member was *not* picklable, which was a defect rather than a distinction. It now asserts the round trip works and that the member's `name` is the only remaining difference from what a parse returns -- which is what still justifies the peek. `AppType.__hash__` gained the docstring its new `#1940` entry requires. mypy over `pcapkit/` is byte-identical to mainline (98 errors in 32 files, same set); `pylint` with the Makefile flags reports the same message set and counts over the touched files as the pre-review revision; `isort -l100 -ppcapkit` is clean. Tests: 876 tests / 25,888 subtests over `foundation/reassembly`, `protocols/transport`, the `const` registries, the field-pickling suites, the `#1940` docs guard and the docstring contract, with 4 skips that predate this branch (the local venv has sphinx-autodoc-typehints 3.13.6, not the 3.17.2 `docs/requirements.txt` pins). Reassembly over the sample captures resolves the same next layer as before, port for port.
1 parent 867616b commit 53f1e57

15 files changed

Lines changed: 1153 additions & 61 deletions

File tree

‎docs/source/pcapkit/const/reg.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ registry is one enumeration per transport protocol sharing a memberless base.
109109
:members:
110110
:undoc-members:
111111
:show-inheritance:
112-
:special-members: __new__, __repr__, __str__, __int__, __eq__, __registry__, __canonical__
112+
:special-members: __new__, __repr__, __str__, __int__, __eq__, __hash__, __registry__, __canonical__
113113

114114
.. module:: pcapkit.const.reg.apptype.tcp
115115

‎docs/source/pcapkit/foundation/reassembly/tcp.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ which reconstructs fragmented TCP packets back to origin.
1414

1515
.. automethod:: reassembly
1616
.. automethod:: submit
17+
.. automethod:: _make_ports
1718

1819
.. autoattribute:: __protocol_name__
1920
.. autoattribute:: __protocol_type__

‎pcapkit/const/reg/apptype/apptype.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2497,6 +2497,17 @@ def __ne__(self, other: 'Any') -> 'bool':
24972497
return self.port != other
24982498

24992499
def __hash__(self) -> 'int':
2500+
"""Hash the member by its port number, as :meth:`__eq__` compares it.
2501+
2502+
Keeping the two consistent is what lets a member and the bare
2503+
:obj:`int` port it carries be the same key of an :obj:`int`-keyed
2504+
mapping -- which is how :meth:`Transport.analyze
2505+
<pcapkit.protocols.transport.transport.Transport.analyze>` reads
2506+
:attr:`Transport.__proto__
2507+
<pcapkit.protocols.transport.transport.Transport.__proto__>` with
2508+
either one.
2509+
2510+
"""
25002511
return hash(self.port)
25012512

25022513
@classmethod
@@ -2941,6 +2952,22 @@ def _unregistered_member(cls, value: 'int', name: 'str',
29412952
exactly the one this registry must not remember past the call that
29422953
built it.
29432954
2955+
The member is made reducible through
2956+
:meth:`~pcapkit.corekit.enum.EnumRegistry._make_reducible` before it is
2957+
returned, so that it survives :mod:`pickle` and :mod:`copy`. Without
2958+
that, ``Enum.__reduce_ex__`` would reduce it to a lookup of its own
2959+
crafted value -- ``TCP('unknown [54321 - tcp]')`` -- which
2960+
:meth:`_missing_` rejects as not a port, so the member pickled fine and
2961+
then raised :exc:`ValueError` on read-back. That bit reassembly, whose
2962+
:class:`~pcapkit.foundation.reassembly.data.tcp.Datagram` holds these
2963+
members in an unresolved
2964+
:class:`~pcapkit.foundation.reassembly.data.data.Deferred`: measured
2965+
before the fix, 59,413 of the 65,536 possible ports -- every
2966+
``unknown`` and every IANA-span label alike, not just the truly
2967+
unregistered ones -- failed to round-trip. Rebuilding re-enters this
2968+
method, so an unpickled member is unregistered exactly as the original
2969+
was and neither lookup table nor :data:`__registry__` grows.
2970+
29442971
Args:
29452972
value: Port number.
29462973
name: Bare label for the unregistered member -- what
@@ -2956,7 +2983,9 @@ def _unregistered_member(cls, value: 'int', name: 'str',
29562983
obj.svc = name
29572984
obj.port = value
29582985
obj.proto = proto
2959-
return obj
2986+
# NOTE: reduce to this method's own (value, name, proto), not to the
2987+
# crafted ``temp`` the base was handed -- see _make_reducible's note.
2988+
return cls._make_reducible(obj, value, name, proto)
29602989

29612990
@classmethod
29622991
def _missing_(cls, value: 'int') -> 'Optional[AppType]':

‎pcapkit/corekit/enum.py‎

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
an unknown value does not mint one (:issue:`775`).
9797
9898
"""
99+
import functools
99100
from typing import TYPE_CHECKING
100101

101102
from aenum import extend_enum
@@ -104,7 +105,7 @@
104105
from pcapkit.utilities.exceptions import BaseError, CaughtByCaller, EnumKeyError, EnumValueError
105106

106107
if TYPE_CHECKING:
107-
from typing import Any
108+
from typing import Any, Callable, Type
108109

109110
from typing_extensions import Self
110111

@@ -709,3 +710,97 @@ def _unregistered_member(cls, value: 'Any', name: 'str') -> 'Self':
709710
obj._name_ = name # pylint: disable=protected-access
710711
obj._value_ = value # pylint: disable=protected-access
711712
return obj
713+
714+
@classmethod
715+
def _make_reducible(cls, obj: 'Any', *args: 'Any') -> 'Any':
716+
"""Make an unregistered member survive :mod:`pickle` and :mod:`copy`.
717+
718+
``Enum.__reduce_ex__`` reduces a member to ``(cls, (value,))``, i.e. to
719+
a *value*-keyed lookup -- the one table an unregistered member is
720+
deliberately absent from. So a member :meth:`_unregistered_member`
721+
built pickles happily and then raises :exc:`ValueError` on read-back,
722+
where the failure is a long way from its cause.
723+
724+
This replaces that reduction with one through
725+
:func:`_rebuild_unregistered_member`, which re-enters
726+
``cls._unregistered_member(*args)``. Rebuilding therefore takes exactly
727+
the path that built the original and never ``cls.__new__``, so an
728+
unpickled member is unregistered just as the original was and no
729+
lookup table grows. The rebuilt member is itself reducible, since the
730+
method it comes back through installs this again.
731+
732+
The override goes on the *instance* rather than on the type, which both
733+
:mod:`pickle` and :mod:`copy` honour: each fetches ``__reduce_ex__``
734+
with :func:`getattr` on the object. :func:`functools.partial` rather
735+
than a closure keeps the member itself out of its own reducer, so being
736+
reducible does not put it in a reference cycle.
737+
738+
Note:
739+
This is opt-in per registry rather than done by
740+
:meth:`_unregistered_member` itself, because the arguments to
741+
rebuild *from* are the overriding method's own, not this base
742+
method's. :class:`~pcapkit.const.reg.apptype.apptype.AppType`,
743+
for one, hands its base the crafted ``'svc [port - proto]'``
744+
string while its own entry point takes ``(port, name, proto)``;
745+
reducing to the former would rebuild a member whose value had been
746+
formatted twice. A registry whose ``_missing_`` calls
747+
:meth:`_unregistered_member` directly is unaffected and keeps
748+
reducing the way ``Enum`` does.
749+
750+
Args:
751+
obj: The unregistered member to install the reducer on.
752+
*args: The arguments ``cls._unregistered_member`` should be called
753+
with to rebuild an equivalent member.
754+
755+
Returns:
756+
``obj``, for use as a ``return`` expression.
757+
758+
"""
759+
obj.__reduce_ex__ = functools.partial(_reduce_unregistered_member, cls, args)
760+
return obj
761+
762+
763+
def _rebuild_unregistered_member(namespace: 'Type[EnumRegistry]',
764+
args: 'tuple[Any, ...]') -> 'Any':
765+
"""Rebuild the member :meth:`EnumRegistry._make_reducible` reduced.
766+
767+
This is what :mod:`pickle` and :mod:`copy` reconstruct through, in place of
768+
the value lookup ``Enum.__reduce_ex__`` would have reduced the member to.
769+
It is a module-level function rather than a method so that every
770+
:mod:`pickle` protocol can name it: protocols below 4 cannot reference a
771+
callable nested inside a class.
772+
773+
Args:
774+
namespace: The registry class to rebuild the member as an instance of.
775+
args: The arguments to ``namespace._unregistered_member``.
776+
777+
Returns:
778+
A member equal to the original and, like it, absent from every one of
779+
``namespace``'s lookup tables.
780+
781+
"""
782+
return namespace._unregistered_member(*args) # pylint: disable=protected-access
783+
784+
785+
def _reduce_unregistered_member( # pylint: disable=unused-argument
786+
namespace: 'Type[EnumRegistry]', args: 'tuple[Any, ...]',
787+
protocol: 'int') -> 'tuple[Callable[..., Any], tuple[Any, ...]]':
788+
"""The ``__reduce_ex__`` :meth:`EnumRegistry._make_reducible` installs.
789+
790+
Bound to its first two arguments with :func:`functools.partial`, so that
791+
the reducer holds the ingredients of the member rather than the member
792+
itself.
793+
794+
Args:
795+
namespace: The registry class the member is an instance of.
796+
args: The arguments to rebuild the member from.
797+
protocol: The :mod:`pickle` protocol version, as passed by
798+
:mod:`pickle` and :mod:`copy`. Unused -- the reduction is the same
799+
for every protocol -- but accepted because both call this with it.
800+
801+
Returns:
802+
The callable and arguments :mod:`pickle` and :mod:`copy` should rebuild
803+
the member through.
804+
805+
"""
806+
return (_rebuild_unregistered_member, (namespace, args))

‎pcapkit/foundation/reassembly/data/data.py‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@
1212
if TYPE_CHECKING:
1313
from typing import Callable, Optional
1414

15+
from pcapkit.const.reg.apptype import TCP as AppType_TCP
1516
from pcapkit.const.reg.transtype import TransType
1617
from pcapkit.foundation.reassembly.data.ip import Datagram as IP_Datagram
1718
from pcapkit.foundation.reassembly.data.tcp import Datagram as TCP_Datagram
1819
from pcapkit.protocols.protocol import ProtocolBase
1920

2021
# registry key an analyser dispatches a reassembled payload on; see ``Deferred``
21-
_KT = TypeVar('_KT', 'TransType', 'tuple[int, int]')
22+
_KT = TypeVar('_KT', 'TransType', 'tuple[AppType_TCP, AppType_TCP]')
2223

2324

2425
class Completion(EnumLookup, StrEnum):
@@ -148,23 +149,37 @@ class Deferred(Generic[_KT]):
148149
:meth:`Protocol.analyze <pcapkit.protocols.protocol.Protocol.analyze>`,
149150
which looks the next layer up by protocol registry number.
150151
* :meth:`TCP.submit <pcapkit.foundation.reassembly.tcp.TCP.submit>` builds a
151-
``Deferred[tuple[int, int]]``. It passes a ``(source, destination)`` port
152-
pair, because :meth:`Transport.analyze
152+
``Deferred[tuple[AppType_TCP, AppType_TCP]]``. It passes a
153+
``(source, destination)`` port pair, because :meth:`Transport.analyze
153154
<pcapkit.protocols.transport.transport.Transport.analyze>` overrides that
154155
parameter to one: a reassembled transport-layer payload carries no
155156
protocol number, so its application protocol is inferred from the ports.
156157
157-
Those are the two cases the type parameter is constrained to. The pairing
158-
the regression actually produced -- a port pair handed to an analyser
159-
declared over :class:`~pcapkit.const.reg.transtype.TransType` -- is
160-
rejected, which is the direction a real mismatch takes and the one
158+
Those are the two cases the type parameter is constrained to. Both are
159+
*named registry values* rather than one named value and one pair of bare
160+
port numbers: a port is a member of
161+
:class:`~pcapkit.const.reg.apptype.tcp.TCP` here -- the per-transport
162+
registry itself, not the base
163+
:class:`~pcapkit.const.reg.apptype.apptype.AppType`, since that is what
164+
:meth:`TCP._make_ports <pcapkit.foundation.reassembly.tcp.TCP._make_ports>`
165+
really answers with -- so what the deferred analysis dispatches on is of a
166+
piece with what the eager path dispatches on at
167+
:attr:`TCP.src <pcapkit.protocols.transport.tcp.TCP.src>`, whichever
168+
reassembler built it. (Of a piece, not identical: for an *unassigned* port
169+
the two paths build equal members by different routes, which
170+
``TCP.submit``'s own docstring records.)
171+
172+
The pairing the regression actually produced -- a port pair handed to an
173+
analyser declared over :class:`~pcapkit.const.reg.transtype.TransType` --
174+
is rejected, which is the direction a real mismatch takes and the one
161175
:issue:`2417` was about.
162176
163177
Rejecting the *reverse* pairing is not guaranteed by mypy alone, for a
164178
reason outside this class: |aenum|_ ships no type stubs, so under
165179
``ignore_missing_imports`` the ``aenum.IntEnum`` base of
166180
:class:`~pcapkit.const.reg.transtype.TransType` resolves to ``Any``. A
167-
``TransType`` is therefore accepted where a ``tuple[int, int]`` is
181+
``TransType`` is therefore accepted where a
182+
``tuple[AppType_TCP, AppType_TCP]`` is
168183
expected, and a ``Deferred[TransType]`` built over the port-pair analyser
169184
type-checks clean. That is a gap in the dependency's type coverage rather
170185
than in the constraint, which would bite both ways were ``TransType`` fully

‎pcapkit/foundation/reassembly/data/tcp.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from typing_extensions import TypeAlias
2121

22+
from pcapkit.const.reg.apptype import TCP as AppType_TCP
2223
from pcapkit.protocols.protocol import ProtocolBase
2324

2425
_AT = TypeVar('_AT', 'IPv4Address', 'IPv6Address')
@@ -84,7 +85,26 @@ def __init__(self, bufid: 'BufferID', dsn: 'int', ack: 'int', num: 'int', syn: '
8485

8586
@info_final
8687
class DatagramID(Info, Generic[_AT]):
87-
"""Data model for :term:`TCP <reasm.tcp.datagram>` original packet identifier."""
88+
"""Data model for :term:`TCP <reasm.tcp.datagram>` original packet identifier.
89+
90+
Note:
91+
The ports here stay bare :obj:`int` port numbers, where the pair
92+
:meth:`TCP.submit <pcapkit.foundation.reassembly.tcp.TCP.submit>` hands
93+
:class:`~pcapkit.foundation.reassembly.data.data.Deferred` is one of
94+
:class:`~pcapkit.const.reg.apptype.tcp.TCP` members
95+
(:issue:`2417`). The two are not the same thing to name: this is the
96+
flow's identity, split out of :data:`BufferID`, which every
97+
:mod:`pcapkit.toolkit` adapter builds from a plain port number -- the
98+
native ones by reading :attr:`AppType.port
99+
<pcapkit.const.reg.apptype.apptype.AppType.port>` off the parsed header,
100+
the third-party ones from whatever their own engine exposes -- and which
101+
is then used as a :class:`dict` key and dumped as part of the report.
102+
The deferred pair is a *dispatch* key for
103+
:meth:`Transport.analyze
104+
<pcapkit.protocols.transport.transport.Transport.analyze>`, which is
105+
what gains from carrying the service name.
106+
107+
"""
88108

89109
#: Source address.
90110
src: 'tuple[_AT, int]'
@@ -150,7 +170,7 @@ class Datagram(DeferredPacket, Info, Generic[_AT]):
150170
# :class:`~pcapkit.foundation.reassembly.data.ip.Datagram`, which applies
151171
# here identically: ``strict=False`` reports an incomplete payload buffer as
152172
# one contiguous ``bytes`` and analyses it.
153-
def __init__(self, completed: 'Completion', id: 'DatagramID[_AT]', index: 'tuple[int, ...]', header: 'bytes', payload: 'bytes | tuple[bytes, ...]', packet: 'Optional[ProtocolBase | Deferred[tuple[int, int]]]', conflict: 'tuple[tuple[int, int], ...]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long,redefined-builtin
173+
def __init__(self, completed: 'Completion', id: 'DatagramID[_AT]', index: 'tuple[int, ...]', header: 'bytes', payload: 'bytes | tuple[bytes, ...]', packet: 'Optional[ProtocolBase | Deferred[tuple[AppType_TCP, AppType_TCP]]]', conflict: 'tuple[tuple[int, int], ...]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long,redefined-builtin
154174

155175

156176
@info_final

0 commit comments

Comments
 (0)