Skip to content

fix(proto): ignore coalesced datagram tail for unknown path - #782

Open
axelbaumlisto wants to merge 5 commits into
n0-computer:mainfrom
axelbaumlisto:clipshot/pr-known-path-guard
Open

fix(proto): ignore coalesced datagram tail for unknown path#782
axelbaumlisto wants to merge 5 commits into
n0-computer:mainfrom
axelbaumlisto:clipshot/pr-known-path-guard

Conversation

@axelbaumlisto

@axelbaumlisto axelbaumlisto commented Aug 2, 2026

Copy link
Copy Markdown

Description

Fixes #781.

handle_coalesced accounted for the datagram with path_data_mut(path_id), which panics via expect("known path") when the path id is not in paths. Handling the first packet of a datagram can remove the path, so the coalesced remainder must not assume it still exists.

The remainder is now discarded, matching the existing unknown-path handling in process_decrypted_packet.

Also adds a test for a path id present in neither paths nor abandoned_paths, which early_discard_packet does not cover — the existing stale_coalesced_datagram_after_path_discard_is_ignored only covers the abandoned case. It panics at mod.rs:4163 without the fix and passes with it, while the existing test stays green, confirming the two cover different cases.

The connect-and-capture setup the new test needs was inlined in the existing one, so it is extracted first (connect_capturing_coalesced_datagram, CoalescedDatagram::replay) and both tests share it.

Breaking Changes

None.

Notes & open questions

Three further expect("known path") call sites remain (migrate, populate_packet); those look like genuine internal invariants and are untouched here.

Change checklist

  • Self-review.
  • Tests if relevant.
  • This PR was created by a human that thought critically about the
    proposed change and wrote an as clear and concise description as
    they could.
  • This PR isn't slop, and is carefully crafted to do have the
    intented effect.

`handle_coalesced` accounted for the datagram via `path_data_mut(path_id)`, which
panics with `expect("known path")` when the path is not in `paths`. Handling the
first packet of a datagram can remove the path, so the coalesced remainder must
not assume it still exists.

Discard the remainder instead, matching the existing unknown-path handling in
`process_decrypted_packet`.
@axelbaumlisto
axelbaumlisto force-pushed the clipshot/pr-known-path-guard branch from 97a4925 to 659ef7a Compare August 2, 2026 08:13
Aleksandr Prilipko added 2 commits August 2, 2026 15:20
`stale_coalesced_datagram_after_path_discard_is_ignored` inlined the whole
connect-and-capture sequence. Move it behind
`connect_capturing_coalesced_datagram` and `CoalescedDatagram::replay` so the
setup can be shared.
Exercises a coalesced datagram whose path id is in neither `paths` nor
`abandoned_paths`, which `early_discard_packet` does not cover. The existing
`stale_coalesced_datagram_after_path_discard_is_ignored` only covers the
abandoned case.
@axelbaumlisto
axelbaumlisto force-pushed the clipshot/pr-known-path-guard branch from 659ef7a to a82ab6e Compare August 2, 2026 08:21
@n0bot n0bot Bot added this to iroh Aug 2, 2026
@github-project-automation github-project-automation Bot moved this to 🚑 Needs Triage in iroh Aug 2, 2026

@divagant-martian divagant-martian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The direction is good

Comment thread noq-proto/src/connection/mod.rs Outdated
self.path_data_mut(path_id)
.inc_total_recvd(data.len() as u64);
let Some(path) = self.paths.get_mut(&path_id) else {
// Handling the first packet of this datagram may have removed the path, so the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I've perused the code and I'm certain this is not true. The bug does exist but this is not the justification. The path might only be discarded when the timer expires, which would need a call to handle_timeout in between the first packet and the coalesced one. proto is a state machine so this is not something achievable with a data race either

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right. paths.remove lives only in discard_path, whose sole caller is handle_timeout — no packet removes a path mid-datagram. The real gap: an id in neither paths nor abandoned_paths passes both early_discard_packet guards. Comment rewritten.

Comment thread noq-proto/src/tests/mod.rs Outdated
#[test]
fn stale_coalesced_datagram_after_path_discard_is_ignored() {
let _guard = subscribe();
/// A coalesced handshake datagram captured before the client finished connecting, together

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this bug exists in part because the coalesced datagram must arrive later than the handshake. A handshake datagram will always belong to PathId::Zero which is guaranteed to not escape the existing checks

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed, this doesn't arise from the network — I checked, a handshake dst_cid is registered as PathId::ZERO explicitly. Keeping the test as a defensive contract: the production SIGABRT was real, but it proves no more than that.

Comment thread noq-proto/src/tests/mod.rs Outdated

impl CoalescedDatagram {
/// Replays the datagram on `path_id`, as if it had just arrived from the network.
fn replay(self, pair: &mut ConnPair, path_id: PathId) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This function makes readability slightly worse because the bug is achievable with a public api (handle_event) but the call is hidden behind a very specific helper. Please make this just to_connection_event (or something similar) receiving now and path_id, so that the public api call is explicit in the tests

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair. replay is now to_connection_event(now, path_id), with handle_event visible in each test. Worth noting: with the guard reverted the existing abandoned-path test still passes — only the never-opened one panics.

@github-project-automation github-project-automation Bot moved this from 🚑 Needs Triage to 🏗 In progress in iroh Aug 4, 2026
@divagant-martian divagant-martian self-assigned this Aug 4, 2026
Aleksandr Prilipko added 2 commits August 5, 2026 03:30
…l explicit

Three review comments, all correct. Verified each against the code before changing
anything.

1. The comment's mechanism does not exist. It claimed handling the first packet may
   have removed the path. `paths.remove()` appears once, in `discard_path`, whose only
   caller is `handle_timeout` — so a packet cannot remove a path mid-datagram, and as
   the reviewer notes, a synchronous state machine cannot get there by a data race
   either.

   The real reason an unknown id arrives here: `early_discard_packet`'s handshake guard
   needs `is_handshaking()`, and its discarded-path guard needs the id to be in
   `abandoned_paths`. An id that was never opened is in neither map, so the datagram
   proceeds; `process_decrypted_packet` drops the first packet through its own
   unknown-path check while the coalesced remainder assumed that lookup had succeeded.
   The comment now says that instead.

2. On the handshake/PathId::ZERO point — correct, and worth recording why replaying
   under another id is still a faithful model rather than a fabricated input: the
   receiver never reads the path id off the wire. The endpoint resolves it as
   `connection_ids[dst_cid]`, so attribution is local receiver state, not something a
   sender chose. Noted in the test.

3. `replay` is gone. `to_connection_event(now, path_id)` returns the event and each
   test calls `handle_event` itself, so the public API call that panics is visible
   where it matters.

Evidence the new test earns its place: with the guard reverted,
`coalesced_datagram_for_never_opened_path_is_ignored` panics at the `expect` while the
pre-existing `stale_coalesced_datagram_after_path_discard_is_ignored` still PASSES — the
abandoned-path case does not exercise this panic at all.

388 noq-proto tests pass, clippy clean, both touched files rustfmt-clean.
Removes every comment this PR added: the rationale block in `handle_coalesced`, the doc
comments on `CoalescedDatagram` / `to_connection_event` /
`connect_capturing_coalesced_datagram`, and the explanation block in the new test.

One of them had to go regardless of style preference. The test carried an argument that
replaying under a different path id is "a faithful model of the receive path" because the
endpoint resolves the id from `connection_ids[dst_cid]` rather than the wire. I checked
that after the review and it does not hold: a handshake `dst_cid` is registered as
`PathId::ZERO` explicitly, so local state says ZERO too. Leaving a claim in the code that
I had already conceded in the review thread would have been worse than leaving no comment
at all.

Code and tests are unchanged. 388 noq-proto tests pass; with the guard reverted,
`coalesced_datagram_for_never_opened_path_is_ignored` still panics at the `expect` while
`stale_coalesced_datagram_after_path_discard_is_ignored` still passes, so the new test is
carrying the coverage on its own.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🏗 In progress

Development

Successfully merging this pull request may close these issues.

Panic in handle_coalesced: path_data_mut expects a known path

2 participants