Skip to content

fix(protocols): let Application carry an undissected remainder (#719) - #1030

Merged
JarryShaw merged 1 commit into
mainfrom
fix/719-application-raw-payload
Oct 5, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/719-application-raw-payload

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Please follow the guide below

  • Searched for similar pull requests
  • Followed the coding style (make pylint, make mypy, make isort) — measured per file: mypy reports 0 errors in application.py on this change and on main alike. An earlier revision of this branch introduced 5 there by dropping a load-bearing # type: ignore[override]; that is fixed. mypy is not a clean baseline on main either way — 305 errors in 33 other files on this invocation, none on a line this change touches.
  • A test case covers the change — tests/protocols/application/test_application_dispatch_unit.py, new here. make test itself was not run: the full suite reaches ~56 GB on this host and gets OOM-killed, so the selections below were run instead.
  • Added a changelog entry under docs/source/changelog/ and regenerated CHANGELOG.md

What is the purpose of your pull request?

  • fix — corrects a defect
  • feat — adds a feature
  • perf — changes performance, not behaviour
  • refactor — changes neither behaviour nor performance
  • test — tests only
  • docs — documentation only
  • ci — workflows or build tooling
  • chore — anything else

Description of your pull request and other information

Implements the ruling on #719: loosen Application itself rather than work around it per class. Application forbade payload dispatch outright, which is stricter than the invariant it means to encode — "no further protocol layer above this one". Undissected trailer bytes are not a protocol layer, so refusing them was refusing something the invariant permits.

  • _decode_next_layer and _import_next_layer raised UnsupportedCall unconditionally. They now accept the -1 sentinel — the rest of the packet, undissected — and delegate to ProtocolBase, which resolves it to Raw, or to NoPayload when nothing remains. Any other proto is still refused, and the message now names the protocol number instead of claiming the attribute does not exist.
  • __post_init__ unconditionally overwrote _next with NoPayload() and rebuilt _protos after read(), discarding whatever a dispatch had just set. It now fills them only when read left _next unset.
  • The class docstring said "transport layer protocol family" and said nothing about the dispatch contract. Both corrected.

Why it matters beyond these two methods: this is what blocked a functionally application-layer protocol that still carries a trailer from naming Application as its base at all. The OSPF/RARP layer move rides on top of this and is a separate pull request.

Breaking in two ways a third party can observe. A subclass calling either method with -1 changes from raising to succeeding; and a subclass whose read assigns _next itself used to have it overwritten with NoPayload after read() returned, and now keeps it. Neither needs a code change by the caller to observe, which is the convention page's test. No subclass in the package does either — the five are FTP, HTTP, HTTPv1, HTTPv2 and NGAP, enumerated by walking Application.__subclasses__() recursively, and none overrides the two dispatch methods or __post_init__ (FTP_DATA subclasses Raw, not Application) — so nothing in the tree changes behaviour.

Verified on 0e6e5f3cf: tests/protocols/application + tests/project give 399 passed, 1 skipped, 1305 subtests. Three of the new test's five cases fail when application.py is reverted to main — the two sentinel dispatches and the _import_next_layer acceptance — and the other two are regression guards on what must not change: a real protocol number still refused, and an application protocol that never dispatches still ending in NoPayload.

@JarryShaw JarryShaw added breaking Breaks public-facing behaviour or API (apply alongside the type label) fix Pull requests that fix a defect (fix: subject prefix) review: running A cross-review is in flight against the current head - no verdict yet labels Oct 5, 2026
`Application` forbade payload dispatch outright, which is stricter than the
invariant it means to encode. Ruled on #719.

* `_decode_next_layer` and `_import_next_layer` raised `UnsupportedCall`
  unconditionally. They now accept the `-1` sentinel -- the rest of the packet,
  undissected -- and delegate to `ProtocolBase`, which resolves it to `Raw`, or
  to `NoPayload` when nothing remains. Any other `proto` is still refused, and
  the message now names the protocol number rather than claiming the attribute
  does not exist.
* `__post_init__` unconditionally overwrote `_next` with `NoPayload()` and
  rebuilt `_protos` after `read()`, discarding whatever a dispatch had just set.
  It now fills them only when `read` left `_next` unset.
* The class docstring said "transport layer protocol family" and said nothing
  about the dispatch contract. Both corrected.

The invariant is "no further *protocol* layer above this one". Undissected
trailer bytes are not a protocol layer, so refusing `-1` was refusing something
the invariant permits -- and it is what blocked a functionally application-layer
protocol that still has a trailer from naming `Application` as its base at all.

Breaking in two ways a third party can observe. A subclass calling either
method with `-1` changes from raising to succeeding; and a subclass whose
`read` assigns `_next` itself used to have it overwritten with `NoPayload`
after `read()` returned, and now keeps it. No subclass in the package does
either -- the five are `FTP`, `HTTP`, `HTTPv1`, `HTTPv2` and `NGAP`, none of
which overrides the two dispatch methods or `__post_init__` -- so nothing in
the tree changes behaviour.

`_import_next_layer` keeps its `# type: ignore[override]` and its `super()`
call gains `[call-arg,misc]`. The base is wrapped in `@beholder`, which mypy
sees as a no-argument method, so both are load-bearing rather than decorative;
`protocol.py:598` carries the same pair for the same reason. Dropping the
override ignore drew five new errors in this file. `_decode_next_layer` needs
none: widening `proto` to `Optional[int]` is LSP-legal. Measured -- mypy on
this file reports zero errors on both this change and main, against 305
elsewhere either way.

New test pins both halves. Three of its five cases fail on main -- the two
sentinel dispatches and the `_import_next_layer` acceptance -- and the other two
are regression guards on what must not change: a real protocol number is still
refused, and an application protocol that never dispatches still ends in
`NoPayload`.

tests/protocols/application + tests/project: 399 passed, 1 skipped, 1305
subtests passed.
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review verdict on 55dc55b4b: NEEDS CHANGES (ran on Sonnet; I authored the branch). A real code defect, not prose — fixed in 0e6e5f3cf, now pushed.

The defect: I deleted a # type: ignore[override] that was doing real work. The diff moved the ignore from _import_next_layer onto _decode_next_layer, and the reviewer measured five new mypy errors in application.py as a result. I reproduced it before acting:

tree errors in pcapkit/protocols/application/application.py
main 0
55dc55b4b 5 — :109 unused-ignore, :133 [override], and three at :155 ([misc], two [call-arg])
0e6e5f3cf 0

The mechanism is worth recording: ProtocolBase._import_next_layer is wrapped in @beholder, which mypy sees as a no-argument method, so both the [override] on the signature and [call-arg,misc] on the super() call are load-bearing — protocol.py:598 carries the same pair for the same reason. _decode_next_layer genuinely needs none, because widening proto to Optional[int] is LSP-legal, so that ignore really was unused. Three edits: drop the unused one, restore the override ignore, add [call-arg,misc] to the super() call.

And the PR body's mypy sentence was false as written — I claimed none of mypy's errors sit on a line this change touches. Corrected.

On the breaking label, which the review called defensible but weakly justified, I am keeping it and strengthening the reason. It found a second observable I had missed and which is better than the one I gave: a third-party subclass whose read assigns _next itself used to have it overwritten with NoPayload after read() returned, and now keeps it. That is a behaviour change requiring no code change by the caller, which is exactly the convention page's test. Both the body and the commit message now name it.

What it confirmed independently, re-deriving rather than accepting: the sentinel accepted and every other proto refused with UnsupportedCall naming the number; -1 with bytes giving Raw and without giving NoPayload; and — the claim I most wanted attacked — the subclass enumeration, walking Application.__subclasses__() recursively to exactly five (FTP, HTTP, HTTPv1, HTTPv2, NGAP), none overriding either dispatch method or __post_init__, with FTP_DATA subclassing Raw rather than Application. It also mutation-tested the new test four ways, which is stronger than the pass/fail split I reported: dropping the proto check in _decode_next_layer gives 8 failures, in _import_next_layer 4, and inverting the __post_init__ guard either way gives 1 each.

One correction it raised against my own prose on #719, which belongs to the move rather than here: my 00:11Z comment said Link and Application define "precisely the same three names beyond ProtocolBase". They do not — Link adds seven and Application eight, so OSPF would also lose Link.__proto__, register and _read_protos on moving. It checked the behaviour is unaffected (a -1 lookup misses in either registry and falls back to Raw, and OSPF is reached by IP protocol rather than EtherType), but the wording is wrong and I will correct it on the move's pull request rather than leave it standing.

Resetting review: since the head moved. tests/protocols/application + tests/project on the fix: 399 passed, 1 skipped, 1305 subtests.

@JarryShaw
JarryShaw force-pushed the fix/719-application-raw-payload branch from 55dc55b to 0e6e5f3 Compare October 5, 2026 13:52
@JarryShaw JarryShaw added review: pending No verdict for the current head - never reviewed, or the head moved since the last one review: running A cross-review is in flight against the current head - no verdict yet and removed review: running A cross-review is in flight against the current head - no verdict yet review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Oct 5, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review verdict on 0e6e5f3cf: GOOD TO GO (ran on Sonnet; I authored the branch).

The mypy regression is fixed and re-measured independently: no line in application.py, ending Found 305 errors in 33 files — matching the main baseline of 0 in this file and the same 305 elsewhere. The 5 errors at 55dc55b4b are gone.

The diff scope held, which is what let the rest of the first pass carry forward untouched. git diff 55dc55b4b 0e6e5f3cf is one file, 3 insertions and 3 deletions, all three # type: ignore comments:

-    def _decode_next_layer(..., *,  # type: ignore[override]      <- unused, removed
+    def _import_next_layer(..., *,  # type: ignore[override]      <- restored
+        return super()._import_next_layer(-1, length, packet=packet)  # type: ignore[call-arg,misc]

It also agreed with my reading of the removed one: widening proto to Optional[int] is LSP-legal, so there is no [override] to suppress on _decode_next_layer, and mypy no longer reports it as unused.

One cosmetic staleness it caught, now fixed: the body's "Verified on" line still named 55dc55b4b. The figures hold, since the only change since is comment-only, but a stale sha in a description is exactly the drift I have let through three times before — it now reads 0e6e5f3cf.

Swapping to review: good-to-go. The one red check will be Plain unittest ordering (protocols), which is red on main too and not required — tracked as #1029, not this change's doing.

@JarryShaw JarryShaw added review: good-to-go Cross-review at the current head says ready; CI state is separate and removed review: running A cross-review is in flight against the current head - no verdict yet labels Oct 5, 2026
@JarryShaw
JarryShaw merged commit 012f4a2 into main Oct 5, 2026
73 checks passed
@JarryShaw
JarryShaw deleted the fix/719-application-raw-payload branch October 5, 2026 14:22
@JarryShaw JarryShaw removed the review: good-to-go Cross-review at the current head says ready; CI state is separate label Oct 5, 2026
JarryShaw added a commit that referenced this pull request Oct 5, 2026
`process.rst:104` pinned 159 entries while the file holds 161, so
`tests/project` was failing on `main` again.

* #1025, #1028 and #1030 each measured 159 against a 158-entry base, which
  was correct for each branch in isolation. Merging all three added three
  entries and left the pin two behind.
* This is the second time the same collision has landed, after `51100da7e`
  fixed a two-way version of it. Measuring per branch is not sufficient --
  the pin is only correct at the moment it is measured against the tree it
  will merge into, so it needs re-measuring at merge time or the check will
  keep going red whenever two changelog-touching branches land together.

Measured rather than incremented: `grep -cE '^\* '
docs/source/changelog/1.5.0.rst` gives 161.

tests/project: 268 passed, 1 skipped, 864 subtests passed. The failing test
was test_the_page_pins_its_own_measured_numbers.
@JarryShaw JarryShaw added this to the 1.5 milestone Oct 6, 2026
@JarryShaw JarryShaw moved this to Done in PyPCAPKit Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaks public-facing behaviour or API (apply alongside the type label) fix Pull requests that fix a defect (fix: subject prefix)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant