Skip to content

Latest commit

 

History

81 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tcpip-stack

A user-space TCP/IP protocol core for Linux, interoperated against the Linux kernel and built as an experiment in AI-assisted systems engineering. v0.1.0 deliberately has no application-facing TCP API and no routing-layer consumer.

What runs today: tcpip-stack. It creates and attaches to a TAP device in the current network namespace, and reports the ethernet frames the kernel puts on it — length per frame, and the raw bytes on request. ⚠ Given --mac and --ipv4 it also answers the ARP requests and the ICMP echo requests that ask for that address, and ⚠ ping reports 0% packet loss against it.Add --tcp-port and the Linux kernel's own connect() succeeds against it: ss reports the connection established.When the other side closes, its FIN is acknowledged and this end closes in the same segment, and the connection is finished once that is acknowledged in return — ⚠ ss on the peer then reports TIME-WAIT, which is its own. ⚠ Data that arrives is taken and discarded, as much of it as the advertised window covers. ⚠ With --send it also sends data of its own: bounded by the effective send MSS and by the smaller of the peer's window and a congestion window, ⚠ and resent when nobody acknowledges it — ⚠ the retransmission timeout is computed from round trips it measured, and it backs off.An ICMP error is matched to the connection that caused it. Without those options it only reads. ⚠ docs/SPEC.md §2 says what is missing.

The namespace is not tcpip-stack's doing. It uses whichever network namespace it is started in. The checks put a fresh one there with unshare -Urn (ADR 0001), and running tcpip-stack outside one would create the device on the machine's real networking.

What may be claimed is docs/SPEC.md, and only that.

Why this exists

Two repositories, one workflow:

                 AI-assisted engineering
                           |
          +----------------+----------------+
          |                                 |
       konjaku                        tcpip-stack
     web / UX / product             C / Linux / RFC
     arguable answers               pinned-down answers
          |                                 |
          +----------------+----------------+
                           |
              the same rules, checks and telemetry

The question is whether a way of working that holds where the right answer is arguable still holds where the right answer is written down in an RFC and visible on the wire.

What proves general across both gets extracted into claude-dev-template.What is specific to one domain stays in that repository.

Running it

make                 # build/tcpip-stack
make check           # all three tiers

Answering a ping, a connection request, some data and a close with our own code (⚠ a real run, 2026-08-29, kernel 7.0.2-arch1-1 — the kernel's hardware address and its source port are whatever it picked on that run, and the IPv6 frames it sends unprompted are left in rather than tidied away):

unshare -Urn sh -c '
  ./build/tcpip-stack --mac 02:00:00:00:00:02 --ipv4 10.0.0.2 --tcp-port 80 --timeout 1500 &
  until ip link show tap0 >/dev/null 2>&1; do sleep 0.05; done
  ip addr add 10.0.0.1/24 dev tap0
  ip link set tap0 up
  LC_ALL=C ping -c 2 -i 0.3 -W 1 10.0.0.2
  python3 -c "
import socket, subprocess, time
s = socket.socket(); s.connect((\"10.0.0.2\", 80))
s.sendall(b\"hello, stack\")
print(subprocess.run([\"ss\", \"-tn\"], capture_output=True, text=True).stdout, end=\"\")
s.close(); time.sleep(0.5)
print(subprocess.run([\"ss\", \"-tan\"], capture_output=True, text=True).stdout, end=\"\")
"
  wait
'

What ping said:

2 packets transmitted, 2 received, 0% packet loss, time 305ms
rtt min/avg/max/mdev = 0.054/0.055/0.056/0.001 ms

What ss said, while the connection was open and again after close():

State     Recv-Q Send-Q Local Address:Port  Peer Address:Port
ESTAB     0      0           10.0.0.1:42920     10.0.0.2:80

State     Recv-Q Send-Q Local Address:Port  Peer Address:Port
TIME-WAIT 0      0           10.0.0.1:42920     10.0.0.2:80

Send-Q is 0 while the connection is still open, which is the kernel saying it has had an acknowledgment it accepts for every octet of hello, stack. ⚠ TIME-WAIT is the kernel's own state, entered only once it has had our close and acknowledged it. ⚠ It is the peer's, and this stack never enters it — RFC 793 puts it on the side that closed first. ⚠ How long it stays there is the peer's business and nothing here can shorten it.

What the stack said (⚠ the ethernet header line of every frame is left out here; a real run prints one for each):

listening on tap0
  answered it: 10.0.0.2 is ours, and 10.0.0.1 was told our hardware address
  answered it: 10.0.0.2 is ours, and 10.0.0.1 got its 56 octets back
  answered it: 10.0.0.2 is ours, and 10.0.0.1 got its 56 octets back
  10.0.0.1:42920 asked to open a connection; now waiting for it to
    confirm (SYN-RECEIVED)
  10.0.0.1:42920 confirmed it; the connection is open (ESTABLISHED)
  12 octets of data arrived; we took them, told the sender so, and
    had nobody to give them to
  10.0.0.1:42920 has closed its side; we read the FIN, closed ours in the same
    segment, and are waiting for that to be acknowledged (LAST-ACK)
  10.0.0.1:42920 acknowledged our own close; the connection is finished and
    the room it held is free again (CLOSED)
listened on tap0 for 1500 ms after frame 14 and read no more. That does not say whether anything more was sent.
read 14 frames, 0 read errors
0 frames were malformed, 0 carried an IEEE 802.3 Length, 0 carried a length/type the standard does not define
answered 1 ARP request. 0 were not for us, 0 were malformed, 0 named an address space we cannot place, 0 had an opcode we do not act on
answered 2 echo requests. 0 were not for us, 0 carried a protocol we do not act on
0 internet headers were malformed, 0 were ones we do not read yet, 0 had a checksum that does not agree, 0 were fragments
0 ICMP messages were malformed, 0 had a type we do not act on, 0 had a checksum that does not agree
0 replies could not be built, which would be ours and not the sender's
0 TCP headers were malformed and 0 had a checksum that does not agree
1 connection was opened and 1 answered. 0 asked again
1 reached open. 0 acknowledged a number we are not waiting for, 0 arrived for no connection we hold, 0 arrived where the connection's state did not expect them
1 of our own closes left the device and 0 went out again because nobody had acknowledged them. 1 connection finished, and 0 were given up on with our close unacknowledged
0 answers went out again because nobody had confirmed them
0 connections were given up on after nobody confirmed them
1 acknowledgment for data left the device
12 octets of data were taken and discarded. 0 segments carried data we had taken already, and 0 began past what we were waiting for
the other side closed 1 connection. 0 FINs arrived that we had read already, 0 began past what we were waiting for, and 0 named a connection we hold nothing for
0 were refused for want of room and 0 answers never left the device, which are ours and not the sender's

0x0806 and 0x0800 are printed as the values they are, never as names. A name would be a lie for a VLAN-tagged frame, and ⚠ 0x0800 → IPv4 has never been taken from a standard in this repository — it is what the kernel put on a device while doing IPv4 (ADR 0003).

This run is not the one that stood here before. ⚠ The 2026-08-28 one ended with four lines of nothing in this connection's state expects that and a connection that was never closed; ⚠ the one after it closed but carried no data. ⚠ Each was re-run rather than edited (#67 AC 6, #77 AC 6): a worked example patched by hand is no longer a record of anything.

The last line of the timer is not a failure. ⚠ Nothing more arrived here, and that does not say whether anything more was sent (CLAUDE.md §1).

Environment

Linux is the source of truth for development. The stack talks to the kernel through /dev/net/tun, and the test environment is built out of network namespaces so that experiments cannot disturb ordinary networking.

Development tooling is Node (the hooks under .claude/ use only Node built-ins — there are no npm dependencies). The product itself is C.

Milestones

Data actually moves

Met, on 2026-08-29.send() from the Linux kernel completes and its Send-Q reaches 0, and ⚠ the connection then closes properly in the same run (tests/interop.sh a_connection_carries_data_and_then_closes_properly, 3000 octets). ⚠ The kernel does not release a buffer until the octets in it have been acknowledged with numbers it accepts, so ⚠ that is its judgement on our acknowledgment numbers, not ours.

What is taken is acknowledged and then discarded. ⚠ There is no user of this stack to hand an octet to, and ⚠ saying so out loud is the whole of what "data moves" means here.

The same lesson as the ping, the handshake and the close applies: a stack that acknowledged every arriving segment with a number ahead of what it took would drain the peer's queue too. ⚠ A segment carrying 1600 octets against a window of 1460 is acknowledged for exactly 1460 — not one octet more (tests/interop.sh an_acknowledgment_never_covers_an_octet_we_did_not_take). ⚠ The Linux kernel honours the window and will not build that segment, ⚠ so the check builds it by hand.

What it cost, and it is worth saying where: the interop tier was 24157 / 24167 / 24167 ms at thirteen cases when this milestone closed, up from 8747 ms at six. ⚠ It is 38771 / 38880 / 38919 ms at nineteen now (2026-08-29) — ⚠ two numbers from two different trees, and each says which (CLAUDE.md §6). ⚠ The cost is not the case count and it is not the namespace — ⚠ the two cases added here take 100 and 103 ms between them, and a whole connection with 3000 octets through it is one of them. ⚠ It is the cases that wait on real time: a schedule running out, a close() completing, a crafted connection driven a step at a time. ⚠ docs/SPEC.md §3 has every case timed on its own.

The connection can be closed

Met, on 2026-08-29.close() from the Linux kernel completes against this stack and the kernel enters TIME-WAIT (tests/interop.sh the_kernel_reaches_time_wait_and_our_block_is_free_again). ⚠ That state is entered only once it has had our FIN and acknowledged it, so ⚠ reaching it is somebody else's judgement on our sequence numbers, not ours.

What is deliberately not claimed:the connection disappearing from ss after 2 MSL. ⚠ That wait is the peer's own and ⚠ nothing here can shorten it — a check that waited it out would be measuring the Linux kernel's timer and not this stack.

And a second connect() succeeding straight afterwards is evidence about our own connection block being free again, and about nothing on the peer. ⚠ The first connection is still in TIME-WAIT over there while it succeeds, ⚠ which is exactly why the two are never spoken of as one thing.

The same lesson as the ping and the handshake applies: a stack that closed back on any FIN at all would reach TIME-WAIT too. ⚠ A FIN 500 past the window we promised draws no close from us and is counted apart, and the same FIN at the sequence number we are waiting for, in the same run, draws one (tests/interop.sh a_fin_whose_sequence_number_we_do_not_expect_is_not_answered).

What it cost:the interop tier went from 8747 ms at six cases to 20517 ms at ten, and ⚠ the growth is not the case count — the four cases added over this milestone all wait on real time. ⚠ docs/SPEC.md §3 owns those numbers, with the runs listed.

The stack keeps its own time

Met, on 2026-08-29.A connection nobody confirms is answered again on the wire — twice, a second apart — and then given up on, counted by an AF_PACKET socket rather than by our own output (tests/isolated.sh the_answer_really_goes_out_again).

The State layer still reads no clock. It is handed a moment, which is what keeps every one of its checks running with no waiting at all (ADR 0018).

What is not in it:RFC 793 says the retransmission timeout must be determined from a round trip, and nothing here measures one — a fixed second is used instead, which is the document's own example lower bound. docs/SPEC.md §2 records that as our gap.

The kernel's own connect() succeeds

Met, on 2026-08-28.connect() from the Linux kernel completes against this stack and ss -tn reports the connection established (tests/interop.sh the_kernel_opens_a_connection_to_us). The kernel checks the TCP checksum over a pseudo-header and the acknowledgment number before it completes one, so ⚠ that verdict is not ours.

And the same lesson as the ping applies: a stack that never validated a checksum on the way in would complete this handshake too. ⚠ A SYN whose TCP checksum does not agree is not answered and is counted, ⚠ and the same segment with the right checksum, in the same run, is answered.

What is in it after that, and what is not. ⚠ The answer advertises a window of 1460 octets — ⚠ what one frame carries at the MTU the checks use — and ⚠ the number has moved twice, each time after what backs it moved. ⚠ With a window of 0 the kernel's own close() produces no FIN at all; ⚠ at 1 the FIN arrives; ⚠ at 1460 a peer sends a whole segment at once. ⚠ Data the window covers is ⚠ acknowledged and then discarded — there is nobody to give it to — and ⚠ the peer's Send-Q reaches 0, measured 2026-08-29. ⚠ Before that it resent the same octet seven times and its queue never emptied.

The FIN is read, with RCV.NXT advanced over it by exactly one, after any data it rides with. ⚠ TIME-WAIT is not ours: the document puts it on the side that closed first, and this stack never does. ⚠ The document's CLOSE-WAIT waits for a local user to close, and there is no user here — so what happens instead is a decision, recorded in ADR 0022: the FIN's arrival is the close.

So one segment goes back carrying FIN,ACK — their close acknowledged and ours sent together — and the connection enters LAST-ACK. ⚠ The other side's verdict, not ours: measured 2026-08-29, ⚠ the kernel sent its FIN five times before this and once after, and ss then reports TIME-WAIT. ⚠ Why one segment and not two, and why LAST-ACK and not what RFC 793's CLOSE Call prints, are in ADR 0023.

A segment we refuse draws an acknowledgment saying where we are, which is what lets a peer whose own acknowledgment was lost recover — ⚠ measured 2026-08-29 with the loss made to happen on purpose. ⚠ What is still not sent: the three places RFC 793 §3.9 asks for a reset produce a counted reason and no segment. ⚠ The initial sequence number is fixed, which is a known weakness outside a private namespacedocs/SPEC.md §2 says so plainly.

Whether the kernel then lets the connection go is the milestone above.

Our own code answers a ping

Not TCP — ethernet frames, ARP, IPv4, ICMP echo, through a TAP device, inside a namespace, verified against something we did not write.

Met, on 2026-08-28.ping reports 0% packet loss, and the verdict is not ours: the Linux kernel checks the internet header checksum and the ICMP checksum before it accepts a reply (tests/interop.sh ping_reports_no_loss_against_our_own_stack).

And that alone would not prove we validated anything.A stack that answers a ping while computing the checksum wrong still answers the ping, so a second check carries the other half: an echo request whose ICMP checksum does not agree is not answered, and is counted (tests/static.sh echo_responder). ⚠ Neither replaces the other.

What is not in it: TCP, UDP, fragment reassembly, IPv4 Options, routing, any ICMP type but echo. ⚠ docs/SPEC.md §2 says which of those are decisions and which are simply not written yet.

Where things are written down

Question File
How do we work? CLAUDE.md
How do we write it? .claude/rules/
What may we claim? docs/SPEC.md
Why was it decided that way? docs/adr/

Licence

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages