← All posts

The LSA You Cannot Take Back

Joining a routing protocol is a handshake. Leaving one ends with a single message you may never get to send.

Michel Wijnberg

The previous post was about joining a link-state protocol without becoming a path through it. This one is about the other end, which is harder, and which I underestimated.

Arriving is a handshake. Both sides negotiate, both sides confirm, and if a packet is lost you try again. Every state machine in OSPF and IS-IS exists to make arrival reliable.

Leaving has no state machine. You announce your departure once, into a protocol whose entire purpose is to remember what routers said, and then you are gone and cannot correct anything.


The network keeps you

Stop a recorder without telling anyone and nothing dramatic happens. That is the problem.

Your LSA does not disappear when your process does. It sits in every router’s database with a sequence number and an age, and it will sit there until the age reaches maximum, which in OSPF is an hour. For that hour the topology contains a router that no longer exists, advertising an adjacency to a neighbour that is quietly wondering where you went.

An hour of a network describing something untrue, because a monitoring tool exited.

So you have to say goodbye properly, and both protocols have a way to do it that amounts to the same idea: re-originate the thing you want removed, in a form that means delete this.

  • OSPF floods the LSA with its age set to MaxAge, 3600 seconds, which makes every receiving router flush it from its database (RFC 2328 §13.4).
  • IS-IS floods the LSP with remaining lifetime zero. Same idea, different field.

Both are one message. There is no acknowledged teardown, no “are you sure”, and no retry loop, because by the time it matters the process is exiting.


The trap in the goodbye

Here is the part that is easy to get wrong, and that I would not have found without running authenticated IS-IS.

A purge LSP carries nothing. No reachability, no hostname, no capabilities. Its entire content is its own header saying “lifetime zero”. That makes it look like a message with nothing in it to protect.

It still has to be authenticated. RFC 5304 requires an implementation using cryptographic authentication to reject an unauthenticated purge, and requires a purge to carry no TLVs other than the authentication TLV. Both halves make sense the moment you ask what an unauthenticated purge is: a single unsigned packet that deletes another router’s state, which is the shape of an attack rather than of a shutdown.

Which means the one message you send while shutting down, the one you cannot retry, the one carrying no other content at all, is the one that must still be signed. Get it wrong and the failure is silent in the worst way: your shutdown looks clean from your side, the purge goes out, authenticating routers drop it, and your ghost stays in the database for the full lifetime.

Which is exactly the shape the code produces: the purge LSP is built carrying no TLVs at all, and the marshaller adds the authentication TLV when signing is configured. On an authenticated network the entire content of that message is its own signature. That is not an optimisation. It is the difference between leaving and appearing to leave.


The goodbye you do not get to send

Before the mechanics, the assumption underneath them, because it is easy to design the wrong way round.

Everything above describes a graceful shutdown: the process knows it is stopping and has time to send one more message. That is the easy case, and it is not the case worth designing for.

The case worth designing for is the one where there is no message at all. A kill signal that does not wait. A host that loses power. A container reaped for memory. In every one of those the purge is never sent, and the network is left holding a router that has already ceased to exist, with no notification that anything happened.

So the purge is best effort, and the design cannot lean on it. What has to be correct is the path that assumes it never happened: come back up, find your own ghost still installed, and supersede it. If that path works, the graceful purge is an optimisation that shortens an outage window from an hour to a second. If it does not, the graceful purge is a comfort that fails exactly when it was needed.

A purge improves convergence. It cannot be the correctness mechanism.

Which is why the rest of this post is about restarting rather than about stopping.


Coming back to find yourself already there

The other half of the problem is the restart, and it is the one that generates real bugs.

A recorder that stops uncleanly, or is restarted faster than the network forgets, comes back to find the previous run’s LSA still installed everywhere, holding a sequence number. To be believed, the new origination has to beat it.

That sounds like a one-line comparison. It is not, for a reason worth spelling out.

OSPF sequence numbers are signed 32-bit. The initial value, 0x80000001, is the most negative representable number, and they count upward from there. So the natural implementation of “have I seen a previous sequence number?” using zero as the no value sentinel is wrong in a way that survives testing: zero is never a valid OSPF sequence number, but int32(0) is greater than every valid one. A stale LSA would always lose the comparison against a sentinel that was supposed to mean “there wasn’t one”. The code carries explicit booleans instead, and a comment explaining why the obvious version is a trap.

Then there is when to do it, and both obvious answers are wrong.

The first instinct is to flush the ghost the moment you see it, mid database exchange, with a MaxAge flood. We observed Cisco implementations reacting badly to that: a MaxAge flush arriving while the adjacency was still in Loading put the flooding into a periodic reset cycle, roughly every forty seconds. Not a specification violation on anyone’s part that I can point to. Just what those routers did.

The second instinct, having learned that, is to wait: record the stale sequence number and re-originate once the adjacency reaches Full. That deadlocks. The neighbour is still advertising the ghost, so it stays on our request list, so the list never fully clears, so Full is never reached, so the origination that would have resolved it never happens. Recovery then waits for the refresh timer, about half an hour later.

The answer is neither, and it turns on a distinction that is easy to miss: what is dangerous mid-Loading is a MaxAge flush, not an origination. So the recorder re-originates immediately, during Loading, as an ordinary LSA carrying a sequence number higher than both its own copy and the ghost, and drops the stale entry from the neighbour’s request list on the way past. An ordinary LSA is safe there. A flush is not.

None of that is in a specification. It is what the network did, twice, in two different ways.

The LSDB browser showing Age, Seq# and Cksum columns. Three routers carry live values (ages 1492, 978 and 572 seconds, sequence numbers around 0x8000057x); two carry an em-dash because only an SNMP reconstruction exists for them, and the header says so above the table
The LSDB browser showing Age, Seq# and Cksum columns. Three routers carry live values (ages 1492, 978 and 572 seconds, sequence numbers around 0x8000057x); two carry an em-dash because only an SNMP reconstruction exists for them, and the header says so above the table

The two columns this post is about are the two that make an LSA hard to retract: the age that has to reach 3600 before anyone will drop it, and the sequence number that has to be beaten. Where a recorder holds the adjacency, both are real. Where only an SNMP walk reconstructed the LSA, the browser prints an em-dash rather than a plausible zero.


The five-second rule that swallowed an adjacency

And then the interesting failure, which is my favourite kind: two correct rules that combine into a wrong outcome.

RFC 2328 §12.4 requires at least five seconds between two originations of the same LSA. That is a sensible rate limit and Osprey enforces it.

Separately, the supersede LSA sent before the adjacency reaches Full deliberately carries no links. Advertising an adjacency you have not finished forming would be a claim about a neighbour relationship that does not exist yet.

Now put them in the same second. The recorder supersedes the ghost, carrying no links, because it is not Full. Immediately afterwards the adjacency reaches Full and the recorder tries to originate its real LSA, the one with the link in it. That origination lands inside the five-second window and is dropped by the rate limiter.

Nothing errors. Nothing retries. The recorder is now advertising, correctly and conformantly, a router with no links, and it will keep advertising that until the periodic refresh comes round.

That refresh is thirty minutes.

Measured on the lab deploy of 2026-08-05, both events landing in the same second: the recorder’s own adjacency showed as down for half an hour, in a product whose entire job is to say whether adjacencies are up. And the honest part is that every component behaved as designed. The rate limiter obeyed the RFC. The pre-Full LSA was right not to claim a link. The bug was in the space between them, which is where this class of bug always is.

The fix is a coalesced one-shot retry: an origination refused by the rate limiter is not discarded, it is re-armed for when the window opens.


Why leaving is the harder half

Arriving is a negotiation with feedback. If your Hello is wrong, the adjacency does not form and you find out immediately, loudly, in a state machine that will not advance.

Leaving is a single unacknowledged message, sent by a process that is about to stop existing, which must be correctly formed and correctly signed on the first attempt, and whose failure looks exactly like success from where you are standing.

That asymmetry is why the shutdown path in this code has more comments than the startup path, and why the one message with no content in it is the one with the strictest requirement attached.


The point

A monitoring system that cannot leave cleanly is not passive. It is passive until it stops, and then it is an hour of fiction in somebody else’s topology database.

Most of the difficulty is not in the specification. It is in the join between rules: a rate limit that is correct, a pre-Full LSA that is correct, a sentinel value that is obviously right and silently wrong. None of those is a protocol misunderstanding. They are all the same shape, which is two correct things meeting.


Next: the protocol that has no database to join at all, and what a MIB will not tell you even when you ask it politely. The Protocol With No Database.