TCP’s Maximum Segment Size option advertises the receiving endpoint’s maximum TCP data size under the option’s defined accounting rules. Each direction has its own advertisement; the two values need not match. A router can reduce those advertisements while forwarding SYN messages so later data segments fit a constrained path. That changes TCP behavior for the affected connections, not the underlying link MTU itself.
When it breaks
An intermediate bottleneck can be smaller than either endpoint interface. If classic path-MTU feedback is filtered, a connection may establish successfully and then stall on larger data. Reducing MSS can help that TCP flow avoid the oversized packets, but the missing feedback remains missing. Treating the workaround as a complete network repair leaves other protocols and traffic patterns exposed to the original limitation.
MSS is not the full IP packet size. For an IPv4 path MTU of 1280 with ordinary twenty-byte IPv4 and TCP base headers, an advertised MSS of 1240 follows the basic calculation. Actual IP or TCP options require additional allowance when forming data segments. Confusing the advertisement with a final wire-packet length can make a seemingly conservative value too large.
Direction and timing matter too. Editing only one SYN direction leaves the other endpoint’s advertisement unchanged. Updating a rule does not rewrite options already exchanged by existing connections. A clamp should reduce an excessive advertisement, not raise a peer’s already smaller limit. Scope such policy to the real bottleneck and relevant flow instead of treating one hard-coded value as suitable for every network.
Check it quickly
Save this as check-mss-clamp.py and run python3 check-mss-clamp.py with Docker. The disposable environment uses Python 3.11.16, iproute2 6.1.0, nftables 1.0.6, and tcpdump 4.99.3, tested on Linux 6.12.76. NET_ADMIN and SYS_ADMIN enable four nested namespaces. Endpoint links have MTU 1500; the link between two routers has MTU 1280:
The environment and numerical outcomes below are inherited article reports, not independently reproduced here. The image does not pin the host kernel or package availability.
Startup retains unbuffered diagnostic and listening lines before checking readiness; synthetic tests leave actual pipe, process and capture behavior unverified.
from pathlib import Path
from tempfile import TemporaryDirectory
import subprocess,json,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,re,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 spawn(stack,command):
process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
stack.callback(stop,process);return process
def line(process,timeout=3):
assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
value=process.stdout.readline();assert value,process.stderr.read();return value
@contextmanager
def lab():
with TemporaryDirectory(prefix='mtu-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 ['client','r1','r2','target']:
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 ['client','r1','r2','target']
path=proc/'sys'/key.replace('.','/')
ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r});p.write_text({str(value)!r});assert p.read_text().strip()=={str(value)!r}')
yield root,stack,setting
def topology(setting,ipv6=False):
links=[('client','c0','r1','a0',1,2,1,1500),('r1','a1','r2','b0',2,1,2,1280),('r2','b1','target','t0',3,1,2,1500)]
for left,left_if,right,right_if,network,left_host,right_host,mtu in links:
run('ip','link','add',left_if,'type','veth','peer','name',right_if)
for name,interface,host in [(left,left_if,left_host),(right,right_if,right_host)]:
run('ip','link','set',interface,'netns',name);ip(name,'link','set',interface,'mtu',str(mtu))
address=f'2001:db8:{network}::{host}/64' if ipv6 else f'10.210.{network}.{host}/30'
ip(name,'addr','add',address,'dev',interface,*(['nodad'] if ipv6 else []));ip(name,'link','set',interface,'up')
family='-6' if ipv6 else '-4'
for name in ['r1','r2']:setting(name,'net.ipv6.conf.all.forwarding' if ipv6 else 'net.ipv4.ip_forward',1)
routes=[('client','default','2001:db8:1::1' if ipv6 else '10.210.1.1'),('target','default','2001:db8:3::1' if ipv6 else '10.210.3.1'),
('r1','2001:db8:3::/64' if ipv6 else '10.210.3.0/30','2001:db8:2::2' if ipv6 else '10.210.2.2'),
('r2','2001:db8:1::/64' if ipv6 else '10.210.1.0/30','2001:db8:2::1' if ipv6 else '10.210.2.1')]
for name,destination,gateway in routes:ip(name,family,'route','add',destination,'via',gateway)
if ipv6:
deadline=time.monotonic()+4
while any('tentative' in ip(name,'-6','address','show').stdout for name in ['client','r1','r2','target']):
assert time.monotonic()<deadline,'IPv6 link-local DAD did not finish';time.sleep(.02)
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
result={}
for item in json.loads(ns(name,'nft','-j','list','ruleset').stdout)['nftables']:
rule=item.get('rule',{})
if 'comment' in rule:
for expression in rule['expr']:
if 'counter' in expression:result[rule['comment']]=expression['counter']['packets']
return result
class DeadlineLines:
def __init__(self,read_chunk,wait_readable,now,limit=65536):
self.read_chunk=read_chunk;self.wait_readable=wait_readable;self.now=now
self.pending=bytearray();self.eof=False;self.limit=limit
def read(self,timeout):
deadline=self.now()+timeout
while True:
end=self.pending.find(b'\n')
if end>=0:
if end+1>self.limit:raise ValueError('child status line too long')
value=bytes(self.pending[:end+1]);del self.pending[:end+1]
return value.decode('utf-8')
if len(self.pending)>self.limit:raise ValueError('child status line too long')
if self.eof:raise EOFError(('child status EOF before newline',bytes(self.pending[:160])))
remaining=deadline-self.now()
if remaining<=0 or not self.wait_readable(remaining):
raise TimeoutError('child status deadline exceeded; this is not a TCP diagnosis')
chunk=self.read_chunk(4096)
if not chunk:self.eof=True
else:self.pending.extend(chunk)
def reader(stream):
# Only use this with an unbuffered binary pipe, with no other reader.
return DeadlineLines(stream.read,lambda timeout:bool(select.select([stream],[],[],timeout)[0]),time.monotonic)
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,bufsize=0)
stack.callback(process.stderr.close);stack.callback(stop,process);messages=reader(process.stderr)
deadline=time.monotonic()+3
while time.monotonic()<deadline:
assert process.poll() is None,'tcpdump exited before listening'
if 'listening on' in messages.read(max(0,deadline-time.monotonic())):return process,path
raise AssertionError('tcpdump did not become ready')
def captured(process,path):
process.send_signal(signal.SIGINT);_,diagnostic=process.communicate(timeout=3);assert process.returncode==0,diagnostic
return run('tcpdump','-nn','-tt','-S','-r',str(path)).stdout
def advertisement(trace,source,destination,acknowledges):
# This deliberately accepts only the pinned tcpdump IPv4 SYN display.
prefix=re.compile(r'^\S+ IP '+re.escape(source)+r' > '+re.escape(destination)+r': Flags \[([^]]+)\], (.*)$')
values=set()
for row in trace.splitlines():
match=prefix.fullmatch(row)
if not match:continue
flags,fields=match.groups()
if 'S' not in flags:continue
assert set(flags)<=set('S.EW') and ('.' in flags)==acknowledges,('unexpected SYN flags',row)
assert '[|' not in row and '[bad' not in row,('incomplete SYN',row)
seq=re.findall(r'(?:^|, )seq (\d+)(?=,|$)',fields)
ack=re.findall(r'(?:^|, )ack (\d+)(?=,|$)',fields)
options=re.findall(r'(?:^|, )options \[([^]]*)\](?=,|$)',fields)
assert len(seq)==1 and len(ack)==int(acknowledges) and len(options)==1,('missing/ambiguous SYN fields',row)
mss=re.findall(r'(?:^|,)mss (\d+)(?=,|$)',options[0])
assert len(mss)==1 and re.search(r'(?:^|, )length 0$',fields),('invalid MSS or SYN payload',row)
value=(int(seq[0]),int(ack[0]) if ack else None,int(mss[0]))
assert 0<=value[0]<2**32 and (value[1] is None or 0<=value[1]<2**32),row
assert 0<value[2]<=1240,('unclamped advertisement',row)
values.add(value)
assert len(values)==1,('missing or inconsistent handshake',source,destination,values)
seq,ack,mss=values.pop()
return {'source':source,'destination':destination,'seq':seq,'ack':ack,'mss':mss}
def verify_advertisements(target_trace,client_trace,port):
client=f'10.210.1.2.{port}';target='10.210.3.2.18080'
syn=advertisement(target_trace,client,target,False)
synack=advertisement(client_trace,target,client,True)
assert synack['ack']==(syn['seq']+1)%2**32,('unrelated SYN-ACK',syn,synack)
return {'syn_at_target':syn,'synack_at_client':synack}
def server(stack):
code=r'''
import socketserver,hashlib
class Handler(socketserver.StreamRequestHandler):
def handle(self):
self.request.settimeout(3)
header=self.rfile.readline()
if not header:return
length=int(header);data=self.rfile.read(length)
if len(data)!=length:return
self.wfile.write(hashlib.sha256(data).hexdigest().encode()+b'\n');self.wfile.flush()
class Server(socketserver.ThreadingTCPServer):allow_reuse_address=True;daemon_threads=True
service=Server(('10.210.3.2',18080),Handler);print('ready',flush=True);service.serve_forever()
'''
process=spawn(stack,['ip','netns','exec','target','python3','-u','-c',code]);assert line(process).strip()=='ready'
def transfer(size,port):
code=r'''
import socket,json,hashlib,sys,struct
size=int(sys.argv[1]);stream=socket.socket();stream.settimeout(1.5);connected=False
try:
stream.bind(('10.210.1.2',int(sys.argv[2])));stream.connect(('10.210.3.2',18080));connected=True
stream.sendall(str(size).encode()+b'\n'+b'x'*size)
with stream.makefile('rb') as incoming:reply=incoming.readline().decode().strip()
assert reply==hashlib.sha256(b'x'*size).hexdigest()
print(json.dumps({'ok':True,'connected':True,'bytes':size,'sha256':reply,'snd_mss':stream.getsockopt(socket.IPPROTO_TCP,socket.TCP_MAXSEG)}))
except OSError as error:
stream.setsockopt(socket.SOL_SOCKET,socket.SO_LINGER,struct.pack('ii',1,0))
print(json.dumps({'ok':False,'connected':connected,'type':type(error).__name__,'errno':error.errno}))
finally:stream.close()
'''
return json.loads(ns('client','python3','-c',code,str(size),str(port)).stdout)
def hide_fragmentation_feedback():
nft('r1','''table ip hidden_mtu {
chain output { type filter hook output priority 0; policy accept;
icmp type destination-unreachable icmp code frag-needed counter drop comment "fragmentation_feedback_dropped"
}
}
''')
with lab() as (root,stack,setting):
topology(setting);setting('client','net.ipv4.tcp_mtu_probing',0);server(stack);hide_fragmentation_feedback()
failed=transfer(65536,40100);assert failed['connected'] and not failed['ok'] and failed['type']=='TimeoutError'
initial_counter=counters('r1')['fragmentation_feedback_dropped'];assert initial_counter>0
nft('r1','''table ip clamp {
chain forward { type filter hook forward priority 0; policy accept;
tcp flags & syn == syn tcp option maxseg size > 1240 tcp option maxseg size set 1240 counter comment "mss_rewritten"
}
}
''')
# Inspect each advertisement after forwarding, at its receiving endpoint.
client_tap,client_path=capture(stack,root,'client','c0','tcp port 18080','mss-client')
target_tap,target_path=capture(stack,root,'target','t0','tcp port 18080','mss-target')
recovered=transfer(65536,40101);assert recovered['ok'] and recovered['snd_mss']<=1240,recovered
client_trace=captured(client_tap,client_path);target_trace=captured(target_tap,target_path)
advertisements=verify_advertisements(target_trace,client_trace,40101)
after_transfer=counters('r1');assert after_transfer['mss_rewritten']>=2 and after_transfer['fragmentation_feedback_dropped']==initial_counter,after_transfer
large_ping=ns('client','ping','-n','-M','do','-s','1400','-c','1','-W','1','10.210.3.2',check=False)
assert large_ping.returncode!=0 and counters('r1')['fragmentation_feedback_dropped']>initial_counter
print(json.dumps({'before_clamp':failed,'after_clamp':recovered,'counters_after_tcp':after_transfer,
'icmp_echo_still_fails':{'returncode':large_ping.returncode,'stdout':large_ping.stdout,'stderr':large_ping.stderr},
'advertisements':advertisements,'capture':{'client_c0':client_trace,'target_t0':target_trace}}))
"""
name='xbcatm-mtupath-'+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 router suppresses outgoing ICMP fragmentation-needed messages. The client disables its separate TCP MTU probing feature, making this a controlled classic-discovery failure. A 65536-byte transfer establishes TCP and then reaches its application deadline. The router counter confirms feedback was suppressed. The failed socket is aborted so its remaining retransmissions cannot be mistaken for packets from the later comparison.
The script adds a forwarding rule matching SYN-bearing TCP packets whose advertised MSS exceeds 1240. It sets that option to 1240 and counts the changes. SYN-ACK also has SYN set, so the rule covers the corresponding return advertisement. The comparison uses a fresh connection because an already established stream would retain the options it received during its own setup.
The revised assertions use fresh client port 40101, distinct from the failed connection’s 40100. Target-side capture checks SYN after forwarding; client-side capture checks returning SYN-ACK. Absolute sequence numbers link the acknowledgment to that SYN, and retransmissions cannot substitute for a missing direction. Both advertisements must be at most 1240. Aggregate counts alone remain insufficient. Synthetic traces test these checks; actual captures, one-sided-clamp failures and the full transfer still require authorized validation. The inherited report records a verified SHA-256 response, effective sending MSS 1228 after option overhead, and no additional fragmentation-feedback drops.
An IPv4 echo probe with a 1400-byte payload and Don’t Fragment set still fails afterward. The suppressed-feedback counter increases again. This larger ICMP packet is outside the TCP MSS mechanism: reducing an option in a TCP handshake did not shrink it or repair the router’s feedback policy. The paired outcomes define the workaround’s actual scope through traffic, not just a rule listing.
Keep the workaround tied to observed packet constraints
The local capture contains offload representations that can be larger than the resulting TCP segments and can carry apparent partial checksums. Do not conclude from those records alone that MSS was ignored or that giant packets crossed the 1280-byte link. The option, socket diagnostic, counters, and verified application response provide complementary evidence at different processing stages.
Derive a suitable bound from the actual encapsulation and IP version. An IPv6 outer header or additional tunnel overhead changes the packet budget, and path changes can invalidate a previously adequate fixed value. The rule in this isolated topology uses a known bottleneck; it is not a discovery algorithm that learns every later route change automatically.
After applying a production policy, test both new connections and relevant non-TCP traffic. Preserve peer advertisements that are already smaller, and inspect both directions when transfers differ by upload versus download. Prefer correcting required feedback and path configuration where possible. MSS adjustment is useful when its constrained role is explicit and the intended TCP exchange is actually verified.
Sources
RFC 6691 TCP Options and MSS, MSS advertisement and accounting for actual header options, checked 2026-09-12.
nftables packet-header mangling, editing MSS on SYN and SYN-ACK and directional advertisements, checked 2026-09-12.
RFC 1191 Path MTU Discovery, the underlying IPv4 discovery problem that clamping does not repair, checked 2026-09-12.
Linux segmentation offloads, local offload representations versus final TCP segments, checked 2026-09-12.
tcpdump 4.99.3 manual, numeric endpoints, absolute sequence numbers and SYN option display, checked 2026-10-19.
tcpdump 4.99.3 startup source, privilege-drop diagnostics precede the listening line, checked 2026-10-19.
Python 3.11 subprocess, unbuffered binary pipes and short reads, checked 2026-10-19.