A TCP cumulative acknowledgment identifies the next byte expected in order. If a segment is missing but later bytes arrive, that acknowledgment cannot advance across the hole. Selective Acknowledgment, or SACK, adds information about received byte ranges beyond the cumulative boundary. It helps a sender distinguish missing data from data already queued at the receiver, improving the information available to its recovery algorithm.

When it breaks

Without SACK, a cumulative ACK does not force the sender to retransmit every later segment. A sender can use duplicate ACKs, timers, and recovery algorithms to infer loss, but the feedback is less explicit, particularly with multiple gaps. Describing ordinary TCP as mandatory retransmission of the entire suffix confuses a limitation in information with a required recovery policy.

SACK blocks describe received ranges, not a direct list of missing ranges. Each block has an inclusive left edge and an exclusive right edge in sequence space. A hole is inferred by comparing those blocks with the cumulative acknowledgment and outstanding data. SACK does not change what the ordinary ACK field means, and the application still receives an ordered byte stream rather than out-of-order blocks.

The capability must also be permitted for the connection. SACK-Permitted in a SYN lets the peer send SACK options back to the SYN sender; it does not prove any SACK block was needed during a clean transfer. Conversely, missing SACK information is not automatically evidence that a firewall stripped an option. Inspect endpoint configuration and the actual handshake before selecting a cause.

Check it quickly

Save this as check-sack.py and run python3 check-sack.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 support nested network namespaces and their temporary proc mount. The example uses only a new veth link and namespace-local settings:

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'
    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'])
    setting('client','net.ipv4.tcp_sack',1);setting('target','net.ipv4.tcp_sack',1)
    setting('client','net.ipv4.tcp_autocorking',0)
    server_code=r'''
import socket,json,hashlib
listener=socket.socket();listener.bind(('192.0.2.2',18080));listener.listen();print('listening',flush=True)
with listener.accept()[0] as connection:
    connection.settimeout(6);data=b''
    while len(data)<12*256:
        chunk=connection.recv(4096);assert chunk;data+=chunk
    expected=b''.join(bytes([i])*256 for i in range(12));assert data==expected
    result={'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest()}
    connection.sendall(json.dumps(result).encode()+b'\n');print(json.dumps(result),flush=True)
'''
    service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(service).strip()=='listening'
    nft('target','''table ip one_gap {
 chain input { type filter hook input priority 0; policy accept;
  tcp dport 18080 ip length > 100 numgen inc mod 2147483647 == 1 counter drop comment "second_data_packet"
 }
}
''')
    tap,path=capture(stack,root,'client','c0','tcp port 18080','sack')
    client_code=r'''
import socket,json,time,hashlib
s=socket.socket();s.settimeout(6);s.setsockopt(socket.IPPROTO_TCP,socket.TCP_NODELAY,1)
s.bind(('192.0.2.1',40500));s.connect(('192.0.2.2',18080))
for i in range(12):s.sendall(bytes([i])*256);time.sleep(.003)
with s.makefile('rb') as stream:result=json.loads(stream.readline())
expected=b''.join(bytes([i])*256 for i in range(12))
assert result=={'bytes':len(expected),'sha256':hashlib.sha256(expected).hexdigest()}
print(json.dumps(result));s.close()
'''
    result=json.loads(ns('client','python3','-c',client_code).stdout);assert json.loads(line(service))==result
    dropped=counters('target');assert dropped['second_data_packet']==1,dropped
    observed=packets(tap,path);syn=next(p for p in observed if p['flags']&0x12==0x02);synack=next(p for p in observed if p['flags']&0x12==0x12)
    assert syn['options'].get('sack_ok') and synack['options'].get('sack_ok')
    reports=[p for p in observed if p['sport']==18080 and p['options'].get('sack')]
    assert reports,observed
    data_start=(syn['seq']+1)%2**32
    def relative(sequence):return (sequence-data_start)%2**32
    hole=(data_start+256)%2**32
    assert any(relative(p['ack'])==256 and any(256<relative(left)<relative(right)<=3072 for left,right in p['options']['sack']) for p in reports),reports
    retransmissions=[p for p in observed if p['sport']==40500 and p['length'] and p['seq']==hole]
    assert len(retransmissions)>=2,retransmissions
    print(json.dumps({'sack_negotiated_both_directions':True,'missing_byte_sequence':hole,'sack_reports':reports,
        'original_and_retransmitted_hole':retransmissions,'drop_counter':dropped,'verified_delivery':result}))
"""
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)

Both endpoints enable SACK. The client enables TCP_NODELAY and disables automatic corking in its own namespace, then sends twelve distinguishable chunks of 256 bytes with short pauses. The target input chain uses an incrementing rule expression to drop only the second matching data packet. The very large modulus makes that a single controlled loss within this short transfer; the counter must report exactly one drop.

The capture confirms SACK-Permitted in both SYN messages. After the first chunk arrives, the missing second chunk creates a hole. The receiver’s cumulative acknowledgment stays at the first byte of that hole, while a SACK block describes a later received range. The script checks the relationship using the actual initial sequence number rather than assuming a fixed packet sequence across runs.

The missing range appears in the capture first as the original transmitted segment and again as a retransmission with the same sequence number. In this run, it was a 256-byte segment. The server eventually receives all 3072 bytes in their expected order and checks their content. It returns a SHA-256 digest, which the client independently checks against the intended payload.

Those observations establish a real gap, informative acknowledgment, retransmission, and successful delivery. They do not benchmark SACK against a disabled configuration or establish a universal reduction in retransmission count. The capture also does not, by itself, identify every detail of Linux’s loss-detection decision. Negotiating SACK and choosing when to retransmit are related but distinct parts of the transport implementation.

Interpret feedback without promising perfect retransmission decisions

RFC 2018 makes SACK advisory: a receiver may later discard data it previously reported. The sender therefore cannot treat SACK as a permanent application-level receipt or discard all retransmission responsibility merely because a range appeared in an option. The cumulative acknowledgment remains central to determining which bytes have been accepted in order by TCP.

Reordering can also produce gaps and selective reports without permanent packet loss. Correlate sequence ranges, retransmission timing, cumulative progress, and capture location before classifying every SACK as a damaged network. Here, the known input filter and its exact drop counter provide evidence of deliberate loss that would be absent from an unexplained production trace.

For an incident, retain handshake options and enough of the data exchange to reconstruct the relevant ranges. Compare application delivery and transport recovery separately. Correct reassembly may hide a transient network loss from application correctness while still affecting latency or throughput. Use representative workload measurements for those performance questions; this small fixture verifies the protocol information and resulting byte delivery.

Sources

  • RFC 2018 TCP Selective Acknowledgment, received-byte ranges, direction of permission and the advisory nature of SACK, checked 2026-09-12.
  • RFC 9293 TCP, cumulative acknowledgments, sequence space and reliable byte delivery, checked 2026-09-12.
  • Linux tcp(7), Linux TCP SACK configuration and socket diagnostics, checked 2026-09-12.