Skip to content

Commit 20461f6

Browse files
ccie18643claude
andcommitted
feat(examples): async TCP echo server + client (RFC 862), sharing malpi
Adds a daemon-backed TCP Echo example pair, mirroring the legacy interactive echo service on top of asyncio: - tcp_echo_server__async.py — an asyncio.start_server echo server: greets on connect, echoes every chunk (answering a 'malpka'/'malpa'/'malpi' request with the ASCII-art monkey), and sends a farewell when the client sends a quit word or half-closes. Each connection is its own coroutine, so many clients are served concurrently — the shape that actually justifies async. - tcp_echo_client.py — a sync single-shot client: connect, send, half-close the write side (shutdown(SHUT_WR)) so the server replies and closes, then drain the reply to EOF. Half-close is daemon-safe: ClientTcpSocket.shutdown is a control-plane RPC that drives the stack socket's FIN directly. The shared malpi selector (echo_reply) moved into examples/malpi.py so both echo servers use one source; the UDP server + test import it from there now. Both use Click + net_addr host validation and the injectable make_socket, so the tests exercise the same logic over real loopback stdlib sockets (the blocking client in a worker thread) — no daemon required. Reference: RFC 862 (Echo Protocol). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 83edf2a commit 20461f6

6 files changed

Lines changed: 427 additions & 22 deletions

File tree

examples/malpi.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"""
2626
This module contains the test monkeys used as easter-egg payloads in the
2727
echo examples (a 'malpa' / 'malpka' / 'malpi' request is answered with the
28-
matching ASCII-art monkey). Carried over from the legacy examples.
28+
matching ASCII-art monkey) plus the shared 'echo_reply' selector both the
29+
UDP and TCP echo servers use. Carried over from the legacy examples.
2930
3031
examples/malpi.py
3132
@@ -81,3 +82,23 @@
8182

8283

8384
malpi: bytes = b"".join([_ + __ + b"\n" for _, __ in zip(malpka.split(b"\n"), malpa.split(b"\n"))])
85+
86+
87+
def echo_reply(message: bytes, /) -> bytes:
88+
"""
89+
Build the echo reply for 'message': the message itself, unless it names
90+
a monkey ('malpka' / 'malpa' / 'malpi'), in which case the matching
91+
ASCII-art monkey is returned instead. The three names are tested in
92+
'malpka' -> 'malpa' -> 'malpi' order, matching the legacy echo service.
93+
Matching is case-insensitive and ignores surrounding whitespace; the
94+
echoed bytes for a non-monkey message are the original, unstripped.
95+
"""
96+
97+
lowered = message.strip().lower()
98+
if b"malpka" in lowered:
99+
return malpka
100+
if b"malpa" in lowered:
101+
return malpa
102+
if b"malpi" in lowered:
103+
return malpi
104+
return message

examples/tcp_echo_client.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env python3
2+
3+
################################################################################
4+
## ##
5+
## PyTCP - Python TCP/IP stack ##
6+
## Copyright (C) 2020-present Sebastian Majewski ##
7+
## ##
8+
## This program is free software: you can redistribute it and/or modify ##
9+
## it under the terms of the GNU General Public License as published by ##
10+
## the Free Software Foundation, either version 3 of the License, or ##
11+
## (at your option) any later version. ##
12+
## ##
13+
## This program is distributed in the hope that it will be useful, ##
14+
## but WITHOUT ANY WARRANTY; without even the implied warranty of ##
15+
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ##
16+
## GNU General Public License for more details. ##
17+
## ##
18+
## You should have received a copy of the GNU General Public License ##
19+
## along with this program. If not, see <https://www.gnu.org/licenses/>. ##
20+
## ##
21+
## Author's email: ccie18643@gmail.com ##
22+
## Github repository: https://github.com/ccie18643/PyTCP ##
23+
## ##
24+
################################################################################
25+
26+
27+
"""
28+
This module contains a minimal TCP Echo client (RFC 862) for the PyTCP
29+
daemon — the companion to 'tcp_echo_server__async.py'. It connects, sends
30+
one message, half-closes its write side (so the server replies and closes),
31+
and prints everything the server sent back — the greeting, the echoed
32+
message (send 'malpi' for an ASCII-art monkey), and the closing farewell.
33+
34+
The socket source is injectable ('make_socket') so the same client logic is
35+
exercised over real loopback sockets in the project's test suite; the 'main'
36+
entry point wires it to a PyTCP daemon socket.
37+
38+
Needs a running daemon that owns the TAP interface, e.g.:
39+
40+
pytcp stack start -i tap7 --ip4-host <stack-ip>/24
41+
PYTCP_DAEMON_SOCKET=/tmp/pytcp.sock \
42+
python -m examples.tcp_echo_client <server-ip> --message malpi
43+
44+
examples/tcp_echo_client.py
45+
46+
ver 3.0.8
47+
"""
48+
49+
import socket
50+
from collections.abc import Callable
51+
from typing import cast
52+
53+
import click
54+
55+
from net_addr import Ip4Address
56+
from pytcp import socket as pytcp_socket
57+
58+
# A 'make_socket' returns a fresh, unbound AF_INET stream socket. The daemon
59+
# wiring returns a PyTCP drop-in socket; tests pass a stdlib factory.
60+
type MakeSocket = Callable[[], socket.socket]
61+
62+
ECHO__PORT: int = 7
63+
ECHO__CHUNK_SIZE: int = 65536
64+
65+
66+
def echo_once(*, host: str, port: int, message: bytes, timeout: float, make_socket: MakeSocket) -> bytes:
67+
"""
68+
Connect to the echo server at '(host, port)', send 'message', half-close
69+
the write side, and return everything the server sends back before it
70+
closes (greeting + echoed message + farewell). Blocks up to 'timeout'
71+
seconds per socket operation.
72+
"""
73+
74+
sock = make_socket()
75+
try:
76+
sock.settimeout(timeout)
77+
sock.connect((host, port))
78+
sock.sendall(message)
79+
# Half-close: signal "done sending" so the server echoes, sends its
80+
# farewell, and closes — letting us drain the reply to EOF.
81+
sock.shutdown(socket.SHUT_WR)
82+
chunks: list[bytes] = []
83+
while chunk := sock.recv(ECHO__CHUNK_SIZE):
84+
chunks.append(chunk)
85+
finally:
86+
sock.close()
87+
return b"".join(chunks)
88+
89+
90+
def _pytcp_make_socket() -> socket.socket:
91+
"""
92+
Create a fresh PyTCP daemon-backed AF_INET stream socket (typed as a
93+
'socket.socket' for the stdlib-shaped client plumbing).
94+
"""
95+
96+
return cast(socket.socket, pytcp_socket.socket(pytcp_socket.AF_INET, pytcp_socket.SOCK_STREAM))
97+
98+
99+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
100+
@click.argument("host")
101+
@click.option("--port", type=click.IntRange(1, 0xFFFF), default=ECHO__PORT, show_default=True)
102+
@click.option("--message", "-m", default="malpi", show_default=True, help="Text to send to the echo server.")
103+
@click.option("--timeout", "-W", type=float, default=5.0, show_default=True, help="Seconds per socket operation.")
104+
def tcp_echo_client(host: str, port: int, message: str, timeout: float) -> None:
105+
"""
106+
Connect to a TCP Echo server at HOST, send one message, and print the
107+
server's reply (greeting + echo + farewell).
108+
"""
109+
110+
Ip4Address(host) # validate; raises a clear net_addr error on a bad literal
111+
112+
try:
113+
reply = echo_once(
114+
host=host,
115+
port=port,
116+
message=message.encode("utf-8", "replace"),
117+
timeout=timeout,
118+
make_socket=_pytcp_make_socket,
119+
)
120+
except (TimeoutError, ConnectionError) as error:
121+
raise click.ClickException(f"Echo exchange with {host}:{port} failed: {error}") from error
122+
123+
click.echo(reply.decode("utf-8", "replace"), nl=False)
124+
125+
126+
if __name__ == "__main__":
127+
tcp_echo_client() # pylint: disable=no-value-for-parameter # click injects the arguments

examples/tcp_echo_server__async.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env python3
2+
3+
################################################################################
4+
## ##
5+
## PyTCP - Python TCP/IP stack ##
6+
## Copyright (C) 2020-present Sebastian Majewski ##
7+
## ##
8+
## This program is free software: you can redistribute it and/or modify ##
9+
## it under the terms of the GNU General Public License as published by ##
10+
## the Free Software Foundation, either version 3 of the License, or ##
11+
## (at your option) any later version. ##
12+
## ##
13+
## This program is distributed in the hope that it will be useful, ##
14+
## but WITHOUT ANY WARRANTY; without even the implied warranty of ##
15+
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ##
16+
## GNU General Public License for more details. ##
17+
## ##
18+
## You should have received a copy of the GNU General Public License ##
19+
## along with this program. If not, see <https://www.gnu.org/licenses/>. ##
20+
## ##
21+
## Author's email: ccie18643@gmail.com ##
22+
## Github repository: https://github.com/ccie18643/PyTCP ##
23+
## ##
24+
################################################################################
25+
26+
27+
"""
28+
This module contains an async TCP Echo server (RFC 862) for the PyTCP
29+
daemon — an asyncio program that runs over the daemon's drop-in stream
30+
socket. On connect it sends a greeting, then echoes every chunk it receives
31+
back to the client; as an easter egg carried over from the legacy examples,
32+
a chunk naming a monkey ('malpka' / 'malpa' / 'malpi') is answered with the
33+
matching ASCII-art payload. A 'quit' / 'close' / 'bye' / 'exit' line, or the
34+
client closing its write half, ends the session with a farewell.
35+
36+
It is built on 'asyncio.start_server', passing an explicit PyTCP socket via
37+
'sock=' — the supported integration path (a global sys.modules['socket']
38+
swap is NOT viable because asyncio's own event loop needs the real socket
39+
module for its self-pipe / selector). Each accepted connection is handled by
40+
its own coroutine, so many clients are served concurrently on one loop.
41+
42+
The socket source is injectable ('make_socket') so the same server logic is
43+
exercised over real loopback sockets in the project's test suite; the 'main'
44+
entry point wires it to a PyTCP daemon socket.
45+
46+
Needs a running daemon that owns the TAP interface, e.g.:
47+
48+
pytcp stack start -i tap7 --ip4-host <stack-ip>/24
49+
PYTCP_DAEMON_SOCKET=/tmp/pytcp.sock \
50+
python -m examples.tcp_echo_server__async --host <stack-ip>
51+
# then, from a peer on the link:
52+
printf 'malpi\n' | nc <stack-ip> 7
53+
54+
examples/tcp_echo_server__async.py
55+
56+
ver 3.0.8
57+
"""
58+
59+
import asyncio
60+
import socket
61+
from collections.abc import Callable
62+
from typing import cast
63+
64+
import click
65+
66+
from examples.malpi import echo_reply
67+
from net_addr import Ip4Address
68+
from pytcp import socket as pytcp_socket
69+
70+
# A 'make_socket' returns a fresh, unbound AF_INET stream socket. The daemon
71+
# wiring returns a PyTCP drop-in socket; tests pass a stdlib factory.
72+
type MakeSocket = Callable[[], socket.socket]
73+
74+
ECHO__PORT: int = 7
75+
ECHO__CHUNK_SIZE: int = 65536
76+
77+
GREETING: bytes = b"***CLIENT OPEN / SERVICE OPEN***\n"
78+
FAREWELL__PEER_CLOSED: bytes = b"***CLIENT CLOSED, SERVICE CLOSING***\n"
79+
FAREWELL__QUIT: bytes = b"***CLIENT OPEN, SERVICE CLOSING***\n"
80+
QUIT_WORDS: frozenset[bytes] = frozenset({b"quit", b"close", b"bye", b"exit"})
81+
82+
83+
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
84+
"""
85+
Drive one echo connection: greet, echo each received chunk (answering a
86+
monkey request with its ASCII art), and close with a farewell when the
87+
client sends a quit word or closes its write half.
88+
"""
89+
90+
writer.write(GREETING)
91+
await writer.drain()
92+
try:
93+
while True:
94+
data = await reader.read(ECHO__CHUNK_SIZE)
95+
if not data:
96+
writer.write(FAREWELL__PEER_CLOSED)
97+
await writer.drain()
98+
break
99+
if data.strip().lower() in QUIT_WORDS:
100+
writer.write(FAREWELL__QUIT)
101+
await writer.drain()
102+
break
103+
writer.write(echo_reply(data))
104+
await writer.drain()
105+
except ConnectionError:
106+
pass
107+
finally:
108+
writer.close()
109+
110+
111+
def _pytcp_make_socket() -> socket.socket:
112+
"""
113+
Create a fresh PyTCP daemon-backed AF_INET stream socket (typed as a
114+
'socket.socket' for the duck-typed asyncio plumbing).
115+
"""
116+
117+
return cast(socket.socket, pytcp_socket.socket(pytcp_socket.AF_INET, pytcp_socket.SOCK_STREAM))
118+
119+
120+
async def serve(*, host: str, port: int, make_socket: MakeSocket) -> None:
121+
"""
122+
Bind a listener via 'make_socket' and serve TCP echo forever.
123+
"""
124+
125+
listener = make_socket()
126+
listener.bind((host, port))
127+
server = await asyncio.start_server(_handle, sock=listener)
128+
async with server:
129+
await server.serve_forever()
130+
131+
132+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
133+
@click.option("--host", default=None, help="Stack IPv4 address to bind (the daemon's configured host).")
134+
@click.option("--port", type=click.IntRange(1, 0xFFFF), default=ECHO__PORT, show_default=True)
135+
def tcp_echo_server(host: str | None, port: int) -> None:
136+
"""
137+
Run an async TCP Echo server (RFC 862) over the PyTCP daemon.
138+
"""
139+
140+
if host is None:
141+
raise click.UsageError("--host is required (the stack IPv4 address the daemon is configured with).")
142+
Ip4Address(host) # validate; raises a clear net_addr error on a bad literal
143+
144+
click.echo(f"PyTCP async TCP echo server on {host}:{port} (send 'malpi' for a surprise)")
145+
try:
146+
asyncio.run(serve(host=host, port=port, make_socket=_pytcp_make_socket))
147+
except KeyboardInterrupt:
148+
click.echo("\nShutting down.")
149+
150+
151+
if __name__ == "__main__":
152+
tcp_echo_server() # pylint: disable=no-value-for-parameter # click injects the arguments

examples/udp_echo_server__async.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464

6565
import click
6666

67-
from examples.malpi import malpa, malpi, malpka
67+
from examples.malpi import echo_reply
6868
from net_addr import Ip4Address
6969
from pytcp import socket as pytcp_socket
7070

@@ -75,24 +75,6 @@
7575
ECHO__PORT: int = 7
7676

7777

78-
def echo_reply(message: bytes, /) -> bytes:
79-
"""
80-
Build the echo reply for 'message': the message itself, unless it names
81-
a monkey ('malpka' / 'malpa' / 'malpi'), in which case the matching
82-
ASCII-art monkey is returned. 'malpka' is tested before 'malpa' so the
83-
more specific name wins.
84-
"""
85-
86-
lowered = message.strip().lower()
87-
if b"malpka" in lowered:
88-
return malpka
89-
if b"malpa" in lowered:
90-
return malpa
91-
if b"malpi" in lowered:
92-
return malpi
93-
return message
94-
95-
9678
class _EchoServerProtocol(asyncio.DatagramProtocol):
9779
"""
9880
An asyncio datagram protocol that echoes each received datagram back to

0 commit comments

Comments
 (0)