A Hello does not make a neighbor Full — the code says so in two stages
This post is part of Protocol in Code, a free series that reads network protocols as logic — inputs, state, and branches — rather than as configuration examples. The source lives at github.com/pathvector-studio/protocol-in-code, and every article quotes directly from a small Python module you can read end to end in one sitting. If you're earlier on the path and want to do things with protocols before dissecting them, start with the hands-on companion series, Protocol Lab.
The question
How does a received Hello turn into Init, 2-Way, or Full?
That question contains a trap, and the trap is the whole point of this session. Read enough vendor documentation and you absorb a mental model where a Hello packet arrives, some negotiation happens, and the neighbor "comes up" — one continuous process with Full at the end of it. Then you hit a network where two routers sit at 2-Way forever and it looks like a failure, or a pair stuck in ExStart where Hellos are clearly flowing fine.
The code we're reading this session is src/protocol_in_code/ospf/neighbor.py. It answers the question by refusing to answer it as asked: a Hello can produce Init, 2-Way, or ExStart, and that's the end of what a Hello can do. Full comes from somewhere else entirely.
Session 01 left us with two booleans: whether a Hello is acceptable, and whether the peer already lists us in its neighbor set. This session picks up exactly there, turns those booleans into a state, and then — separately — brings in the database-exchange inputs.
Read the file in this order: NeighborState, then AdjacencyInputs, then advance_neighbor_state().
The state space, and what it's hiding
class NeighborState(str, Enum):
DOWN = "Down"
INIT = "Init"
TWO_WAY = "2-Way"
EXSTART = "ExStart"
EXCHANGE = "Exchange"
LOADING = "Loading"
FULL = "Full"
Seven states, in the order OSPF traverses them. Nothing surprising yet — but notice there's a seam in this list even though the enum doesn't mark it. DOWN, INIT, and TWO_WAY are outcomes of looking at Hello packets. EXCHANGE, LOADING, and FULL are outcomes of exchanging link-state databases. EXSTART is the hinge: it's where the Hello phase hands off to the database phase.
The input struct makes that seam explicit:
@dataclass(frozen=True)
class AdjacencyInputs:
hello_accepted: bool
saw_self: bool
should_form_full_adjacency: bool
database_description_ok: bool = True
request_list_empty: bool = True
retransmissions_cleared: bool = True
Six booleans, and they split cleanly into two groups. The first three come from Hello processing. The last three come from the database exchange, and they all default to True — which is a small design decision worth pausing on, because it means an AdjacencyInputs constructed with only the Hello fields describes a neighbor whose database exchange has already gone perfectly. That's a convenience for tests, not a claim about reality; the helper that builds inputs from a real Hello result flips those defaults the other way:
def inputs_from_hello(
hello: HelloCheckResult,
*,
should_form_full_adjacency: bool,
database_description_ok: bool = False,
request_list_empty: bool = False,
retransmissions_cleared: bool = False,
) -> AdjacencyInputs:
# ...
Same three fields, defaults inverted. When all you have is a Hello, you have no evidence about the database at all, so the honest default is "not yet."
Note:
should_form_full_adjacencyis an input, not a computed result. In real OSPF it's the output of DR/BDR election on a broadcast segment, plus network-type rules — a router on an Ethernet segment forms full adjacencies with the DR and BDR and stops at2-Waywith everyone else. This model takes that decision as given so the state machine stays readable. Where that boolean comes from is its own problem; what it does is what we're reading here.
Stage one: what a Hello can decide
advance_on_hello() is a chain of guard clauses, and reading it top to bottom is reading a priority order:
def advance_on_hello(
current: NeighborState,
hello_accepted: bool,
saw_self: bool,
should_form_full_adjacency: bool,
) -> NeighborState:
if not hello_accepted:
return NeighborState.DOWN
if not saw_self:
return NeighborState.INIT
if not should_form_full_adjacency:
return NeighborState.TWO_WAY
if current in {NeighborState.DOWN, NeighborState.INIT, NeighborState.TWO_WAY}:
return NeighborState.EXSTART
return current
Four branches, and each one answers a different question.
hello_accepted is a hard floor. A Hello that fails the Session 01 checks — mismatched area, hello/dead interval disagreement, authentication failure, wrong netmask — doesn't leave the neighbor where it was. It drops it to DOWN. There is no partial credit and no memory of how far the relationship had progressed. This is worth internalizing because it explains a class of production symptom: a neighbor that was Full for months collapsing all the way to DOWN after someone changes a timer on one side. The code has no branch for "was Full, so be lenient."
saw_self is the one-way/two-way test. We received a valid Hello, so we know they're transmitting and we can hear them. What we don't know is whether they can hear us. The only evidence for that is our own Router ID appearing in the neighbor list of the Hello they sent. Without it, INIT — the state that means "I hear you, unconfirmed whether you hear me." This is the classic unidirectional-link signature, and it's why INIT exists as a distinct state rather than being folded into DOWN.
should_form_full_adjacency is the terminal branch that isn't a failure. Bidirectional communication is confirmed, and if the answer here is "no," the function returns TWO_WAY — and that's the final answer. Not a stop on the way somewhere; the destination. Two routers on a broadcast segment that are both DROTHERs will sit at 2-Way indefinitely, exchanging Hellos, never exchanging databases, and that is the protocol working correctly. They don't need a full adjacency with each other because they'll both get the same LSAs through the DR.
The last two lines are the interesting ones. Once we've decided a full adjacency is wanted, the function checks where we currently are:
if current in {NeighborState.DOWN, NeighborState.INIT, NeighborState.TWO_WAY}:
return NeighborState.EXSTART
return current
If the neighbor is still in a Hello-phase state, we promote it to EXSTART — the handoff point. But if it's already past that (EXCHANGE, LOADING, FULL), we return current unchanged. A valid Hello arriving at an established neighbor does not restart the database exchange. That bare return current is the entire mechanism that makes OSPF adjacencies stable under ordinary Hello traffic: every 10 seconds a Hello comes in, gets validated, and lands on a line that says "change nothing."
Stage two: what the database exchange decides
advance_neighbor_state() composes the two phases:
def advance_neighbor_state(
current: NeighborState,
inputs: AdjacencyInputs,
) -> NeighborState:
current = advance_on_hello(
current,
hello_accepted=inputs.hello_accepted,
saw_self=inputs.saw_self,
should_form_full_adjacency=inputs.should_form_full_adjacency,
)
if current in {NeighborState.DOWN, NeighborState.INIT, NeighborState.TWO_WAY}:
return current
# ...
Hello phase runs first, unconditionally. Then a gate: if the Hello phase landed on DOWN, INIT, or TWO_WAY, we return immediately and the database-exchange inputs are never consulted. They're present in the struct, they may be fully populated, and the function ignores them completely.
This is the structural answer to the question we opened with. hello_accepted=True, saw_self=True, should_form_full_adjacency=False returns TWO_WAY no matter what the other three booleans say. There is no path from a Hello to Full that skips this gate.
Past the gate, we're in database territory:
if not inputs.database_description_ok:
return NeighborState.EXSTART
current = NeighborState.EXCHANGE
if not inputs.request_list_empty:
return NeighborState.LOADING
if not inputs.retransmissions_cleared:
return NeighborState.LOADING
return NeighborState.FULL
database_description_ok covers master/slave negotiation and DD packet agreement — MTU match, sequence number sync, options compatibility. Fail it and you stay at EXSTART. Note that this branch pins you there rather than dropping you to DOWN: the Hellos are fine, bidirectional communication is fine, and the peer is still very much a neighbor. What's broken is one layer up. A pair of routers stuck at ExStart with healthy Hello traffic is exactly this branch firing repeatedly, and the classic cause — an MTU mismatch — is one you'll never find by looking at Hello packets.
Past that, current is assigned EXCHANGE and then two conditions can knock it down to LOADING. request_list_empty asks whether we still have LSAs we've requested but not received. retransmissions_cleared asks whether we have LSAs we sent that haven't been acknowledged. Either one non-empty means the databases aren't synchronized yet, in either direction, and LOADING is the state for "still reconciling."
Only when all six booleans line up does the function return FULL. Read the branches as a conjunction and FULL is the absence of every objection — not a positive achievement, but the state you fall through to when nothing is left to complain about.
Run it
The walkthrough is executable:
PYTHONPATH=src python3 examples/ospf/session_02_walkthrough.py
Read it, then start changing inputs. The most useful experiment is constructing an AdjacencyInputs with every database field True and should_form_full_adjacency=False, and confirming that the result is TWO_WAY. Then set should_form_full_adjacency=True with database_description_ok=False and watch it pin at EXSTART. Two calls, and the two-stage structure stops being an abstraction.
The other experiment worth doing: call advance_neighbor_state() with current=NeighborState.FULL and a perfectly good set of inputs, then with current=NeighborState.FULL and hello_accepted=False. The second one collapses to DOWN in a single call.
Toy model boundary
This model is deliberately smaller than the real state machine, and the gaps matter:
There are no events. RFC 2328 defines the neighbor state machine in terms of events — HelloReceived, Start, 2-WayReceived, NegotiationDone, ExchangeDone, LoadingDone, AdjOK?, SeqNumberMismatch, BadLSReq, KillNbr, InactivityTimer, 1-WayReceived — each with a defined action and a defined resulting state. This code has no event type at all. It takes a snapshot of six booleans and computes a state from scratch. That means it can express where a neighbor should be given current conditions, but it cannot express what happens on a specific trigger, and it has no place to hang the side effects the real machine specifies (start the inactivity timer, clear the LSA lists, send a DD packet).
Timers are absent. No dead interval, no inactivity timer, no retransmit interval, no RouterDeadInterval expiry path. In real OSPF, a neighbor goes DOWN because Hellos stopped arriving for dead-interval seconds — an absence of input over time. This model can only express hello_accepted=False, a Hello that arrived and failed. The most common cause of a neighbor going down in production is the one the model structurally cannot represent.
EXCHANGE is a pass-through, not a real state. Look closely: current = NeighborState.EXCHANGE is assigned and then immediately either overwritten by LOADING or fallen through to FULL. The function can never return EXCHANGE. In real OSPF, Exchange is where DD packets are actually traded and it can persist for a meaningful stretch. Here it's a line of code that exists to name the phase.
The DR/BDR election is a boolean. should_form_full_adjacency compresses network type, priority comparison, DR/BDR election, and the wait timer into one input. That's a substantial subsystem reduced to True/False.
Nothing is per-interface or per-LSA. No interface state machine (Waiting, DR, BDR, DROther), no LSA-level detail behind request_list_empty and retransmissions_cleared, no graceful restart, no AdjOK? re-evaluation when a DR changes under an established adjacency.
What the model does preserve, and preserves precisely, is the two-phase structure and the guard ordering. That's the thing worth carrying to real routers.
Same shape, different protocol
Once you see the shape here — reachability confirmed, then capability negotiated, then state synchronized, with separate gates and separate failure modes at each — you start finding it everywhere.
BGP is the closest match. TCP connects (Connect), OPEN messages are exchanged and validated (OpenSent → OpenConfirm), and only then does the session reach Established and begin exchanging routes. A BGP session stuck in OpenSent is the same class of problem as OSPF stuck in ExStart: the transport is fine, the parameter negotiation isn't. And just as a bad Hello drops OSPF to DOWN regardless of prior state, a NOTIFICATION drops BGP to Idle from anywhere.
TCP's own handshake is advance_on_hello() in miniature. SYN received means "I hear you" — the INIT equivalent. SYN-ACK carrying an acknowledgment of your sequence number is the saw_self check: proof the peer heard you, not just that you heard the peer. A half-open connection is INIT.
TLS splits the same way at a different layer. The handshake establishes that both sides can talk and agree on parameters; only after Finished verifies both transcripts does application data flow. A handshake failure at parameter negotiation — no shared cipher suite — is database_description_ok=False in a different costume: connectivity proven, compatibility not.
The recurring lesson is that "connected" and "usable" are always separate states, and the states in between exist to name which of them failed.
Can you answer these from the code alone?
Don't look anything up. Go back to neighbor.py and trace the branches:
-
Two routers exchange valid Hellos, each sees the other's Router ID in the neighbor list, and both stay at
2-Wayindefinitely. Which single line inadvance_on_hello()produced that, and which input would you have to change to move them forward? -
A neighbor has been
FULLfor a week. One Hello arrives with a mismatched hello interval. What doesadvance_neighbor_state()return, and how many lines of the function does it execute before returning? -
advance_neighbor_state()is called withshould_form_full_adjacency=True,database_description_ok=True,request_list_empty=True, andretransmissions_cleared=False. What comes back, and what does that result tell you about which direction of the database exchange is incomplete?
You're done with this session when you can explain why some neighbors stop at 2-Way — and why that's not a bug — and what still blocks Full after a neighbor reaches ExStart.
Further reading
- RFC 2328 — OSPF Version 2. Section 10 covers the neighbor data structure and the full neighbor state machine; section 10.3 is the state-change table this module compresses.
- RFC 5340 — OSPF for IPv6, for the same machine with a different addressing model.
- Session 01: Hello Acceptance — where
hello_acceptedandsaw_selfcome from.