The TCP three-way handshake establishes the two directions of a byte stream. Each endpoint supplies an initial sequence number and acknowledges the other endpoint’s number. Those are independent sequence spaces, not a shared wall clock. Applications do not need synchronized machine clocks to connect. Later sequence numbers identify bytes and support ordering, duplicate handling, acknowledgments, and retransmission within that connection.

When it breaks

A client sends SYN with its initial sequence number. The server returns SYN-ACK containing its own initial number and acknowledging the client’s SYN. The client acknowledges that response. SYN consumes one position in sequence space, so its acknowledgment advances by one even when the segment contains no application data. Treating these values as timestamps produces incorrect packet interpretations and can send an investigation toward unrelated clock synchronization.

The two endpoints do not change state at exactly the same instant. A client’s connect call can succeed after receiving SYN-ACK while its final acknowledgment is lost before reaching the server. The server can remain in SYN-RECEIVED, displayed as SYN-RECV by Linux tools. Subsequent data carrying an appropriate acknowledgment can also complete the exchange, so a missing standalone third packet does not inevitably cause permanent failure.

Successful TCP setup establishes neither application health nor peer authentication. A service might accept connections but never produce a valid response. A proxy may complete one TCP connection while failing to establish its separate upstream connection. Likewise, sequence-number selection helps protect connection incarnations and resist prediction attacks, but it is not an authentication protocol and does not encrypt the stream.

Check it quickly

Save this as check-tcp-handshake.py and run python3 check-tcp-handshake.py with Docker. The disposable container uses Python 3.11.16, iproute2 6.1.0, nftables 1.0.6, and tcpdump 4.99.3, tested with Linux 6.12.76. NET_ADMIN and SYS_ADMIN support its nested network namespaces. A newly created veth joins the client and target, with no host interfaces or published ports involved:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,os,select,signal,subprocess,time

def run(*args,check=True,input=None,timeout=8):
    result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout)
    if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
    return result
def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs)
def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs)
def stop(process):
    if process.poll() is None:
        process.terminate()
        try:process.wait(timeout=2)
        except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2)
def line(process,timeout=3):
    assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
    value=process.stdout.readline();assert value,('child exited without a response',process.stderr.read())
    return value
def spawn(stack,command):
    process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process);return process
@contextmanager
def lab(names):
    with TemporaryDirectory(prefix='net-path-') as directory,ExitStack() as stack:
        root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc))
        stack.callback(run,'umount',str(proc))
        for name in names:
            run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False)
            ip(name,'link','set','lo','up')
        def setting(name,key,value):
            assert name in names
            path=proc/'sys'/key.replace('.','/')
            return ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r}); p.write_text({str(value)!r}); print(p.read_text().strip())').stdout.strip()
        yield root,stack,setting
def wire(left,left_if,right,right_if,left_addresses,right_addresses):
    run('ip','link','add',left_if,'type','veth','peer','name',right_if)
    for name,interface,addresses in [(left,left_if,left_addresses),(right,right_if,right_addresses)]:
        run('ip','link','set',interface,'netns',name)
        for address in addresses:ip(name,'address','add',address,'dev',interface,*(['nodad'] if ':' in address else []))
        ip(name,'link','set',interface,'up')
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
    data=json.loads(ns(name,'nft','-j','list','ruleset').stdout);found={}
    for entry in data['nftables']:
        rule=entry.get('rule',{})
        if 'comment' in rule:
            for expression in rule['expr']:
                if 'counter' in expression:found[rule['comment']]=expression['counter']['packets']
    return found


def capture(stack,root,name,interface,expression,label):
    path=root/(label+'.pcap')
    process=subprocess.Popen(['ip','netns','exec',name,'tcpdump','--immediate-mode','-Z','root','-U','-n','-i',interface,'-w',str(path),expression],
        stdout=subprocess.DEVNULL,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process)
    deadline=time.monotonic()+3
    while time.monotonic()<deadline:
        assert process.poll() is None,'tcpdump exited before listening'
        if select.select([process.stderr],[],[],.1)[0] and 'listening on' in process.stderr.readline():return process,path
    raise AssertionError('tcpdump did not become ready')
def packets(process,path):
    import struct,socket
    process.send_signal(signal.SIGINT);_,diagnostic=process.communicate(timeout=3)
    assert process.returncode==0,diagnostic
    raw=path.read_bytes();assert raw[:4]==bytes.fromhex('d4c3b2a1') and struct.unpack_from('<I',raw,20)[0]==1
    offset=24;result=[]
    while offset<len(raw):
        seconds,micros,size,original=struct.unpack_from('<IIII',raw,offset);offset+=16
        frame=raw[offset:offset+size];offset+=size
        if frame[12:14]!=b'\x08\x00' or frame[23]!=6:continue
        ihl=(frame[14]&15)*4;length=struct.unpack_from('!H',frame,16)[0];tcp=14+ihl
        sport,dport,seq,ack=struct.unpack_from('!HHII',frame,tcp);header=(frame[tcp+12]>>4)*4
        opts={};cursor=tcp+20
        while cursor<tcp+header:
            kind=frame[cursor]
            if kind==0:break
            if kind==1:cursor+=1;continue
            option_length=frame[cursor+1];data=frame[cursor+2:cursor+option_length];assert option_length>=2
            if kind==2:opts['mss']=struct.unpack('!H',data)[0]
            elif kind==3:opts['wscale']=data[0]
            elif kind==4:opts['sack_ok']=True
            elif kind==5:opts['sack']=[list(pair) for pair in struct.iter_unpack('!II',data)]
            cursor+=option_length
        body=frame[tcp+header:14+length]
        result.append({'t':seconds+micros/1e6,'src':socket.inet_ntoa(frame[26:30]),'dst':socket.inet_ntoa(frame[30:34]),
            'sport':sport,'dport':dport,'seq':seq,'ack':ack,'flags':frame[tcp+13],'window':struct.unpack_from('!H',frame,tcp+14)[0],
            'length':len(body),'options':opts,'preview':body[:24].hex()})
    return result
with lab(['client','target']) as (root,stack,setting):
    wire('client','c0','target','t0',['192.0.2.1/30'],['192.0.2.2/30'])
    server_code=r'''
import socketserver
class Handler(socketserver.StreamRequestHandler):
    def handle(self):
        data=self.rfile.readline();self.wfile.write(b'echo:'+data);self.wfile.flush()
class Server(socketserver.ThreadingTCPServer):allow_reuse_address=True;daemon_threads=True
server=Server(('192.0.2.2',18080),Handler);print('ready',flush=True);server.serve_forever()
'''
    service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(service).strip()=='ready'
    tap,path=capture(stack,root,'client','c0','tcp port 18080','handshake')
    normal=ns('client','python3','-c',
        'import socket; s=socket.socket();s.settimeout(2);s.bind(("192.0.2.1",40100));s.connect(("192.0.2.2",18080));s.sendall(b"normal\\n");print(s.makefile("rb").readline().decode().strip());s.close()')
    assert normal.stdout.strip()=='echo:normal'
    nft('client','''table ip missing_ack {
 chain output { type filter hook output priority 0; policy accept;
  tcp dport 18080 tcp flags & (syn|ack|rst|fin) == ack counter drop comment "missing_final_ack"
 }
}
''')
    client_code=r'''
import json,socket,sys
s=socket.socket();s.settimeout(.25);s.bind(('192.0.2.1',40101));s.connect(('192.0.2.2',18080))
print(json.dumps({'connect_returned':True}),flush=True);s.sendall(b'deferred\n')
try:s.recv(64);raise AssertionError('unexpected application response')
except TimeoutError:print(json.dumps({'application_timeout':True}),flush=True)
assert sys.stdin.readline().strip()=='restore';s.settimeout(5)
received=b''
while not received.endswith(b'\n'):
    chunk=s.recv(64);assert chunk,'premature EOF';received+=chunk
assert received==b'echo:deferred\n',received
print(json.dumps({'recovered':received.decode().strip()}),flush=True);s.close()
'''
    client=spawn(stack,['ip','netns','exec','client','python3','-u','-c',client_code])
    connected=json.loads(line(client));failure=json.loads(line(client));assert connected['connect_returned'] and failure['application_timeout']
    pending=ns('target','ss','-nHt','state','syn-recv').stdout;assert '192.0.2.1:40101' in pending,pending
    blocked=counters('client');assert blocked['missing_final_ack']>0
    nft('client','delete table ip missing_ack');client.stdin.write('restore\n');client.stdin.flush()
    recovery=json.loads(line(client,6));assert recovery['recovered']=='echo:deferred'
    client.wait(timeout=2);assert client.returncode==0
    observed=packets(tap,path);flow=[p for p in observed if 40100 in (p['sport'],p['dport'])]
    syn=next(p for p in flow if p['flags']&0x12==0x02)
    synack=next(p for p in flow if p['flags']&0x12==0x12)
    third=next(p for p in flow if p['sport']==40100 and p['flags']&0x12==0x10)
    assert synack['ack']==(syn['seq']+1)%2**32
    assert third['seq']==(syn['seq']+1)%2**32 and third['ack']==(synack['seq']+1)%2**32
    print(json.dumps({'three_messages':[syn,synack,third],'normal_reply':normal.stdout.strip(),
        'client_connect_returned':connected['connect_returned'],'application_timeout':failure['application_timeout'],
        'server_still_syn_recv':pending.strip(),'drop_counters':blocked,'after_ack_filter_removed':recovery}))
"""
name='xbcatm-tcpflow-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory);(root/'Dockerfile').write_text(recipe)
        build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr
        result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN',
            '--memory','256m','--pids-limit','128',name,'python3','-',input=program)
        assert result.returncode==0,result.stderr
        print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
    docker('rm','-f',name);docker('image','rm',name)

The first connection uses client port 40100 and target port 18080. It sends a newline-terminated request and receives the expected echo. A packet capture supplies the actual SYN, SYN-ACK, and final ACK values. Assertions check that SYN-ACK acknowledges the client’s sequence plus one and that the third message acknowledges the server’s sequence plus one, using modulo arithmetic for the 32-bit sequence space.

The second connection uses client port 40101. An output filter in the client namespace drops ACK-bearing packets without SYN, FIN, or RST, including the final acknowledgment and subsequent data. Blocking that data matters: otherwise a later data segment could acknowledge the SYN-ACK and repair the intentionally missing third message. The example therefore makes the intended asymmetric state observable instead of relying on a narrow timing accident.

The client reports that connect returned successfully, but its application read reaches a deadline. At the same point, the server’s socket listing shows that exact client tuple in SYN-RECV. A rule counter confirms dropped packets. These independent observations establish the distinction between the client’s local API result, the server’s protocol state, and the failed application exchange.

Removing the filter lets the same connection finish the exchange and deliver the queued request. The client receives echo:deferred; it does not create a replacement socket to hide the original failure. Framed reads accumulate the response rather than assuming one recv call always returns the entire application message. The test verifies both the temporary failure and recovery after its controlled cause is removed.

Diagnose setup separately from useful service

Record the four-tuple, namespace, capture point, and state on each endpoint. A reset, repeated SYN, repeated SYN-ACK, and a completed handshake followed by an application timeout suggest different next checks. Capture both directions when possible: an outgoing acknowledgment visible locally is not proof that the remote endpoint received it. The fixture’s counter and remote socket state supply that missing context.

For service validation, follow the transport connection with a meaningful request and inspect its response. For identity and confidentiality, use the appropriate authenticated protocol above TCP. This exercise covers an ordinary connection without Fast Open or simultaneous open, and does not claim every valid connection begins with exactly three separately visible packets. Its central check is the sequence and state relationship, not a simplistic packet-count rule.

Sources

  • RFC 9293 TCP, sequence numbers, connection states and the three-way handshake, checked 2026-09-12.
  • Linux tcp(7), TCP socket behavior and Linux-specific controls, checked 2026-09-12.
  • Linux ss(8), examining established and SYN-RECV sockets, checked 2026-09-12.