From b2904be563f0f53cb43fbfdc70a5516ea678b6a0 Mon Sep 17 00:00:00 2001 From: wenceslao Date: Tue, 22 Sep 2026 11:33:38 +0000 Subject: [PATCH] update: tests, HOWTO, lab and gitignore --- .gitignore | 20 +- tests/HOWTO.txt | 158 + tests/Makefile | 43 + tests/checks/Makefile | 29 + tests/checks/basic/checks.sh | 67 + tests/{test_bot.sh => checks/bonus/bot.sh} | 39 +- tests/checks/bonus/transfer.sh | 72 + tests/checks/client/messaging.cpp | 141 + tests/checks/client/operator.cpp | 151 + .../client/parser.cpp} | 8 +- .../client/registration.cpp} | 170 +- tests/checks/client/reply.cpp | 66 + tests/checks/client/state.cpp | 121 + tests/checks/client/unit.hpp | 154 + .../networking/churn.py} | 19 +- tests/checks/networking/closing.py | 54 + tests/checks/networking/e2e.hpp | 166 + tests/checks/networking/flood.cpp | 189 + .../networking/longline.py} | 24 +- .../networking/overlong.py} | 38 +- .../networking/probe.py} | 21 +- .../networking/session.sh} | 0 tests/checks/networking/socket.cpp | 313 ++ tests/checks/networking/syscalls.c | 53 + tests/checks/networking/syscalls.sh | 114 + .../networking/throw.py} | 19 +- tests/checks/run.sh | 155 + tests/dynamic_tester/Makefile | 88 + tests/dynamic_tester/README.md | 131 + tests/dynamic_tester/irctop.py | 1423 +++++++ tests/dynamic_tester/test_irc.py | 3674 +++++++++++++++++ tests/dynamic_tester/tester.conf | 21 + tests/genprint.py | 21 - tests/reap_test.py | 82 - tests/src/test_client.cpp | 186 - tests/src/test_cmds2.cpp | 211 - tests/src/test_reply.cpp | 69 - tests/stall_test.py | 38 - tests/test_client.sh | 14 - tests/test_cmds2.sh | 16 - tests/test_parser.sh | 13 - tests/test_registration.sh | 16 - tests/test_reply.sh | 13 - tests/test_transfer.sh | 77 - 44 files changed, 7522 insertions(+), 975 deletions(-) create mode 100644 tests/HOWTO.txt create mode 100644 tests/Makefile create mode 100644 tests/checks/Makefile create mode 100755 tests/checks/basic/checks.sh rename tests/{test_bot.sh => checks/bonus/bot.sh} (70%) create mode 100755 tests/checks/bonus/transfer.sh create mode 100644 tests/checks/client/messaging.cpp create mode 100644 tests/checks/client/operator.cpp rename tests/{src/test_parser.cpp => checks/client/parser.cpp} (95%) rename tests/{src/test_registration.cpp => checks/client/registration.cpp} (54%) create mode 100644 tests/checks/client/reply.cpp create mode 100644 tests/checks/client/state.cpp create mode 100644 tests/checks/client/unit.hpp rename tests/{churn_test.py => checks/networking/churn.py} (84%) create mode 100644 tests/checks/networking/closing.py create mode 100644 tests/checks/networking/e2e.hpp create mode 100644 tests/checks/networking/flood.cpp rename tests/{longline_test.py => checks/networking/longline.py} (70%) rename tests/{overlong_test.py => checks/networking/overlong.py} (60%) rename tests/{probe_test.py => checks/networking/probe.py} (83%) rename tests/{test_session.sh => checks/networking/session.sh} (100%) create mode 100644 tests/checks/networking/socket.cpp create mode 100644 tests/checks/networking/syscalls.c create mode 100644 tests/checks/networking/syscalls.sh rename tests/{throw_test.py => checks/networking/throw.py} (68%) create mode 100755 tests/checks/run.sh create mode 100644 tests/dynamic_tester/Makefile create mode 100644 tests/dynamic_tester/README.md create mode 100644 tests/dynamic_tester/irctop.py create mode 100644 tests/dynamic_tester/test_irc.py create mode 100644 tests/dynamic_tester/tester.conf delete mode 100644 tests/genprint.py delete mode 100644 tests/reap_test.py delete mode 100644 tests/src/test_client.cpp delete mode 100644 tests/src/test_cmds2.cpp delete mode 100644 tests/src/test_reply.cpp delete mode 100644 tests/stall_test.py delete mode 100755 tests/test_client.sh delete mode 100755 tests/test_cmds2.sh delete mode 100755 tests/test_parser.sh delete mode 100644 tests/test_registration.sh delete mode 100755 tests/test_reply.sh delete mode 100755 tests/test_transfer.sh diff --git a/.gitignore b/.gitignore index c183ee7..0f9d20e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # subject en.subject.pdf +# md files +*.md +!README.md + # Prerequisites *.d @@ -26,7 +30,6 @@ en.subject.pdf *.dll *.so.* - # Fortran module files *.mod *.smod @@ -51,6 +54,11 @@ build/ build_debug/ Build/ build-*/ +dist/ +.dist + +# Byte compiled +__pycache__/ # CMake generated files CMakeFiles/ @@ -59,6 +67,10 @@ cmake_install.cmake install_manifest.txt compile_commands.json +# OS generated files +.DS_Store +.DS_Store? + # Temporary files *.tmp *.log @@ -75,3 +87,9 @@ vcpkg_installed/ Testing/ .cache/ reviewNotes.txt + +# test outputs +tests/dynamic_tester/artifacts/ +tests/checks/.*.test +received.bin +files/.syscalls_big.bin diff --git a/tests/HOWTO.txt b/tests/HOWTO.txt new file mode 100644 index 0000000..55a1847 --- /dev/null +++ b/tests/HOWTO.txt @@ -0,0 +1,158 @@ +::::::::::::::::::: + ft_irc tests +::::::::::::::::::: + +Notation +-------- + required, replace it +[value] optional + a | b choose one +... one or more, separated by spaces + +Defaults, set once for everything below: host 127.0.0.1, port 6667 (the subject's +port; also the one irssi is told to use), password pass. Files for the bonus +transfer are names under files/ (files/test.txt -> test.txt). + +Where to run +------------ +Every command below is written to be typed from the repo root: the ft_irc folder, +the one that contains the Makefile, srcs/ and tests/. + cd /ft_irc +"make -C tests X" means "run make X inside tests/". The same command from inside +the folder is just "make X": + from the repo root from inside the folder + make -C tests cd tests && make + make -C tests/checks bonus cd tests/checks && make bonus + make -C tests/dynamic_tester lab cd tests/dynamic_tester && make lab +Do not combine both ("cd tests" then "make -C tests"): there is no tests/tests. +Nothing needs to be built by hand first: the tests run make and make bonus +themselves. Run them in a normal terminal window (Terminal, iTerm), at least +100 columns by 30 rows; an editor's built-in terminal is fine for the checks +and the tester, but the lab, live, step, monitor and clients modes draw a +full-screen text interface and want a real terminal. Ctrl-C stops any of them. + +Requirements (macOS and Linux) +------------------------------ +c++ # C++ tests are built with the project flags: -Wall -Wextra -Werror -pedantic -std=c++98 +python3 # 3.8 or newer, standard library only bash, make, nc +optional # tmux and irssi (client windows), strace on Linux (syscall figures in the monitor) +Port 6667 must be free; the tester names the process using it otherwise. +The server is built by the tests themselves (make, make bonus). +macOS, before the load suite:ulimit -n 2048 + +============================================================================== +Everything +============================================================================== + make -C tests checks, then the dynamic tester; totals and the report link at the end + make -C tests checks the checks only + make -C tests tester the dynamic tester only + make -C tests clean remove test binaries and artifacts + +============================================================================== +tests/checks +============================================================================== +Fixed pass/fail tests. C++ tests are compiled with the project flags against +the server sources; scripts get their own fresh server on a free port. + + make -C tests/checks all groups + make -C tests/checks + make -C tests/checks one test + bash tests/checks/run.sh ... several tests in one run (any number) + bash tests/checks/run.sh usage and all names + + group name checks + --------- ----------- ------------------------------------------------------------- + basic basic Makefile rules and flags, no relink, only allowed functions, + one poll(), no select/epoll/kqueue/fork/errno, fcntl form + networking socket line framing, 510-byte limit, POLLOUT send path + flood a member stops reading while the channel is flooded: others + still served, the member is kept, every line delivered later + probe odd input: NUL, binary, CR CR LF, bare LF, 511 and 4096-byte lines + throw MODE on a missing channel, +o by a non-operator: server stays up + overlong 417 on an over-length line, close after the third one in a row + longline 10 KB without CRLF from three clients, a fourth stays responsive + churn 20 members, broadcast storm, JOIN/PART churn, RST disconnects + closing what the client gets before its link is closed: 464, ERROR + syscalls close/accept/send/recv return codes traced through every close + path, ircserv and ircserv_bonus (a fd closed twice, a failed accept); needs cc + client parser message grammar: prefix, command, params, trailing + reply numeric reply formatting + state client state: nick casefolding, invites, prefix, flags + registration PASS/NICK/USER/CAP in every order, 431 432 433 461 462 464 + messaging PING/PONG, QUIT, NOTICE, PRIVMSG and their numerics + operator 482/442 for non-operators; KICK INVITE TOPIC MODE i t k o l + bonus bot bot dialogue: !help !version !whoami !send !reject !accept, CTCP + transfer a DCC transfer through the bot, bytes compared with the file + + Output [ name ] what it checks + [section] + [PASS] check [FAIL] check + one PASS/FAIL line per test and "Results: N pass / M fail / T total" at the end. + Exit status 1 if anything failed. + ---------------------------------------------------------------------------- + Against a server you started yourself (./ircserv ): + bash tests/checks/networking/session.sh [host] [port] [password] [nick] raw IRC session, already registered + python3 tests/checks/networking/.py [port] [password] + bash tests/checks/networking/syscalls.sh [repo root] [password] starts its own traced servers + bash tests/checks/bonus/bot.sh [host] [port] [password] [file] ./ircserv_bonus + bash tests/checks/bonus/transfer.sh [host] [port] [password] [nick] [file] [botnick] ./ircserv_bonus + +============================================================================== +tests/dynamic_tester +============================================================================== +Starts the server on port 6667, drives it with real clients over TCP and +writes artifacts/report.html. + + make -C tests/dynamic_tester menu; Enter runs everything + make -C tests/dynamic_tester + make -C tests/dynamic_tester live everything, server and client windows shown while it runs + make -C tests/dynamic_tester step same, paused after every scenario: Enter next, c continue, p pause + make -C tests/dynamic_tester debug everything, every line sent and received printed + make -C tests/dynamic_tester open open the report (macOS) + make -C tests/dynamic_tester [PORT=] [PASSWORD=] + make -C tests/dynamic_tester external [PORT=] [PASSWORD=] a server you started yourself + make -C tests/dynamic_tester config the expectations in tester.conf and their values + make -C tests/dynamic_tester SET="= [=]..." override an expectation for one run + +----------------------------------------------------------------------------- + Result [PASS] / [FAIL] subject requirement + [NOTE] behaviour the subject leaves open; the check says what the server does + [INFO] a measurement + [SKIP] could not run + "Results: N pass / N fail / N notes / N info / N skip / T total", then the report link. +------------------------------------------------------------------------------ + Report artifacts/report.html: one page, every scenario with its checks (sent, received, + expected, why), the client windows and the server log. Scenarios with a FAIL or + NOTE are expanded. artifacts/transcripts/.txt: the raw lines. +------------------------------------------------------------------------------ + tester.conf what the tester expects where the subject leaves a choice (line endings, + over-long lines, a client that never reads, MODE numerics ...). Edit it to match + the server; a wrong entry shows as NOTE or FAIL in the affected checks. + +============================================================================== + Interactive +============================================================================== + make -C tests/dynamic_tester lab # client windows in one terminal, server log, menu bar, walkthrough + make -C tests/dynamic_tester clients # irssi or nc client windows on a launched server + make -C tests/dynamic_tester # same, program preselected + make -C tests/dynamic_tester monitor # live dashboard; starts the server itself + make -C tests/dynamic_tester attach [PORT=] [PASSWORD=] + # live dashboard on a running server + +============================================================================== + Monitor while the tester runs: two terminals from the repo root. +============================================================================== + terminal 1: make -C tests/dynamic_tester attach + # waits for port 6667, follows every server restart + terminal 2: make -C tests/dynamic_tester + # or any suite + + Keys in the monitor and the lab: Tab moves between the panes and ends on the menu bar; + in the menu bar Left/Right pick, Enter or Up/Down change, b back; Up/Down Left/Right + scroll the focused pane. Monitor: q quits. + Lab: typing goes to the focused client; Enter on an empty line runs the walkthrough step + shown above the input; type your own line to try something else first; /skip /back /stop; + the menu bar sets what the windows show, their order, log rows and filter, eol; Ctrl-D leaves. + +------------------------------------------------------------------------------ + Details: tests/dynamic_tester/README.md diff --git a/tests/Makefile b/tests/Makefile new file mode 100644 index 0000000..4b085b6 --- /dev/null +++ b/tests/Makefile @@ -0,0 +1,43 @@ +# ft_irc tests +# +# make -C tests checks (all groups), then the dynamic tester (all suites) +# make -C tests checks tests/checks: fixed C++/python/bash tests: basic networking client bonus +# make -C tests tester tests/dynamic_tester: protocol suites over TCP, HTML report +# make -C tests lab interactive lab (several client windows, server log, walkthrough) +# make -C tests clients irssi or nc client windows on a launched server +# make -C tests monitor live dashboard of the server +# +# Each part has its own Makefile with more targets: make -C tests/checks help make -C tests/dynamic_tester help + +SHELL := /bin/bash +REPORT := $(CURDIR)/dynamic_tester/artifacts/report.html + +.PHONY: all checks tester lab clients monitor clean + +all: + @[ -t 1 ] && export FORCE_COLOR=1; t=$$(mktemp); \ + $(MAKE) --no-print-directory -C "$(CURDIR)/checks" all 2>&1 | tee "$$t"; r1=$${PIPESTATUS[0]}; \ + c=$$(sed $$'s/\e\\[[0-9;]*m//g' "$$t" | grep -E '^ *Results:' | tail -1 | sed 's/^ *Results: *//'); \ + $(MAKE) --no-print-directory -C "$(CURDIR)/dynamic_tester" all 2>&1 | tee "$$t"; r2=$${PIPESTATUS[0]}; \ + d=$$(sed $$'s/\e\\[[0-9;]*m//g' "$$t" | grep -E '^ *Results:' | tail -1 | sed 's/^ *Results: *//'); rm -f "$$t"; \ + url=$$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).as_uri())' "$(REPORT)"); \ + echo; echo "================================ tests ================================"; \ + printf ' %-16s %s\n' "checks" "$${c:-did not run}"; \ + printf ' %-16s %s\n' "dynamic tester" "$${d:-did not run}"; \ + printf ' %-16s %s\n' "report" "$$url"; \ + printf ' %-16s %s\n' "" "(Cmd-click / Ctrl-click the link, or: make -C tests/dynamic_tester open)"; \ + echo "======================================================================="; \ + [ $$r1 = 0 ] && [ $$r2 = 0 ] + +checks: + @[ -t 1 ] && export FORCE_COLOR=1; $(MAKE) --no-print-directory -C "$(CURDIR)/checks" all + +tester: + @$(MAKE) --no-print-directory -C "$(CURDIR)/dynamic_tester" all + +lab clients monitor: + @$(MAKE) --no-print-directory -C "$(CURDIR)/dynamic_tester" $@ + +clean: + @$(MAKE) --no-print-directory -C "$(CURDIR)/checks" clean + @$(MAKE) --no-print-directory -C "$(CURDIR)/dynamic_tester" clean diff --git a/tests/checks/Makefile b/tests/checks/Makefile new file mode 100644 index 0000000..27c2eee --- /dev/null +++ b/tests/checks/Makefile @@ -0,0 +1,29 @@ +# ft_irc checks: basic, networking, client, bonus. macOS and Linux. +# +# make -C tests/checks everything: basic networking client bonus +# make -C tests/checks basic Makefile rules, flags, no relink, allowed functions only, one poll(), +# no select/epoll/kqueue/fork/errno, fcntl form (basic/checks.sh) +# make -C tests/checks networking framing, 510 limit, POLLOUT send path, stalled member kept (C++); +# odd input, crash regressions, over-length lines, killed +# and many clients (python, fresh server each) (networking/) +# make -C tests/checks client registration, PRIVMSG/NOTICE/PING/QUIT, channel operator +# commands and numerics, parser, replies (C++, no sockets) (client/) +# make -C tests/checks bonus bot dialogue, real DCC transfer (bash, fresh ircserv_bonus) (bonus/) +# make -C tests/checks socket one test by name: basic socket flood probe throw overlong longline +# churn closing syscalls parser reply state registration messaging operator bot transfer +# +# By hand: bash tests/checks/run.sh (no argument = usage) + +SHELL := /bin/bash +NAMES := basic socket flood probe throw overlong longline churn closing syscalls parser reply state registration messaging operator bot transfer + +.PHONY: all basic networking client bonus $(NAMES) clean help + +all networking bonus $(NAMES): + @bash "$(CURDIR)/run.sh" $@ + +clean: + @rm -f "$(CURDIR)"/.*.test "$(CURDIR)"/bonus/received.bin "$(CURDIR)"/networking/.syscalls.so + +help: + @sed -n '2,15p' "$(CURDIR)/Makefile" | sed 's/^# \{0,1\}//' diff --git a/tests/checks/basic/checks.sh b/tests/checks/basic/checks.sh new file mode 100755 index 0000000..f36477f --- /dev/null +++ b/tests/checks/basic/checks.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Evaluation sheet "Basic checks", verified on the sources: +# - Makefile has NAME/all/clean/fclean/re, builds with the required flags, +# executable named ircserv, a second make does not relink +# - only functions from the subject's allowed list (no read/write/open/sleep/...) +# - exactly one poll() (or equivalent) call site, no select/epoll/kqueue +# - no fork, no errno-driven logic +# - every fcntl() is fcntl(fd, F_SETFL, O_NONBLOCK) +# Usage: bash tests/checks/basic/checks.sh + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +fail=0 +ok() { echo " ok $1"; } +bad() { echo " FAIL $1"; fail=1; } +code() { # source lines with comments and string literals stripped, prefixed file:line + find "$ROOT/srcs" "$ROOT/includes" -type f \( -name '*.cpp' -o -name '*.hpp' -o -name '*.tpp' -o -name '*.ipp' \) | + while read -r f; do + awk -v F="${f#$ROOT/}" ' + BEGIN { inc = 0 } + { + line = $0 + if (inc) { if (sub(/.*\*\//, "", line)) inc = 0; else next } + while (match(line, /\/\*/)) { if (match(line, /\/\*.*\*\//)) { line = substr(line, 1, RSTART-1) substr(line, RSTART+RLENGTH) } else { line = substr(line, 1, RSTART-1); inc = 1; break } } + sub(/\/\/.*/, "", line) + gsub(/"([^"\\]|\\.)*"/, "\"\"", line) + print F ":" NR ": " line + }' "$f" + done +} +SRC=$(code) + +echo "[Makefile and build]" +for rule in all clean fclean re; do + grep -qE "^$rule[[:space:]]*:" "$ROOT/Makefile" && ok "rule $rule" || bad "rule $rule missing" +done +grep -qE '^[[:space:]]*NAME[[:space:]]*[:?]?=[[:space:]]*ircserv' "$ROOT/Makefile" && ok "NAME = ircserv" || bad "NAME is not ircserv" +grep -qE '^\s*CXXFLAGS.*-Wall.*-Wextra.*-Werror' "$ROOT/Makefile" && ok "flags -Wall -Wextra -Werror" || bad "flags -Wall -Wextra -Werror missing" +grep -qE '\-std=c\+\+98' "$ROOT/Makefile" && ok "flag -std=c++98" || bad "flag -std=c++98 missing" +if make -s -C "$ROOT" >/dev/null 2>&1 && [ -x "$ROOT/ircserv" ]; then ok "make builds ./ircserv"; else bad "make did not produce ./ircserv"; fi +second=$(make -C "$ROOT" 2>&1 | grep -cE '(c\+\+|clang|g\+\+).* -o ') +[ "$second" -eq 0 ] && ok "a second make does not relink" || bad "a second make recompiled/relinked ($second command(s))" + +echo "[allowed functions only]" +# not in the subject's list: read/write (send/recv are), open, stat, sleep/usleep, system, pipe, dup, exec*, ioctl +forb=$(printf '%s\n' "$SRC" | grep -E '(^|[^A-Za-z0-9_:.>])(read|write|open|stat|sleep|usleep|system|pipe|dup|dup2|execve|execl|execvp|ioctl|select)[[:space:]]*\(' | grep -vE 'std::|\.read\(|\.write\(|\.open\(|is_open\(') +if [ -z "$forb" ]; then ok "no function outside the subject's list (read/write/open/stat/sleep/system/pipe/dup/exec/ioctl)"; else bad "functions outside the allowed list:"; printf '%s\n' "$forb" | cut -d: -f1,2,3 | sed 's/^/ /'; fi + +echo "[one poll()]" +polls=$(printf '%s\n' "$SRC" | grep -E '(^|[^A-Za-z0-9_:.>])poll[[:space:]]*\(') +n=$(printf '%s\n' "$polls" | grep -c .) +if [ "$n" -eq 1 ]; then ok "exactly one poll() call site: $(printf '%s\n' "$polls" | cut -d: -f1,2)"; else bad "$n poll() call sites:"; printf '%s\n' "$polls" | cut -d: -f1,2 | sed 's/^/ /'; fi +if printf '%s\n' "$SRC" | grep -qE '(^|[^A-Za-z0-9_])(select|epoll_wait|epoll_create|kqueue|kevent)[[:space:]]*\('; then bad "select/epoll/kqueue found"; else ok "no select/epoll/kqueue"; fi + +echo "[no fork, no errno]" +if printf '%s\n' "$SRC" | grep -qE '(^|[^A-Za-z0-9_])fork[[:space:]]*\('; then bad "fork() found"; else ok "no fork()"; fi +if printf '%s\n' "$SRC" | grep -qE '(^|[^A-Za-z0-9_])errno([^A-Za-z0-9_]|$)'; then bad "errno used:"; printf '%s\n' "$SRC" | grep -E '(^|[^A-Za-z0-9_])errno' | cut -d: -f1,2 | sed 's/^/ /'; else ok "errno is not used"; fi + +echo "[fcntl form]" +fc=$(printf '%s\n' "$SRC" | grep -E '(^|[^A-Za-z0-9_])fcntl[[:space:]]*\(') +badfc=$(printf '%s\n' "$fc" | grep -vE 'fcntl[[:space:]]*\([^,]+,[[:space:]]*F_SETFL[[:space:]]*,[[:space:]]*O_NONBLOCK[[:space:]]*\)' | grep .) +if [ -z "$badfc" ]; then ok "every fcntl() is fcntl(fd, F_SETFL, O_NONBLOCK) ($(printf '%s\n' "$fc" | grep -c .) call(s))"; else bad "fcntl() used another way:"; printf '%s\n' "$badfc" | sed 's/^/ /'; fi + +echo "[non-blocking sockets]" +printf '%s\n' "$SRC" | grep -q 'O_NONBLOCK' && ok "O_NONBLOCK is set" || bad "O_NONBLOCK never set" + +exit $fail diff --git a/tests/test_bot.sh b/tests/checks/bonus/bot.sh similarity index 70% rename from tests/test_bot.sh rename to tests/checks/bonus/bot.sh index 80fe3b5..45d64ea 100755 --- a/tests/test_bot.sh +++ b/tests/checks/bonus/bot.sh @@ -1,12 +1,13 @@ #!/bin/bash -# DCC bot lifecycle test — no irssi/netcat needed. -# Uses bash /dev/tcp: one fd per IRC session, kept open for the whole test. - -# Usage: ./test_bot.sh [host] [port] [password] [testfile] -# Requires: server running (./ircserv_bonus PORT PASS, files/ present) +# The bot dialogue over raw TCP (bash /dev/tcp), two clients. +# +# bash tests/checks/bonus/bot.sh [host] [port] [password] [file] +# defaults: localhost 6667 pass test.txt; is a name under files/ +# needs a running ./ircserv_bonus set -u +failed=0 HOST=${1:-localhost} PORT=${2:-6667} @@ -14,9 +15,6 @@ PASS=${3:-pass} FILE=${4:-test.txt} BOT=bot -PASSED=0 -FAILED=0 - exec 3<>"/dev/tcp/$HOST/$PORT" 2>/dev/null || { echo "cannot connect"; exit 1; } # alice exec 4<>"/dev/tcp/$HOST/$PORT" 2>/dev/null || { echo "cannot connect"; exit 1; } # bob @@ -34,26 +32,25 @@ wait4() { if IFS= read -r -t 1 line <&"$fd"; then (( timeout -= 0 )) if [[ "$line" == *"$want"* ]]; then - echo "PASS [$who] saw: $want" - PASSED=$((PASSED + 1)) + echo " ok [$who] $want" return 0 fi - echo " [$who] $line" + echo " [$who] $line" else timeout=$((timeout - 1)) fi done - echo "FAIL [$who] never saw: $want" - FAILED=$((FAILED + 1)) + echo " FAIL [$who] never got: $want" + failed=1 return 1 } -echo "== registering alice (fd3) and bob (fd4) ==" +echo "[alice and bob register]" send 3 "PASS $PASS" "NICK alice" "USER a 0 * :Alice" send 4 "PASS $PASS" "NICK bob" "USER b 0 * :Bob" sleep 1 -echo "== bot commands (alice -> bot) ==" +echo "[bot commands from alice]" send 3 "PRIVMSG $BOT :!version" wait4 alice 3 "I am DCCBot on" @@ -69,19 +66,19 @@ wait4 alice 3 "Unknown command" send 3 "PRIVMSG $BOT :!accept" wait4 alice 3 "no pending transfers" -echo "== CTCP ==" +echo "[CTCP VERSION and PING]" send 3 "PRIVMSG $BOT :"$'\x01'"VERSION"$'\x01' wait4 alice 3 "DCCBot" send 3 "PRIVMSG $BOT :"$'\x01'"PING 12345"$'\x01' wait4 alice 3 "PING 12345" -echo "== send flow ==" +echo "[!send: the offer reaches bob]" send 3 "PRIVMSG $BOT :!send bob $FILE" wait4 alice 3 "offer sent to bob" wait4 bob 4 "DCC SEND $FILE" -echo "== reject frees the slot, then accept ==" +echo "[!reject frees the slot, a new offer, !accept]" send 4 "PRIVMSG $BOT :!reject" wait4 bob 4 "transfer rejected" @@ -92,12 +89,10 @@ wait4 bob 4 "DCC SEND $FILE" send 4 "PRIVMSG $BOT :!accept" wait4 bob 4 "transfer accepted" -echo "== busy while a transfer is active ==" +echo "[a second !send while a transfer is pending]" send 3 "PRIVMSG $BOT :!send bob $FILE" wait4 alice 3 "bot busy" -echo -echo "PASSED=$PASSED FAILED=$FAILED" exec 3<&- 3>&- exec 4<&- 4>&- -[ "$FAILED" -eq 0 ] +[ "$failed" -eq 0 ] diff --git a/tests/checks/bonus/transfer.sh b/tests/checks/bonus/transfer.sh new file mode 100755 index 0000000..2a0fef6 --- /dev/null +++ b/tests/checks/bonus/transfer.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# A DCC transfer through the bot: offer, accept, connect to the DCC port, read +# to EOF, compare with the original file. +# +# bash tests/checks/bonus/transfer.sh [host] [port] [password] [nick] [file] [botnick] +# defaults: 127.0.0.1 6667 pass alice test.txt DCCBot; is a name under files/ +# needs a running ./ircserv_bonus; the bytes received are saved next to this script as received.bin + +set -u + +HOST=${1:-127.0.0.1} +IRCPORT=${2:-6667} +PASS=${3:-pass} +WHO=${4:-alice} +FILE=${5:-test.txt} +BOT=${6:-DCCBot} +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ORIG="$HERE/../../../files/$FILE" +OUT="$HERE/received.bin" + +tmp=$(mktemp) +exec 3<>"/dev/tcp/$HOST/$IRCPORT" 2>/dev/null || { echo " FAIL cannot connect to $HOST:$IRCPORT"; exit 1; } +cat <&3 > "$tmp" & +catpid=$! +cleanup() { kill "$catpid" 2>/dev/null; exec 3<&- 2>/dev/null; rm -f "$tmp"; } +trap cleanup EXIT INT TERM + +echo "[$WHO offers files/$FILE to $WHO through the bot]" +sleep 0.3 +printf 'PASS %s\r\nNICK %s\r\nUSER %s 0 * :%s\r\n' "$PASS" "$WHO" "$WHO" "$WHO" >&3 +sleep 1 +printf 'PRIVMSG %s :!send %s %s\r\n' "$BOT" "$WHO" "$FILE" >&3 +sleep 1.5 +dcc=$(grep -oa 'DCC SEND [^[:space:]]* [^[:space:]]* [0-9]* [0-9]*' "$tmp" | tail -1) +ip=$(echo "$dcc" | awk '{print $4}') +port=$(echo "$dcc" | awk '{print $5}') +size=$(echo "$dcc" | awk '{print $6}') +if [ -z "$port" ]; then + echo " FAIL no DCC SEND offer received" + sed 's/^/ /' "$tmp" + exit 1 +fi +echo " ok offer received: $dcc" + +echo "[$WHO accepts and connects to $ip:$port]" +printf 'PRIVMSG %s :!accept\r\n' "$BOT" >&3 +sleep 1 +grep -qa "accepted" "$tmp" && echo " ok bot confirmed the accept" || echo " FAIL no accept confirmation from the bot" +python3 - "$ip" "$port" "$OUT" <<'PYEOF' +import socket, sys +ip, port, out = sys.argv[1], int(sys.argv[2]), sys.argv[3] +s = socket.create_connection((ip, port)); s.settimeout(10) +data = b"" +try: + while True: + chunk = s.recv(65536) + if not chunk: + break + data += chunk +except socket.timeout: + print(" FAIL no data or no close within 10 s") +s.close() +open(out, "wb").write(data) +PYEOF +got=$(wc -c < "$OUT" | tr -d ' ') +[ "$got" -gt 0 ] && echo " ok $got bytes received, EOF from the sender" || echo " FAIL nothing received" +if [ -f "$ORIG" ]; then + cmp -s "$ORIG" "$OUT" && echo " ok identical to files/$FILE ($size bytes announced)" || echo " FAIL differs from files/$FILE" +else + echo " files/$FILE not found here, content not compared" +fi +[ "$got" -gt 0 ] && { [ ! -f "$ORIG" ] || cmp -s "$ORIG" "$OUT"; } diff --git a/tests/checks/client/messaging.cpp b/tests/checks/client/messaging.cpp new file mode 100644 index 0000000..8e7e8a2 --- /dev/null +++ b/tests/checks/client/messaging.cpp @@ -0,0 +1,141 @@ +#include "unit.hpp" + +#include "ChannelManager.hpp" + +/* + * Tests for QUIT, PING, PONG (Connection.cpp), NOTICE and PRIVMSG + * (Messaging.cpp), driven through CommandProcessor::run exactly as the event + * loop does, without any socket (see unit.hpp). Clients are put into the + * registered state directly, so this file does not depend on the registration + * commands. + * + * Conn::send returns true when the handler asks the loop to close the link; + * error numerics are read back with drain() (handlers no longer throw). + */ + +int main() { + + { + std::cout << "[PING / PONG]\n"; + Server server(6667, "secret"); + Conn c(server, "bob"); + report(!c.send("PING abc") && + c.drain() == ":irc.local PONG irc.local :abc\r\n", + "PING -> PONG :"); + c.send("PING"); + report(contains(c.drain(), " 409 bob :No origin specified"), + "PING without token -> 409 (not 461)"); + report(!c.send("PONG abc") && c.drain().empty(), + "PONG accepted silently"); + + Conn fresh(server, 9, "h"); // not registered: PING must still work + report(!fresh.send("PING x") && + contains(fresh.drain(), "PONG irc.local :x"), + "PING is usable before registration"); + } + + { + std::cout << "[QUIT reaches channel peers exactly once]\n"; + Server server(6667, "secret"); + Conn a(server, "alice"); + Conn b(server, "bob"); + Conn c(server, "carol"); + ChannelManager &cm = server.getChannelManager(); + cm.join(*a.client, "#x"); + cm.join(*b.client, "#x"); + cm.join(*a.client, "#y"); + cm.join(*b.client, "#y"); // alice and bob share two channels + a.drain(); + b.drain(); + c.drain(); + + report(a.send("QUIT :bye") && a.closed, + "QUIT returns true: the loop must close the link"); + report(a.drain() == "ERROR :Closing link: (alice!a@h) [Quit: bye]\r\n", + "quitter gets ERROR :Closing link (no prefix)"); + report(b.drain() == ":alice!a@h QUIT :bye\r\n", + "peer in two shared channels sees the QUIT once"); + report(c.drain().empty(), "someone sharing no channel sees nothing"); + + Conn d(server, "dave"); + report(d.send("QUIT") && d.closed && + contains(d.drain(), "[Quit: Client Quit]"), + "QUIT without a message uses the default reason"); + + Conn fresh(server, 9, "h"); // unregistered client may QUIT too + report(fresh.send("QUIT") && + contains(fresh.drain(), "ERROR :Closing link: (*)"), + "QUIT before registration closes with prefix *"); + } + + { + std::cout << "[NOTICE delivers like PRIVMSG and never replies]\n"; + Server server(6667, "secret"); + Conn a(server, "alice"); + Conn b(server, "bob"); + Conn c(server, "carol"); + ChannelManager &cm = server.getChannelManager(); + cm.join(*a.client, "#room"); + cm.join(*b.client, "#room"); + a.drain(); + b.drain(); + + report(!a.send("NOTICE #room :psst") && a.drain().empty(), + "channel NOTICE: sender is not echoed"); + report(b.drain() == ":alice!a@h NOTICE #room :psst\r\n", + "channel NOTICE: other member receives it"); + report(!c.send("NOTICE #room :hi") && c.drain().empty(), + "NOTICE to a channel you are not in -> silence (no 404)"); + report(!c.send("NOTICE #nochan :hi") && c.drain().empty(), + "NOTICE to an unknown channel -> silence (no 403)"); + report(!c.send("NOTICE nobody :hi") && c.drain().empty(), + "NOTICE to an unknown nick -> silence (no 401)"); + report(!c.send("NOTICE") && c.drain().empty(), + "NOTICE without target -> silence (no 411)"); + report(!c.send("NOTICE #room") && c.drain().empty(), + "NOTICE without text -> silence (no 412)"); + report(!a.send("NOTICE bob :hey") && a.drain().empty() && + b.drain() == ":alice!a@h NOTICE bob :hey\r\n", + "NOTICE to a nick: only that nick gets it"); + } + + { + std::cout << "[PRIVMSG delivers and replies on errors]\n"; + Server server(6667, "secret"); + Conn a(server, "alice"); + Conn b(server, "bob"); + Conn c(server, "carol"); + ChannelManager &cm = server.getChannelManager(); + cm.join(*a.client, "#room"); + cm.join(*b.client, "#room"); + a.drain(); + b.drain(); + + report(!a.send("PRIVMSG #room :hello") && a.drain().empty(), + "channel PRIVMSG: sender is not echoed"); + report(b.drain() == ":alice!a@h PRIVMSG #room :hello\r\n", + "channel PRIVMSG: other member receives it"); + report(!a.send("PRIVMSG bob :hi bob") && a.drain().empty() && + b.drain() == ":alice!a@h PRIVMSG bob :hi bob\r\n", + "PRIVMSG to a nick"); + report(!c.send("PRIVMSG #room :hi") && + contains(c.drain(), " 404 carol #room "), + "PRIVMSG to a channel you are not in -> 404"); + report(!c.send("PRIVMSG #nochan :hi") && + contains(c.drain(), " 403 carol #nochan "), + "PRIVMSG to an unknown channel -> 403"); + report(!c.send("PRIVMSG nobody :hi") && + contains(c.drain(), " 401 carol nobody "), + "PRIVMSG to an unknown nick -> 401"); + report(!c.send("PRIVMSG") && contains(c.drain(), " 411 carol "), + "PRIVMSG without target -> 411"); + report(!c.send("PRIVMSG #room") && contains(c.drain(), " 412 carol "), + "PRIVMSG without text -> 412"); + report(!a.send("PRIVMSG bob,#room :both") && a.drain().empty() && + b.drain() == ":alice!a@h PRIVMSG bob :both\r\n" + ":alice!a@h PRIVMSG #room :both\r\n", + "PRIVMSG with two targets"); + } + + return finish("cmds2"); +} diff --git a/tests/checks/client/operator.cpp b/tests/checks/client/operator.cpp new file mode 100644 index 0000000..730ee0c --- /dev/null +++ b/tests/checks/client/operator.cpp @@ -0,0 +1,151 @@ +#include "unit.hpp" + +/* + * Channel operator commands (evaluation sheet: "a regular user does not have + * privileges to do channel operator actions; then test with an operator"): + * JOIN, KICK, INVITE, TOPIC, MODE i t k o l, driven through + * CommandProcessor::run without sockets (see unit.hpp). + */ + +int main() { + + { + std::cout << "[JOIN: first joiner is operator, others are not]\n"; + Server server(6667, "secret"); + Conn a(server, "alice"); + Conn b(server, "bob"); + a.send("JOIN #c"); + std::string w = a.drain(); + report(contains(w, ":alice!a@h JOIN #c"), "JOIN is echoed to the joiner"); + report(contains(w, " 353 alice = #c :@alice"), "353 names list shows @alice (operator)"); + report(contains(w, " 366 alice #c "), "366 end of names"); + b.send("JOIN #c"); + report(contains(a.drain(), ":bob!b@h JOIN #c"), "members see the new JOIN"); + report(contains(b.drain(), " 353 bob = #c :@alice bob"), "bob is listed without @"); + b.send("JOIN #c"); + report(!contains(b.drain(), " 4"), "joining twice is not an error"); + a.send("PART #nochan"); + report(contains(a.drain(), " 403 alice #nochan "), "PART of an unknown channel -> 403"); + } + + { + std::cout << "[regular user has no operator privileges]\n"; + Server server(6667, "secret"); + Conn a(server, "alice"); + Conn b(server, "bob"); + Conn c(server, "carol"); + a.send("JOIN #c"); b.send("JOIN #c"); a.drain(); b.drain(); + b.send("KICK #c alice"); + report(contains(b.drain(), " 482 bob #c "), "KICK by a member -> 482"); + b.send("MODE #c +i"); + report(contains(b.drain(), " 482 bob #c "), "MODE +i by a member -> 482"); + b.send("MODE #c +o bob"); + report(contains(b.drain(), " 482 bob #c "), "MODE +o by a member -> 482"); + a.send("MODE #c +t"); a.drain(); b.drain(); + b.send("TOPIC #c :hijack"); + report(contains(b.drain(), " 482 bob #c "), "TOPIC under +t by a member -> 482"); + c.send("KICK #c bob"); + report(contains(c.drain(), " 442 carol #c "), "KICK by a non-member -> 442"); + c.send("TOPIC #c"); + report(contains(c.drain(), " 442 carol #c "), "TOPIC by a non-member -> 442"); + } + + { + std::cout << "[operator: KICK]\n"; + Server server(6667, "secret"); + Conn a(server, "alice"); + Conn b(server, "bob"); + a.send("JOIN #c"); b.send("JOIN #c"); a.drain(); b.drain(); + a.send("KICK #c bob :bye"); + report(contains(a.drain(), ":alice!a@h KICK #c bob :bye") && + contains(b.drain(), ":alice!a@h KICK #c bob :bye"), + "KICK is broadcast to everyone including the kicked user"); + b.send("PRIVMSG #c :still here?"); + report(contains(b.drain(), " 404 bob #c "), "kicked user is out (404 on PRIVMSG)"); + a.send("KICK #c bob"); + report(contains(a.drain(), " 441 alice bob #c "), "KICK of a non-member -> 441"); + a.send("KICK #c"); + report(contains(a.drain(), " 461 alice KICK "), "KICK without nick -> 461"); + } + + { + std::cout << "[operator: INVITE and +i]\n"; + Server server(6667, "secret"); + Conn a(server, "alice"); + Conn b(server, "bob"); + a.send("JOIN #c"); a.drain(); + a.send("MODE #c +i"); a.drain(); + b.send("JOIN #c"); + report(contains(b.drain(), " 473 bob #c "), "JOIN an invite-only channel -> 473"); + a.send("INVITE bob #c"); + report(contains(a.drain(), " 341 alice #c bob"), "inviter gets 341 "); + report(contains(b.drain(), ":alice!a@h INVITE bob #c"), "invitee gets the INVITE"); + b.send("JOIN #c"); + report(contains(b.drain(), " 353 bob = #c "), "invited user can join"); + a.send("INVITE nobody #c"); + report(contains(a.drain(), " 401 alice nobody "), "INVITE an unknown nick -> 401"); + a.send("INVITE bob #c"); + report(contains(a.drain(), " 443 alice bob #c "), "INVITE a member -> 443"); + Conn c(server, "carol"); + b.send("INVITE carol #c"); + report(contains(b.drain(), " 482 bob #c "), "INVITE by a non-operator under +i -> 482"); + } + + { + std::cout << "[operator: TOPIC and +t]\n"; + Server server(6667, "secret"); + Conn a(server, "alice"); + Conn b(server, "bob"); + a.send("JOIN #c"); b.send("JOIN #c"); a.drain(); b.drain(); + a.send("TOPIC #c"); + report(contains(a.drain(), " 331 alice #c "), "no topic -> 331"); + b.send("TOPIC #c :hello"); + report(contains(a.drain(), ":bob!b@h TOPIC #c :hello"), "without +t any member sets the topic, broadcast"); + b.drain(); + a.send("TOPIC #c"); + report(contains(a.drain(), " 332 alice #c :hello"), "TOPIC query -> 332"); + a.send("MODE #c +t"); a.drain(); b.drain(); + b.send("TOPIC #c :nope"); + report(contains(b.drain(), " 482 bob #c "), "under +t a member cannot set it -> 482"); + a.send("TOPIC #c :ops only"); + report(contains(b.drain(), ":alice!a@h TOPIC #c :ops only"), "the operator can"); + } + + { + std::cout << "[operator: MODE +k +l +o and the query]\n"; + Server server(6667, "secret"); + Conn a(server, "alice"); + Conn b(server, "bob"); + Conn c(server, "carol"); + a.send("JOIN #c"); a.drain(); + a.send("MODE #c +k secret"); + report(contains(a.drain(), ":alice!a@h MODE #c +k secret"), "+k is echoed"); + b.send("JOIN #c"); + report(contains(b.drain(), " 475 bob #c "), "JOIN without the key -> 475"); + b.send("JOIN #c wrong"); + report(contains(b.drain(), " 475 bob #c "), "JOIN with a wrong key -> 475"); + b.send("JOIN #c secret"); + report(contains(b.drain(), " 353 bob = #c "), "JOIN with the key works"); + a.drain(); + a.send("MODE #c -k"); a.drain(); b.drain(); + a.send("MODE #c +l 2"); a.drain(); b.drain(); + c.send("JOIN #c"); + report(contains(c.drain(), " 471 carol #c "), "JOIN over the limit -> 471"); + a.send("MODE #c"); + report(contains(a.drain(), " 324 alice #c +"), "MODE query -> 324 with the current modes"); + a.send("MODE #c +o bob"); + report(contains(b.drain(), ":alice!a@h MODE #c +o bob"), "+o is broadcast"); + b.send("MODE #c -l"); b.drain(); a.drain(); + c.send("JOIN #c"); + report(contains(c.drain(), " 353 carol = #c "), "the new operator lifted the limit"); + b.send("MODE #c -o alice"); a.drain(); b.drain(); c.drain(); + a.send("KICK #c bob"); + report(contains(a.drain(), " 482 alice #c "), "after -o alice is a regular member again"); + a.send("MODE #c +z"); + report(contains(a.drain(), " 472 alice z "), "unknown mode letter -> 472"); + a.send("MODE #nochan +i"); + report(contains(a.drain(), " 403 alice #nochan "), "MODE on an unknown channel -> 403"); + } + + return finish("operator"); +} diff --git a/tests/src/test_parser.cpp b/tests/checks/client/parser.cpp similarity index 95% rename from tests/src/test_parser.cpp rename to tests/checks/client/parser.cpp index a2be2dd..f767c8f 100644 --- a/tests/src/test_parser.cpp +++ b/tests/checks/client/parser.cpp @@ -13,7 +13,7 @@ * alone: no Server, no Client, no handler stubs. The whole IRC message * grammar is checked offline, before the poll loop exists. * - * Run: ./tests/test_parser.sh + * Run: bash tests/run.sh parser */ static int failures = 0; @@ -60,7 +60,6 @@ static void expect(const std::string &label, const std::string &line, } int main() { - std::cout << "Parser tests: \n\n"; // An empty message is silently ignored per RFC 2812 section 2.3, so parse // reports failure and run() returns without sending anything back. @@ -147,10 +146,5 @@ int main() { expect("[MODE]", "MODE #chan +o bob", true, "MODE", p); } - std::cout << "\n"; - if (failures == 0) - std::cout << "all checks passed\n"; - else - std::cout << failures << " check(s) failed\n"; return (failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE); } diff --git a/tests/src/test_registration.cpp b/tests/checks/client/registration.cpp similarity index 54% rename from tests/src/test_registration.cpp rename to tests/checks/client/registration.cpp index 71dcf65..65938df 100644 --- a/tests/src/test_registration.cpp +++ b/tests/checks/client/registration.cpp @@ -1,93 +1,29 @@ -#include "Client.hpp" -#include "CommandProcessor.hpp" -#include "Errors.hpp" -#include "Server.hpp" +#include "unit.hpp" -#include -#include -#include +#include "Errors.hpp" /* * Tests for connection registration (RFC 2812 section 3.1): PASS, NICK, USER, - * CAP and Registration::tryRegister, driven through CommandProcessor::run exactly - * as the event loop does, without any socket. + * CAP and Registration::tryRegister, driven through CommandProcessor::run + * exactly as the event loop does, without any socket (see unit.hpp for how + * the replies are read back). * - * Links against the real Server.cpp, so this test breaks loudly if the Server - * constructor changes on main. Registration is driven through this file's own - * CommandProcessor, so it does not depend on Server registering its commands. + * Error replies are no longer thrown: CommandProcessor::run sends the numeric + * to the client and returns, so every check reads the numeric from drain(). */ -static int failures = 0; - -static void report(bool ok, const std::string &what) { - std::cout << (ok ? " ok " : " FAIL ") << what << "\n"; - if (!ok) - failures++; -} - -// A fake connection: a Client registered in the server's ClientManager, and -// a way to feed it lines like the event loop would. -struct Conn { - Server &server; - CommandProcessor cmds; - Client *client; - bool closed; - std::string out; // everything the server queued for this client - std::string error; // numeric reply carried by the last thrown Error - - Conn(Server &s, int fd, const std::string &host) - : server(s), closed(false) { - cmds.registerCommands(); - client = new Client(fd); - client->setHostname(host); - server.getClientManager().add(fd, client); - } - - // send feeds one line; returns false if the handler threw an Error, in - // which case `error` holds the numeric reply exactly as the loop sends it. - bool send(const std::string &line) { - error.clear(); - try { - closed = cmds.run(server, *client, line) || closed; - } catch (const Error &e) { - if (e.hasReply()) - error = Reply::build( - server.getServerName(), - client->getNick().empty() ? "*" : client->getNick(), - e.reply()); - else - error = e.what(); - return false; - } - return true; - } - - // drain returns what was queued since the last drain. - std::string drain() { - std::string s = client->getSendBuffer(); - client->clearSendBuffer(); - out += s; - return s; - } -}; - -static bool contains(const std::string &hay, const std::string &needle) { - return hay.find(needle) != std::string::npos; -} - int main() { - std::cout << "=== Registration ===\n\n"; { std::cout << "[PASS NICK USER in RFC order]\n"; Server server(6667, "secret"); Conn c(server, 4, "127.0.0.1"); - report(c.send("PASS secret"), "PASS accepted"); + report(!c.send("PASS secret"), "PASS accepted (link stays open)"); report(c.drain().empty(), "PASS sends nothing"); - report(c.send("NICK bob"), "NICK accepted"); + report(!c.send("NICK bob"), "NICK accepted"); report(c.drain().empty(), "NICK sends nothing before registration"); report(!c.client->isRegistered(), "not registered before USER"); - report(c.send("USER b 0 * :Bob Example"), "USER accepted"); + report(!c.send("USER b 0 * :Bob Example"), "USER accepted"); std::string w = c.drain(); report(c.client->isRegistered(), "registered after NICK+USER"); report(contains(w, ":irc.local 001 bob :Welcome to the Internet Relay " @@ -114,17 +50,17 @@ int main() { std::cout << "[CAP LS first (irssi), PASS between NICK and USER]\n"; Server server(6667, "secret"); Conn c(server, 4, "::1"); - report(c.send("CAP LS 302"), "CAP LS accepted"); - report(c.drain() == ":irc.local CAP * LS :\r\n", - "CAP LS gets an empty list"); - report(c.send("NICK alice"), "NICK before PASS is fine"); - report(c.send("PASS secret"), "PASS after NICK accepted"); + report(!c.send("CAP LS 302"), "CAP LS accepted"); + report(contains(c.drain(), ":irc.local CAP * LS :"), + "CAP LS gets an empty capability list"); + report(!c.send("NICK alice"), "NICK before PASS is fine"); + report(!c.send("PASS secret"), "PASS after NICK accepted"); report(c.drain().empty() && !c.client->isRegistered(), "nothing sent, still waiting for USER"); - report(c.send("USER a 0 * :Alice"), "USER completes registration"); + report(!c.send("USER a 0 * :Alice"), "USER completes registration"); report(c.client->isRegistered(), "registered"); report(contains(c.drain(), " 001 alice "), "welcome sent"); - report(c.send("CAP END"), "CAP END ignored"); + report(!c.send("CAP END"), "CAP END ignored"); report(c.drain().empty(), "CAP END sends nothing"); } @@ -134,7 +70,7 @@ int main() { Conn c(server, 4, "h"); c.send("PASS nope"); c.send("NICK bob"); - report(c.send("USER b 0 * :B"), "USER handled"); + report(c.send("USER b 0 * :B"), "USER with the wrong PASS asks to close the link"); std::string w = c.drain(); report(contains(w, ":irc.local 464 bob :Password incorrect\r\n"), "464 password mismatch"); @@ -157,34 +93,36 @@ int main() { std::cout << "[NICK errors]\n"; Server server(6667, "secret"); Conn c(server, 4, "h"); - report(!c.send("NICK") && - contains(c.error, " 431 * :No nickname given"), + c.send("NICK"); + report(contains(c.drain(), " 431 * :No nickname given"), "NICK without parameter -> 431 (not 461)"); - report(!c.send("NICK 1bob") && - contains(c.error, " 432 * 1bob :Erroneous"), + c.send("NICK 1bob"); + report(contains(c.drain(), " 432 * 1bob :Erroneous"), "nick starting with a digit -> 432"); - report(c.send("NICK bo b"), "extra param ignored, first is taken"); + report(!c.send("NICK bo b"), "extra param ignored, first is taken"); report(c.client->getNick() == "bo", "nick is 'bo'"); - report(!c.send("NICK #bob") && contains(c.error, " 432 bo #bob"), + c.send("NICK #bob"); + report(contains(c.drain(), " 432 bo #bob"), "nick with # -> 432, reply addressed to current nick"); - report(!c.send("NICK abcdefghijklmnopqrstuvwxyz01234") && - contains(c.error, " 432 "), + c.send("NICK abcdefghijklmnopqrstuvwxyz01234"); + report(contains(c.drain(), " 432 "), "31 chars -> 432 (NICKLEN 30)"); - report(c.send("NICK [b-o_b]^`\\{|}") && + report(!c.send("NICK [b-o_b]^`\\{|}") && c.client->getNick() == "[b-o_b]^`\\{|}", "all RFC specials accepted"); - report(c.send("NICK bob"), "back to bob"); + report(!c.send("NICK bob"), "back to bob"); Conn d(server, 5, "h"); - report(!d.send("NICK BOB") && - contains(d.error, " 433 * BOB :Nickname is already in use"), + d.send("NICK BOB"); + report(contains(d.drain(), " 433 * BOB :Nickname is already in use"), "same nick different case -> 433"); - report(d.send("NICK carol"), "free nick accepted"); - report(c.send("NICK b[o]b"), "bob renames to b[o]b"); - report(!d.send("NICK B{O}B") && contains(d.error, " 433 carol B{O}B"), + report(!d.send("NICK carol"), "free nick accepted"); + report(!c.send("NICK b[o]b"), "bob renames to b[o]b"); + d.send("NICK B{O}B"); + report(contains(d.drain(), " 433 carol B{O}B"), "[] vs {} equivalence (rfc1459 casemapping) -> 433"); - report(c.send("NICK bob"), "back to bob"); - report(c.send("NICK Bob") && c.client->getNick() == "Bob", + report(!c.send("NICK bob"), "back to bob"); + report(!c.send("NICK Bob") && c.client->getNick() == "Bob", "a client may change the case of its own nick"); } @@ -196,12 +134,13 @@ int main() { c.send("NICK bob"); c.send("USER b 0 * :B"); c.drain(); - report(!c.send("PASS secret") && - contains(c.error, " 462 bob :Unauthorized command"), + c.send("PASS secret"); + report(contains(c.drain(), " 462 bob :Unauthorized command"), "PASS after registration -> 462"); - report(!c.send("USER b 0 * :B") && contains(c.error, " 462 "), + c.send("USER b 0 * :B"); + report(contains(c.drain(), " 462 "), "USER after registration -> 462"); - report(c.send("NICK robert"), "NICK change accepted"); + report(!c.send("NICK robert"), "NICK change accepted"); report(c.drain() == ":bob!b@h NICK robert\r\n", "rename echoed with the old prefix"); report(server.getClientManager().hasNick("robert") && @@ -213,16 +152,17 @@ int main() { std::cout << "[unregistered client using a registered-only command]\n"; Server server(6667, "secret"); Conn c(server, 4, "h"); - report(!c.send("JOIN #a") && - contains(c.error, " 451 * :You have not registered"), + c.send("JOIN #a"); + report(contains(c.drain(), " 451 * :You have not registered"), "JOIN before registration -> 451"); - report(!c.send("USER b") && - contains(c.error, " 461 * USER :Not enough parameters"), + c.send("USER b"); + report(contains(c.drain(), " 461 * USER :Not enough parameters"), "USER with 1 param -> 461"); - report(!c.send("BOGUS") && - contains(c.error, " 421 * BOGUS :Unknown command"), + c.send("BOGUS"); + report(contains(c.drain(), " 421 * BOGUS :Unknown command"), "unknown command -> 421"); - report(!c.send("PASS") && contains(c.error, " 461 * PASS"), + c.send("PASS"); + report(contains(c.drain(), " 461 * PASS"), "PASS without param -> 461"); } @@ -233,9 +173,8 @@ int main() { Conn d(server, 5, "h"); report(server.getClientManager().size() == 2, "two connections without nicks"); - Client *dup = new Client(4); - report(!server.getClientManager().add(4, dup), "duplicate fd rejected"); - delete dup; + report(!server.getClientManager().add(4, "h", server), + "duplicate fd rejected"); report(server.getClientManager().find(9) == 0, "find unknown fd -> NULL"); c.send("NICK bob"); @@ -255,8 +194,5 @@ int main() { report(true, "remove(unknown fd) is a no-op"); } - std::cout << "\n" - << (failures ? "FAILED" : "ALL OK") << " (" << failures - << " failures)\n"; - return failures ? EXIT_FAILURE : EXIT_SUCCESS; + return finish("registration"); } diff --git a/tests/checks/client/reply.cpp b/tests/checks/client/reply.cpp new file mode 100644 index 0000000..9511922 --- /dev/null +++ b/tests/checks/client/reply.cpp @@ -0,0 +1,66 @@ +#include "Errors.hpp" +#include "Reply.hpp" + +#include +#include +#include + +static int failures = 0; + +static void report(bool ok, const std::string &what) { + if (!ok) + failures++; + std::cout << (ok ? " ok " : " FAIL ") << what << "\n"; +} + +static void same(const std::string &got, const std::string &want, const std::string &what) { + report(got == want, what + (got == want ? "" : " (got " + got.substr(0, got.size() - 2) + ")")); +} + +int main() { + std::cout << "[numeric replies: :server code target params :text]\n"; + same(Reply::build("irc.local", "alice", Reply::reply(Reply::ERR_NOSUCHNICK, "bob")), + ":irc.local 401 alice bob :No such nick/channel\r\n", "401 nick :No such nick/channel"); + same(Reply::build("irc.local", "*", Reply::reply(Reply::ERR_NICKNAMEINUSE, "alice")), + ":irc.local 433 * alice :Nickname is already in use\r\n", "433 to * before registration"); + same(Reply::build("irc.local", "alice", Reply::reply(Reply::ERR_NEEDMOREPARAMS, "PRIVMSG")), + ":irc.local 461 alice PRIVMSG :Not enough parameters\r\n", "461 command :Not enough parameters"); + same(Reply::build("irc.local", "alice", + Reply::reply(Reply::RPL_WELCOME, "", "Welcome to the Internet Relay Network alice!user@host")), + ":irc.local 001 alice :Welcome to the Internet Relay Network alice!user@host\r\n", + "001 with a caller-supplied text"); + same(Reply::build("irc.local", "alice", Reply::reply(Reply::RPL_TOPIC, "#chan", "a topic")), + ":irc.local 332 alice #chan :a topic\r\n", "332 channel :topic"); + same(Reply::build("irc.local", "alice", Reply::reply(Reply::RPL_NAMREPLY, "= #chan", "@alice bob")), + ":irc.local 353 alice = #chan :@alice bob\r\n", "353 = channel :names"); + same(Reply::build("irc.local", "alice", Reply::reply(Reply::RPL_ENDOFNAMES, "#chan")), + ":irc.local 366 alice #chan :End of NAMES list\r\n", "366 channel :End of NAMES list"); + same(Reply::build("irc.local", "alice", Reply::reply(Reply::RPL_TOPIC, "#chan", "")), + ":irc.local 332 alice #chan :\r\n", "332 with an empty topic keeps the colon"); + same(Reply::build("irc.local", "*", Reply::reply(Reply::ERR_NOTREGISTERED, "")), + ":irc.local 451 * :You have not registered\r\n", "451 with no parameter"); + same(Reply::build("irc.local", "*", Reply::reply(Reply::ERR_INPUTTOOLONG, "")), + ":irc.local 417 * :Input line was too long\r\n", "417 with no parameter"); + + std::cout << "[Error carrying a reply]\n"; + try { + throw Error::reply(Reply::ERR_NOTONCHANNEL, "#chan"); + } catch (const Error &e) { + report(e.hasReply(), "Error::reply has a reply"); + same(Reply::build("irc.local", "alice", e.reply()), + ":irc.local 442 alice #chan :You're not on that channel\r\n", "the reply builds as 442"); + } + try { + throw Error("internal: channel name invalid"); + } catch (const Error &e) { + report(!e.hasReply(), "a plain Error has no reply"); + report(std::string(e.what()) == "internal: channel name invalid", "what() is the message"); + } + try { + throw Error::reply(Reply::ERR_USERNOTINCHANNEL, "bob #chan"); + } catch (const Error &e) { + same(Reply::build("irc.local", "alice", e.reply()), + ":irc.local 441 alice bob #chan :They aren't on that channel\r\n", "441 with two parameters"); + } + return failures ? EXIT_FAILURE : EXIT_SUCCESS; +} diff --git a/tests/checks/client/state.cpp b/tests/checks/client/state.cpp new file mode 100644 index 0000000..22d942e --- /dev/null +++ b/tests/checks/client/state.cpp @@ -0,0 +1,121 @@ +#include "Client.hpp" + +#include +#include +#include + +/* + * Tests for Client: per-connection state only. Since the POLLOUT rework the + * Client has no buffers of its own -- Socket owns the recv/send buffers (see + * test_socket) and Client::send() hands the bytes to Server::sendMsg. With no + * Server attached, send() is a no-op, which is what these checks rely on. + */ + +static int failures = 0; + +static void report(bool ok, const std::string &what) { + std::cout << (ok ? " ok " : " FAIL ") << what << "\n"; + if (!ok) + failures++; +} + +int main() { + + { + std::cout << "[construction]\n"; + Client c; + report(c.getFd() == -1, "default client has fd -1"); + Client d(7, "10.0.0.2", 0); + report(d.getFd() == 7, "fd is stored"); + report(d.getHostname() == "10.0.0.2", "hostname is stored"); + report(!d.isRegistered() && !d.hasPassAccepted() && !d.isOperator(), + "starts unregistered, no PASS, not operator"); + } + + { + std::cout << "[nick is stored casefolded as well]\n"; + Client c; + c.setNick("BoB[\\~"); + report(c.getNick() == "BoB[\\~", "getNick keeps the chosen spelling"); + report(c.getNickCaseFolded() == "bob{|^", + "getNickCaseFolded uses IRC (rfc1459) case mapping"); + } + + { + std::cout << "[invites are per channel and expire with the channel]\n"; + Client c; + c.addInvite("#room", 100); + report(c.isInvitedTo("#room", 100), "invited to the channel"); + report(!c.isInvitedTo("#room", 200), + "a channel re-created later (new creation time) is not covered"); + report(!c.isInvitedTo("#other", 100), "another channel is not covered"); + c.removeInvite("#room"); + report(!c.isInvitedTo("#room", 100), "removal clears the invite"); + } + + { + std::cout << "[registration state starts empty]\n"; + Client c; + report(!c.isRegistered(), "a new client is not registered"); + report(!c.hasPassAccepted(), "a new client has not sent PASS"); + report(!c.hasIdentity(), "a new client has neither NICK nor USER"); + } + + // NICK and USER may arrive in either order. + { + std::cout << "[NICK then USER]\n"; + Client c; + c.setNick("bob"); + report(!c.hasIdentity(), "NICK alone is not enough"); + c.setUser("b", "Bob Example"); + report(c.hasIdentity(), "NICK and USER together are enough"); + report(c.getUsername() == "b" && c.getRealname() == "Bob Example", + "username and realname are stored"); + } + + { + std::cout << "[USER then NICK]\n"; + Client c; + c.setUser("b", "Bob Example"); + report(!c.hasIdentity(), "USER alone is not enough"); + c.setNick("bob"); + report(c.hasIdentity(), "the other order works the same"); + } + + { + std::cout << "[prefix and reply target]\n"; + Client c; + report(c.getPrefix() == "*", "prefix is * without an identity"); + report(c.getReplyTarget() == "*", "numerics go to * without a nick"); + c.setNick("bob"); + report(c.getPrefix() == "*", "prefix is * with only a nickname"); + report(c.getReplyTarget() == "bob", "numerics go to the nick once set"); + c.setUser("b", "Bob Example"); + report(c.getPrefix() == "bob!b@localhost", + "prefix falls back to localhost without a hostname"); + c.setHostname("127.0.0.1"); + report(c.getPrefix() == "bob!b@127.0.0.1", "prefix is nick!user@host"); + } + + { + std::cout << "[flags]\n"; + Client c; + c.setPassAccepted(true); + c.setRegistered(true); + c.setOperator(true); + report(c.hasPassAccepted() && c.isRegistered() && c.isOperator(), + "setters flip the flags"); + c.setOperator(false); + report(!c.isOperator(), "operator flag can be cleared"); + } + + { + std::cout << "[send without a server is a safe no-op]\n"; + Client c(9, "h", 0); + c.send("PING :x\r\n"); + c.sendClosingMsg("bye"); + report(true, "no server attached: nothing to deliver to, no crash"); + } + + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/checks/client/unit.hpp b/tests/checks/client/unit.hpp new file mode 100644 index 0000000..7f76ae2 --- /dev/null +++ b/tests/checks/client/unit.hpp @@ -0,0 +1,154 @@ +#pragma once +/* + * Unit-test harness for the command layer, without sockets. + * + * Since the POLLOUT rework every reply goes Client::send -> Server::sendMsg -> + * Socket::sendMsg (per-fd write buffer) and Server::sendMsg logs each message + * as "Sendto FD : " on std::cout. The Socket buffer is private to the + * Server and the fds here are fake, so the tests read the replies back from + * that log: std::cout is redirected around every command, then the captured + * "Sendto FD n" lines are handed back as "\r\n", exactly what the loop + * would have written to the socket. + * + * Server server(6667, "secret"); + * Conn c(server, 4, "127.0.0.1"); // unregistered connection + * c.send("NICK bob"); // runs the command like the loop + * std::string out = c.drain(); // replies queued since last drain + * + * Conn r(server, "alice"); // already registered alice!a@h + */ + +#include "Client.hpp" +#include "CommandProcessor.hpp" +#include "Server.hpp" + +#include +#include +#include +#include +#include +#include + +static int failures = 0; + +static void report(bool ok, const std::string &what) { + std::cout << (ok ? " ok " : " FAIL ") << what << "\n"; + if (!ok) + failures++; +} + +static bool contains(const std::string &hay, const std::string &needle) { + return hay.find(needle) != std::string::npos; +} + +static int finish(const char *name) { + (void)name; + return failures ? EXIT_FAILURE : EXIT_SUCCESS; +} + +// Everything printed on std::cout while this object lives. +struct CoutCapture { + std::ostringstream buf; + std::streambuf *old; + CoutCapture() : old(std::cout.rdbuf(buf.rdbuf())) {} + ~CoutCapture() { std::cout.rdbuf(old); } +}; + +static std::string stripAnsi(const std::string &s) { + std::string out; + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] == '\033') { + while (i < s.size() && s[i] != 'm') + ++i; + continue; + } + out += s[i]; + } + return out; +} + +// "Sendto FD : " log entries for one fd -> "\r\n" joined. +static std::string sentTo(const std::string &log, int fd) { + std::ostringstream key; + key << "Sendto FD " << fd << ": "; + std::string clean = stripAnsi(log), out; + std::istringstream in(clean); + std::string line; + while (std::getline(in, line)) { + if (line.compare(0, key.str().size(), key.str()) == 0) + out += line.substr(key.str().size()) + "\r\n"; + } + return out; +} + +// One fake connection: a Client owned by the server's ClientManager (like +// after accept()), fed through this file's own CommandProcessor. +struct Conn; +static std::vector liveConns; // every reply is routed to its Conn + +struct Conn { + Server &server; + CommandProcessor cmds; + Client *client; + int fd; + bool closed; + std::string pending; // replies queued since the last drain() + + static int nextFd() { + static int n = 4; + return n++; + } + + // unregistered connection from `host` + Conn(Server &s, int fd_, const std::string &host) + : server(s), client(0), fd(fd_), closed(false) { + cmds.registerCommands(); + s.getClientManager().add(fd, host, s); + client = &s.getClientManager().at(fd); + liveConns.push_back(this); + } + + // registered as nick!@h, like after PASS/NICK/USER + Conn(Server &s, const std::string &nick) + : server(s), client(0), fd(nextFd()), closed(false) { + cmds.registerCommands(); + s.getClientManager().add(fd, "h", s); + client = &s.getClientManager().at(fd); + s.getClientManager().setNick(*client, nick); + client->setUser(nick.substr(0, 1), nick); + client->setPassAccepted(true); + client->setRegistered(true); + liveConns.push_back(this); + } + + ~Conn() { + liveConns.erase(std::remove(liveConns.begin(), liveConns.end(), this), + liveConns.end()); + } + + // Runs one line like the event loop; returns true when the handler asks + // the loop to close the connection (QUIT, wrong password, ...). + bool send(const std::string &line) { + std::string log; + bool close; + { + CoutCapture cap; + close = cmds.run(server, *client, line); + log = cap.buf.str(); + } + closed = closed || close; + for (size_t i = 0; i < liveConns.size(); ++i) + if (&liveConns[i]->server == &server) + liveConns[i]->collect(log); + return close; + } + + void collect(const std::string &log) { pending += sentTo(log, fd); } + + std::string drain() { + std::string s = pending; + pending.clear(); + return s; + } +}; + diff --git a/tests/churn_test.py b/tests/checks/networking/churn.py similarity index 84% rename from tests/churn_test.py rename to tests/checks/networking/churn.py index 7907896..bf8f4ae 100644 --- a/tests/churn_test.py +++ b/tests/checks/networking/churn.py @@ -52,7 +52,11 @@ def drain(s): for s in clients: drain(s) -print("members in #big:", N, "(one member, usr%02d, stops reading; rest live)" % STALL_IDX) +def ok(cond, text): + print(" %s %s" % ("ok " if cond else "FAIL", text)) + + +print("[%d members in #big: broadcast storm, JOIN/PART churn, three RST disconnects, one member stops reading]" % N) # broadcast storm: 3 senders x 20 msgs -> each message goes to all members, # the stalled member's socket buffer fills and server send() starts failing @@ -84,7 +88,7 @@ def drain(s): drain(s) s.sendall(b"PING :final\r\n") s.settimeout(2.5) - ok = False + got = False got = b"" try: while True: @@ -93,11 +97,11 @@ def drain(s): break got += c if b"PONG" in got: - ok = True + got = True break except (socket.timeout, OSError): pass - if not ok: + if not got: lost.append(i) alive = server_alive() @@ -108,7 +112,6 @@ def drain(s): except Exception: pass -print(" server alive :", "YES" if alive else "NO (DIED)") -print(" healthy members that lost their PING/PONG:", len(lost), (lost or "none")) -print(" RESULT:", - "PASS" if (alive and not lost) else "FAIL - inspect state above") +ok(alive, "server alive") +ok(not lost, "every healthy member still gets PONG (%d lost%s)" + % (len(lost), (": " + ", ".join("usr%02d" % i for i in lost)) if lost else "")) diff --git a/tests/checks/networking/closing.py b/tests/checks/networking/closing.py new file mode 100644 index 0000000..a30f690 --- /dev/null +++ b/tests/checks/networking/closing.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# What a client receives right before the server closes its link: +# 464 on a wrong password, ERROR after QUIT, ERROR after the third over-long line. +# python3 closing.py [port] [password] needs a running ./ircserv +import socket +import sys +import time + +HOST = "127.0.0.1" +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 6667 +PW = sys.argv[2] if len(sys.argv) > 2 else "pass" + + +def ok(cond, text): + print(" %s %s" % ("ok " if cond else "FAIL", text)) + + +def talk(lines, wait=2.0): + """send the lines, read until EOF or timeout -> (data, eof)""" + s = socket.create_connection((HOST, PORT)) + for l in lines: + s.sendall(l.encode()) + time.sleep(0.2) + s.settimeout(wait) + data = b"" + eof = False + try: + while True: + c = s.recv(4096) + if not c: + eof = True + break + data += c + except socket.timeout: + pass + s.close() + return data, eof + + +print("[wrong password]") +data, eof = talk(["PASS wrong\r\n", "NICK w\r\n", "USER w 0 * :w\r\n"]) +ok(b" 464 " in data, "464 ERR_PASSWDMISMATCH received" + ("" if b" 464 " in data else " (got %r)" % data[-80:])) +ok(eof, "then the link is closed") + +print("[QUIT]") +data, eof = talk(["PASS %s\r\n" % PW, "NICK q\r\n", "USER q 0 * :q\r\n", "QUIT :bye\r\n"]) +ok(b"ERROR :" in data, "ERROR :Closing link received" + ("" if b"ERROR :" in data else " (last bytes %r)" % data[-80:])) +ok(eof, "then the link is closed") + +print("[three over-long lines in a row]") +data, eof = talk(["PASS %s\r\n" % PW, "NICK o\r\n", "USER o 0 * :o\r\n"] + ["X" * 600 + "\r\n"] * 3) +ok(data.count(b" 417 ") >= 1, "417 received (%d times)" % data.count(b" 417 ")) +ok(eof, "the link is closed after the third one") +print(" ERROR :Closing link before the close: %s" % ("yes" if b"ERROR :" in data else "no")) diff --git a/tests/checks/networking/e2e.hpp b/tests/checks/networking/e2e.hpp new file mode 100644 index 0000000..12e4bbe --- /dev/null +++ b/tests/checks/networking/e2e.hpp @@ -0,0 +1,166 @@ +#pragma once +/* + * End-to-end harness: starts ./ircserv on a free port and talks to it over + * TCP like nc would. + * + * IrcServer srv(rootDir, "secret"); // ./ircserv secret + * Conn c(srv.port()); // one client + * c.send("NICK bob"); // "\r\n" added + * std::string got = c.read(); // "" if nothing came + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int failures = 0; + +static void report(bool ok, const std::string &what) { + std::cout << (ok ? " ok " : " FAIL ") << what << "\n"; + if (!ok) + failures++; +} + +static bool contains(const std::string &hay, const std::string &needle) { + return hay.find(needle) != std::string::npos; +} + +static int finish(const char *name) { + (void)name; + return failures ? EXIT_FAILURE : EXIT_SUCCESS; +} + +// a free TCP port: bind to 0 and read back what the kernel picked +static int freePort() { + int s = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in a; + std::memset(&a, 0, sizeof(a)); + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(a); + if (bind(s, (struct sockaddr *)&a, len) != 0 || + getsockname(s, (struct sockaddr *)&a, &len) != 0) { + close(s); + return 6667; + } + int port = ntohs(a.sin_port); + close(s); + return port; +} + +struct IrcServer { + pid_t pid; + int port_; + + IrcServer(const std::string &rootDir, const std::string &password) + : pid(-1), port_(freePort()) { + std::ostringstream p; + p << port_; + std::string bin = rootDir + "/ircserv"; + pid = fork(); + if (pid == 0) { + // server output only with IRC_TEST_VERBOSE=1 + if (!std::getenv("IRC_TEST_VERBOSE")) { + int devnull = open("/dev/null", O_WRONLY); + dup2(devnull, 1); + dup2(devnull, 2); + } + execl(bin.c_str(), bin.c_str(), p.str().c_str(), password.c_str(), + (char *)0); + std::perror("execl ./ircserv"); + _exit(127); + } + // wait until it accepts connections (up to 3 s) + for (int i = 0; i < 60; i++) { + int s = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in a; + std::memset(&a, 0, sizeof(a)); + a.sin_family = AF_INET; + a.sin_port = htons(port_); + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + bool up = connect(s, (struct sockaddr *)&a, sizeof(a)) == 0; + close(s); + if (up) + return; + usleep(50000); + } + std::cerr << "server did not come up on port " << port_ << "\n"; + std::exit(EXIT_FAILURE); + } + + ~IrcServer() { + if (pid > 0) { + kill(pid, SIGTERM); + int status; + waitpid(pid, &status, 0); + } + } + + int port() const { + return port_; + } +}; + +struct Conn { + int fd; + bool eof; // the server closed the connection + + explicit Conn(int port) : fd(socket(AF_INET, SOCK_STREAM, 0)), eof(false) { + struct sockaddr_in a; + std::memset(&a, 0, sizeof(a)); + a.sin_family = AF_INET; + a.sin_port = htons(port); + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(fd, (struct sockaddr *)&a, sizeof(a)) != 0) { + std::perror("connect"); + std::exit(EXIT_FAILURE); + } + } + + ~Conn() { + if (fd >= 0) + close(fd); + } + + void send(const std::string &line) { + std::string s = line + "\r\n"; + ::send(fd, s.data(), s.size(), MSG_NOSIGNAL); + } + + // everything received until the server is quiet for quietMs + std::string read(int quietMs = 150) { + std::string out; + char buf[4096]; + struct pollfd pfd = {fd, POLLIN, 0}; + while (poll(&pfd, 1, quietMs) > 0 && + (pfd.revents & (POLLIN | POLLHUP))) { + ssize_t n = recv(fd, buf, sizeof(buf), 0); + if (n <= 0) { + eof = true; + break; + } + out.append(buf, n); + } + return out; + } + + // PASS/NICK/USER, welcome burst discarded + void login(const std::string &nick, const std::string &pass = "secret") { + send("PASS " + pass); + send("NICK " + nick); + send("USER " + nick.substr(0, 1) + " 0 * :" + nick); + read(); + } +}; diff --git a/tests/checks/networking/flood.cpp b/tests/checks/networking/flood.cpp new file mode 100644 index 0000000..2f0b322 --- /dev/null +++ b/tests/checks/networking/flood.cpp @@ -0,0 +1,189 @@ +#include "e2e.hpp" + +#include +#include + +/* + * A channel member stops reading while the channel is flooded (the "^Z a + * client, flood the channel" check of the evaluation sheet). + * + * Policy under test (POLLOUT design, Socket::flushWriteBuffer): the server + * keeps serving everyone else, never drops the stalled client for being slow, + * queues its bytes in the per-client write buffer, and delivers every queued + * line, in order, once the client reads again. + * + * With a send-queue cap (SENDQMAX in Socket.hpp) the policy changes: once the + * queue passes the cap the client is dropped. Run with IRC_SENDQ_CAP= + * to test that variant: the flood is cap + 8 MB (the kernel absorbs a few MB + * before the server's own queue grows) and the stalled client must be gone. + * + * Usage: [IRC_SENDQ_CAP=1048576] flood + */ + +static double nowMs() { + struct timeval tv; + gettimeofday(&tv, 0); + return tv.tv_sec * 1000.0 + tv.tv_usec / 1000.0; +} + +static std::string kb(size_t bytes) { + std::ostringstream o; + o << bytes / 1024 << " KB"; + return o.str(); +} + +// a client with a tiny receive buffer: "not reading" fills the pipe in KB +static int smallSocket(int port) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + int n = 4096; + setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &n, sizeof(n)); + struct sockaddr_in a; + std::memset(&a, 0, sizeof(a)); + a.sin_family = AF_INET; + a.sin_port = htons(port); + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(fd, (struct sockaddr *)&a, sizeof(a)) != 0) { + std::perror("connect"); + std::exit(EXIT_FAILURE); + } + return fd; +} + +// resident set size of a process in MB, via ps (macOS and Linux) +static long rssMb(pid_t pid) { + std::ostringstream cmd; + cmd << "ps -o rss= -p " << pid; + FILE *f = popen(cmd.str().c_str(), "r"); + if (!f) + return -1; + long kbv = -1; + if (fscanf(f, "%ld", &kbv) != 1) + kbv = -1; + pclose(f); + return kbv < 0 ? -1 : kbv / 1024; +} + +int main(int argc, char **argv) { + if (argc < 2) { + std::cerr << "usage: " << argv[0] << " \n"; + return EXIT_FAILURE; + } + IrcServer srv(argv[1], "secret"); + + Conn alice(srv.port()); + Conn carol(srv.port()); + Conn bob(srv.port()); + close(bob.fd); + bob.fd = smallSocket(srv.port()); + alice.login("alice"); + bob.login("bob"); + carol.login("carol"); + alice.send("JOIN #x"); + bob.send("JOIN #x"); + alice.read(); + bob.read(); + long rss0 = rssMb(srv.pid); + // from here on bob never reads + + const std::string text(480, 'y'); + const std::string line = "PRIVMSG #x :" + text; + const std::string relayed = + ":alice!a@127.0.0.1 PRIVMSG #x :" + text + "\r\n"; + long cap = std::getenv("IRC_SENDQ_CAP") ? std::atol(std::getenv("IRC_SENDQ_CAP")) : 0; + // keep policy: ~1.9 MB toward a 4 KB receive buffer; cap policy: cap + 8 MB + const int total = cap > 0 ? static_cast((cap + 8L * 1024 * 1024) / (line.size() + 2)) : 4000; + double worstPing = 0; + int sent = 0; + bool bobQuit = false, pongMissing = false; + double t0 = nowMs(); + while (sent < total && nowMs() - t0 < 30000) { + for (int i = 0; i < 50; ++i) + alice.send(line); + sent += 50; + // the server must still answer carol while bob's queue grows + double t1 = nowMs(); + carol.send("PING :x"); + struct pollfd cp = {carol.fd, POLLIN, 0}; + int ready = poll(&cp, 1, 2000); // time until the answer + double dt = nowMs() - t1; + if (dt > worstPing) + worstPing = dt; + std::string pong = ready > 0 ? carol.read(20) : ""; + if (!contains(pong, "PONG")) { + pongMissing = true; + break; + } + if (contains(alice.read(5), "QUIT")) + bobQuit = true; + } + std::ostringstream what; + what << "server kept answering carol during " << sent << " lines (" + << kb(sent * (line.size() + 2)) << ") to a stalled bob: worst PING " + << worstPing << " ms"; + report(!pongMissing && worstPing < 500, what.str()); + if (cap > 0) { + std::ostringstream wc; + wc << "cap policy (" << cap / 1024 << " KB): bob was dropped, alice saw his QUIT"; + report(bobQuit, wc.str()); + carol.send("PRIVMSG bob :are you there"); + report(contains(carol.read(), " 401 "), "bob is gone for everyone (401)"); + std::string got = bob.read(300); + report(bob.eof, "bob reads what was delivered before the drop, then EOF"); + Conn bob3(srv.port()); + bob3.send("PASS secret"); + bob3.send("NICK bob"); + report(!contains(bob3.read(), " 433 "), "the nick is free again"); + return finish("flood"); + } + report(!bobQuit, + "bob was NOT dropped for not reading (no QUIT seen by alice)"); + carol.send("PRIVMSG bob :are you there"); + report(!contains(carol.read(), " 401 "), + "bob is still known to the server (no 401 for PRIVMSG bob)"); + long rss1 = rssMb(srv.pid); + std::ostringstream w1; + w1 << "server RSS " << rss0 << " MB before, " << rss1 << " MB with " + << kb(sent * relayed.size()) + << " outstanding for bob (kernel socket buffer first, then the server queue; no cap by design)"; + report(rss1 >= rss0, w1.str()); + + // bob reads again: every queued line must arrive, in order, plus carol's + // private message, and the link stays open + std::string got; + double t2 = nowMs(); + while (nowMs() - t2 < 20000) { + std::string chunk = bob.read(300); + got += chunk; + if (chunk.empty() || bob.eof) + break; + } + size_t lines = 0, bad = 0; + for (size_t p = 0; (p = got.find(relayed, p)) != std::string::npos; + p += relayed.size()) + ++lines; + for (size_t p = 0; (p = got.find("\r\n", p)) != std::string::npos; p += 2) + if (p + 2 < got.size() && got[p + 2] != ':') + ++bad; + std::ostringstream w2; + w2 << "once bob reads again he gets all " << sent << " lines (" << lines + << " found, " << kb(got.size()) << ") in order, nothing lost"; + report(lines == static_cast(sent) && bad == 0, w2.str()); + report(contains(got, ":carol!c@127.0.0.1 PRIVMSG bob :are you there"), + "the private message sent during the stall is delivered too"); + report(!bob.eof, "bob's connection is still open"); + bob.send("PING :back"); + report(contains(bob.read(), "PONG"), "bob is fully usable again"); + long rss2 = rssMb(srv.pid); + std::ostringstream w3; + w3 << "server RSS after the drain: " << rss2 + << " MB (buffer freed; RSS may not shrink, that is the allocator)"; + report(true, w3.str()); + + // the nick is still taken: a second bob must get 433 + Conn bob2(srv.port()); + bob2.send("PASS secret"); + bob2.send("NICK bob"); + report(contains(bob2.read(), " 433 "), "the nick is still in use (433)"); + + return finish("flood"); +} diff --git a/tests/longline_test.py b/tests/checks/networking/longline.py similarity index 70% rename from tests/longline_test.py rename to tests/checks/networking/longline.py index b00a605..4136025 100644 --- a/tests/longline_test.py +++ b/tests/checks/networking/longline.py @@ -26,11 +26,14 @@ def reg(nick, i): return s -print("opening 3 dumpers and 1 normal client...") +def ok(cond, text): + print(" %s %s" % ("ok " if cond else "FAIL", text)) + + +print("[three clients dump 10 KB without CRLF, a fourth stays normal]") dumpy = [reg("dump%d" % i, i) for i in range(3)] norm = reg("norm", 100) -print("3 dumpers hold a 10k-no-CRLF line (3 chunks each)...") for s in dumpy: s.sendall(b"X" * 4000) time.sleep(0.05) @@ -40,25 +43,22 @@ def reg(nick, i): for s in dumpy: s.sendall(b"Z" * 2000) # 3rd event -> discard limit closes them -print("now the normal client PINGs - must be answered promptly:") t0 = time.time() norm.sendall(b"PING :hi\r\n") norm.settimeout(2) -ok = False +got_pong = False try: while True: c = norm.recv(2048) if not c: break if b"PONG" in c: - ok = True + got_pong = True break except socket.timeout: pass lat = time.time() - t0 -print(" PONG latency: %.3fs %s" % (lat, "OK" if ok else "TIMED OUT")) - -print("do the dumpers get closed after their 3rd chunk?") +ok(got_pong, "the normal client gets its PONG (%.3f s)" % lat) closed = [] for i, s in enumerate(dumpy): s.settimeout(2) @@ -70,10 +70,4 @@ def reg(nick, i): break except socket.timeout: pass -print(" closed dumpers:", len(closed), "(expect 3)", - "PASS" if len(closed) == 3 else "FAIL") - -if ok and len(closed) == 3: - print("RESULT: PASS - server stayed responsive and reaped the abusers") -else: - print("RESULT: FAIL - server was blocked or did not close dumpers") \ No newline at end of file +ok(len(closed) == 3, "the three dumpers are closed after their third chunk (%d of 3)" % len(closed)) \ No newline at end of file diff --git a/tests/overlong_test.py b/tests/checks/networking/overlong.py similarity index 60% rename from tests/overlong_test.py rename to tests/checks/networking/overlong.py index 85db765..615031a 100644 --- a/tests/overlong_test.py +++ b/tests/checks/networking/overlong.py @@ -62,30 +62,22 @@ def drain(s): s.setblocking(True) -print("=== Test A: 600-byte line then NAMES, single packet ===") -print(" expect: 417 YES + 366 YES ") +def ok(cond, text): + print(" %s %s" % ("ok " if cond else "FAIL", text)) + + +print("[a 600-byte line and NAMES in one packet]") s, reg = connect("ovlA", "u1") drain(s) s.sendall(b"A" * 600 + b"\r\nNAMES\r\n") data, eof = read_until_quiet(s, 2.0) got417 = b"417" in data got366 = b"366" in data -print(" 417 (input too long) :", "YES" if got417 else "no") -print(" 366 (End of NAMES) :", "YES" if got366 else "no") -print(" -> PASS (drained at once) " if (got417 and got366) else - " -> FAIL (NAMES stuck until next poll -> apply fix #5) ") -if got417 and not got366: - print(" poke: sending PING to wake the poll loop...") - s.sendall(b"PING :wake\r\n") - data2, eof2 = read_until_quiet(s, 1.0) - print(" 366 after the poke :", - "YES (confirms NAMES was stuck in readBuffer)" if b"366" in data2 - else "no") +ok(got417, "417 for the over-long line") +ok(got366, "366 for the NAMES that followed in the same packet") s.close() -print() -print("=== Test B: overflow with no CRLF ever: 417, then close ===") -print(" expect: 417 YES + closed YES (one 417, then close after the grace limit)") +print("[600 bytes three times, never a CRLF]") s, reg = connect("ovlB", "u2") drain(s) all_data = b"" @@ -103,16 +95,6 @@ def drain(s): any_closed = any_closed or e got417 = b"417" in all_data -print(" 417 seen :", "YES" if got417 else "no") -print(" connection closed :", "YES" if any_closed else "no") -if got417 and any_closed: - print(" -> PASS (one 417, then closed by the discard-limit)") -elif not got417: - print(" -> FAIL (no 417 ever sent)") - print(" close message from server: %r" % all_data[:160]) -else: - print(" -> FAIL (server kept connection open)") +ok(got417, "417 was sent") +ok(any_closed, "connection closed after the third over-long chunk") s.close() -print() -print("note: close message like '[Error while composing command line from buffer.]'") -print("is the generic label of the discard-limit close; the 417 precedes it.") diff --git a/tests/probe_test.py b/tests/checks/networking/probe.py similarity index 83% rename from tests/probe_test.py rename to tests/checks/networking/probe.py index e8d715b..22bfcfa 100644 --- a/tests/probe_test.py +++ b/tests/checks/networking/probe.py @@ -66,19 +66,16 @@ def probe(idx, payload): ("50 noisy PRIVMSGs", (b"PRIVMSG #ghost :" + b"y" * 480 + b"\r\n") * 50), ] -print("case alive closed reply") -print("-" * 70) +def ok(cond, text): + print(" %s %s" % ("ok " if cond else "FAIL", text)) + + +print("[the server must survive every odd input]") all_ok = True for i, (name, payload) in enumerate(CASES): alive, eof, data = probe(i, payload) first = repr(data[:40]) if data else "(nothing)" - if not alive: - all_ok = False - mark = "*** SERVER DIED ***" - else: - mark = "ok" - print("%-26s %-5s %-7s %-45s %s" - % (name, "yes" if alive else "NO", "yes" if eof else "no", - first, mark)) -print("-" * 70) -print("server survived every probe:", "YES" if all_ok else "NO - fix the crash") + all_ok = all_ok and alive + ok(alive, "%-26s %s %s" % (name, "closed by server," if eof else "still open, ", first)) +print("[summary]") +ok(all_ok, "server alive after all %d cases" % len(CASES)) diff --git a/tests/test_session.sh b/tests/checks/networking/session.sh similarity index 100% rename from tests/test_session.sh rename to tests/checks/networking/session.sh diff --git a/tests/checks/networking/socket.cpp b/tests/checks/networking/socket.cpp new file mode 100644 index 0000000..62d99b9 --- /dev/null +++ b/tests/checks/networking/socket.cpp @@ -0,0 +1,313 @@ +#include "Logger.hpp" +#include "Socket.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +/* + * Socket: line buffering, the 510-character limit and the send path, over a + * socketpair. The test writes one end like a client and drives the other + * through updateBuffer_ / getCRLFLineFromBuffer like the poll loop does. + * + * Send path (POLLOUT design): sendMsg() only appends to the per-fd write + * buffer; nothing touches the socket until the loop sees POLLOUT and calls + * flushWriteBuffer(). A client that stops reading is never dropped for that: + * its bytes wait in the buffer (hasPendingWrite) until it reads again. + */ + +static int failures = 0; + +static std::string toStr(size_t n) { + std::ostringstream oss; + oss << n; + return oss.str(); +} + +static void report(bool ok, const std::string &what) { + std::cout << (ok ? " ok " : " FAIL ") << what << "\n"; + if (!ok) + failures++; +} + +// fd is the server side (registered in Socket), peer the client side. +struct Conn { + Logger logger; + Socket sock; + int fd; + int peer; + bool more; + bool tooLong; + + Conn() : logger("test: "), sock(logger), more(false), tooLong(false) { + int sv[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) { + std::cerr << "socketpair failed\n"; + std::exit(EXIT_FAILURE); + } + fd = sv[0]; + peer = sv[1]; + fcntl(fd, F_SETFL, O_NONBLOCK); // like every client fd in Server + sock.addSocket(fd, false); + } + + ~Conn() { + sock.closeSocket(fd); + close(peer); + } + + // write as the client, then let Socket recv() it + bool feed(const std::string &bytes) { + if (!bytes.empty()) + write(peer, bytes.data(), bytes.size()); + struct pollfd pfd = {fd, POLLIN, POLLIN}; + return sock.updateBuffer_(pfd); + } + + std::string line() { + return sock.getCRLFLineFromBuffer(fd, more, tooLong); + } + + // what the server side sent so far, without blocking + std::string readPeer() { + char buf[4096]; + std::string out; + struct pollfd pfd = {peer, POLLIN, 0}; + while (poll(&pfd, 1, 50) > 0 && (pfd.revents & POLLIN)) { + ssize_t n = read(peer, buf, sizeof(buf)); + if (n <= 0) + break; + out.append(buf, n); + } + return out; + } +}; + +// tiny kernel buffers: a few KB behave like a client that stopped reading +static void smallPipe(Conn &c) { + int n = 4096; + setsockopt(c.fd, SOL_SOCKET, SO_SNDBUF, &n, sizeof(n)); + setsockopt(c.peer, SOL_SOCKET, SO_RCVBUF, &n, sizeof(n)); +} + +static void expectLine(Conn &c, const std::string &want) { + std::string got = c.line(); + std::string what = want.size() > 40 + ? "line of " + toStr(want.size()) + " chars" + : "line is \"" + want + "\""; + if (got != want) + what += got.size() > 40 ? ", got " + toStr(got.size()) + " chars" + : ", got \"" + got + "\""; + report(got == want && !c.tooLong, what); +} + +int main() { + + { + std::cout << "[one complete line]\n"; + Conn c; + c.feed("NICK bob\r\n"); + expectLine(c, "NICK bob"); + report(!c.more, "nothing left in the buffer afterwards"); + } + + // TCP may split one command across several recv() calls. + { + std::cout << "[command split across three recv() calls]\n"; + Conn c; + c.feed("NI"); + report(c.line().empty() && !c.more, "nothing yet after \"NI\""); + c.feed("CK bo"); + report(c.line().empty() && !c.more, "still nothing after \"CK bo\""); + c.feed("b\r\n"); + expectLine(c, "NICK bob"); + } + + // One recv() may contain several complete commands. + { + std::cout << "[three commands in one chunk]\n"; + Conn c; + c.feed("PASS x\r\nNICK bob\r\nUSER b 0 * :B\r\n"); + expectLine(c, "PASS x"); + report(c.more, "buffer reports more commands"); + expectLine(c, "NICK bob"); + expectLine(c, "USER b 0 * :B"); + report(!c.more, "buffer is empty afterwards"); + } + + { + std::cout << "[complete line plus a partial one]\n"; + Conn c; + c.feed("NICK bob\r\nUSE"); + expectLine(c, "NICK bob"); + report(c.line().empty() && !c.more, + "\"USE\" is held until the rest arrives"); + c.feed("R b 0 * :B\r\n"); + expectLine(c, "USER b 0 * :B"); + } + + // A bare '\n' (nc without -c, most Linux nc) ends a line as well. + { + std::cout << "[bare newline from nc]\n"; + Conn c; + c.feed("NICK bob\nUSER b 0 * :B\n"); + expectLine(c, "NICK bob"); + expectLine(c, "USER b 0 * :B"); + } + + { + std::cout << "[empty line]\n"; + Conn c; + c.feed("\r\nNICK bob\r\n"); + report(c.line().empty() && c.more, + "empty line comes out empty, next one still pending"); + expectLine(c, "NICK bob"); + } + + // 510 characters of content, whatever the line ending (RFC 2812: 512 + // bytes including "\r\n"). + { + std::cout << "[510-character limit]\n"; + const std::string ok510(510, 'a'); + const std::string long511(511, 'b'); + const std::string long512(512, 'c'); + Conn c; + c.feed(ok510 + "\r\n"); + expectLine(c, ok510); + report(!c.tooLong, "510 chars + CRLF is accepted"); + + c.feed(ok510 + "\n"); + expectLine(c, ok510); + report(!c.tooLong, "510 chars + bare LF is accepted"); + + c.feed(long511 + "\r\n"); + std::string got = c.line(); + report(got.empty() && c.tooLong, "511 chars + CRLF is rejected"); + + c.feed("NICK ok\r\n"); + expectLine(c, "NICK ok"); + c.feed(long511 + "\n"); + got = c.line(); + report(got.empty() && c.tooLong, "511 chars + bare LF is rejected"); + report(!c.sock.socketCloseMarker(c.fd), + "one over-long line does not close the socket"); + + c.feed("NICK bob\r\n"); + expectLine(c, "NICK bob"); + report(true, "a normal line after an over-long one works again"); + + c.feed(long512 + "\r\n"); + c.line(); + c.feed(long512 + "\r\n"); + c.line(); + report(!c.sock.socketCloseMarker(c.fd), + "two more over-long lines: still open (limit is 3 in a row)"); + c.feed(long512 + "\r\n"); + c.line(); + report(c.sock.socketCloseMarker(c.fd), + "third over-long line in a row marks the socket for closing"); + } + + { + std::cout << "[client closes the connection]\n"; + Conn c; + close(c.peer); + c.peer = -1; + report(!c.feed(""), "updateBuffer_ returns false when recv() gives 0"); + report(c.sock.socketCloseMarker(c.fd), "socket is marked for closing"); + c.peer = dup(c.fd); // so the destructor has something to close + } + + { + std::cout << "[sendMsg queues, flushWriteBuffer sends]\n"; + Conn c; + c.sock.sendMsg(c.fd, "PONG irc.local :x\r\n"); + report(c.readPeer().empty(), + "sendMsg alone writes nothing to the socket (waits for POLLOUT)"); + report(c.sock.hasPendingWrite(c.fd), "the message is pending"); + report(c.sock.flushWriteBuffer(c.fd), "flush on POLLOUT sends it all"); + report(c.readPeer() == "PONG irc.local :x\r\n", + "message reaches the client unchanged"); + report(!c.sock.hasPendingWrite(c.fd), "nothing pending afterwards"); + c.sock.sendMsg(c.fd, ":a NOTICE b :1\r\n"); + c.sock.sendMsg(c.fd, ":a NOTICE b :2\r\n"); + c.sock.flushWriteBuffer(c.fd); + report(c.readPeer() == ":a NOTICE b :1\r\n:a NOTICE b :2\r\n", + "two messages arrive in order"); + c.sock.sendMsg(999, "lost\r\n"); + report(!c.sock.hasPendingWrite(999) && c.sock.flushWriteBuffer(999), + "sendMsg/flush on an unknown fd are ignored"); + } + + { + std::cout << "[a client that never reads is kept, its bytes wait]\n"; + Conn c; + smallPipe(c); + const std::string big(4000, 'z'); + size_t queued = 0; + for (int i = 0; i < 100; ++i) { // 400 KB toward a 4 KB pipe + c.sock.sendMsg(c.fd, big + "\r\n"); + queued += big.size() + 2; + } + report(!c.sock.socketCloseMarker(c.fd), + "queueing 400 KB for a stalled client does not mark it for closing"); + bool drained = c.sock.flushWriteBuffer(c.fd); + report(!drained, "flush stops when the kernel buffer is full (send would block)"); + report(!c.sock.socketCloseMarker(c.fd), + "a blocked send is not an error: the client is NOT dropped"); + report(c.sock.hasPendingWrite(c.fd), "the rest stays pending (POLLOUT interest)"); + std::string got = c.readPeer(); + report(!got.empty() && got.size() < queued, + "the client can read what fitted in the kernel buffer (" + + toStr(got.size()) + " of " + toStr(queued) + " bytes)"); + report(got.compare(0, big.size(), big) == 0, + "the part that was delivered is intact"); + } + + { + std::cout << "[the backlog drains as the client reads (one flush per POLLOUT)]\n"; + Conn c; + smallPipe(c); + const std::string big(65536, 'q'); // far more than the pipe holds + c.sock.sendMsg(c.fd, big + "\r\n"); + c.sock.sendMsg(c.fd, "PING :x\r\n"); + c.sock.flushWriteBuffer(c.fd); + std::string got = c.readPeer(); + report(!got.empty() && got.size() < big.size() + 2, + "only part of a 64 KB message reaches the client at once"); + report(c.readPeer().empty(), + "the rest does not arrive without another flush"); + int flushes = 0; + while (c.sock.hasPendingWrite(c.fd) && flushes < 1000) { + c.sock.flushWriteBuffer(c.fd); // the loop does this on POLLOUT + ++flushes; + got += c.readPeer(); // the client reads + } + report(!c.sock.hasPendingWrite(c.fd), "everything drained after " + + toStr(flushes) + " POLLOUT flushes"); + report(got == big + "\r\nPING :x\r\n", + "bytes arrive in order, nothing lost or duplicated"); + } + + { + std::cout << "[a client marked for closing gets nothing more]\n"; + Conn c; + c.sock.sendMsg(c.fd, "one\r\n"); + c.sock.flushWriteBuffer(c.fd); + report(c.readPeer() == "one\r\n", "before: messages flow"); + // the loop marks fds via Socket only; simulate the marker the way + // sendMsg does when the buffer can no longer grow + report(c.sock.getSockIOData(c.fd) != 0, "per-fd data exists"); + c.sock.getSockIOData(c.fd)->closeFd = true; + c.sock.sendMsg(c.fd, "two\r\n"); + report(!c.sock.hasPendingWrite(c.fd) && c.sock.socketCloseMarker(c.fd), + "sendMsg after the marker queues nothing; marker stays set"); + } + + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/checks/networking/syscalls.c b/tests/checks/networking/syscalls.c new file mode 100644 index 0000000..7e77719 --- /dev/null +++ b/tests/checks/networking/syscalls.c @@ -0,0 +1,53 @@ +/* Preloaded into the server under test: logs every close() and every failed + * accept()/send()/recv() with its errno, so a second close of the same fd + * (EBADF) or an unexpected -1 is visible. Linux: LD_PRELOAD; macOS: dyld interpose. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +static void note(const char *name, int fd, long r, int e) { + if (r < 0) + fprintf(stderr, "[sys] %s(%d) -> -1 %s\n", name, fd, strerror(e)); + else if (name[0] == 'c') + fprintf(stderr, "[sys] %s(%d) -> %ld\n", name, fd, r); +} + +#ifdef __APPLE__ +#define INTERPOSE(mine, orig) \ + __attribute__((used)) static struct { const void *m; const void *o; } \ + _ip_##orig __attribute__((section("__DATA,__interpose"))) = \ + {(const void *)(unsigned long)&mine, (const void *)(unsigned long)&orig}; +int my_close(int fd) { int r = close(fd); int e = errno; note("close", fd, r, e); errno = e; return r; } +int my_accept(int fd, struct sockaddr *a, socklen_t *l) { int r = accept(fd, a, l); int e = errno; note("accept", fd, r, e); errno = e; return r; } +ssize_t my_send(int fd, const void *b, size_t n, int f) { ssize_t r = send(fd, b, n, f); int e = errno; note("send", fd, r, e); errno = e; return r; } +ssize_t my_recv(int fd, void *b, size_t n, int f) { ssize_t r = recv(fd, b, n, f); int e = errno; note("recv", fd, r, e); errno = e; return r; } +INTERPOSE(my_close, close) +INTERPOSE(my_accept, accept) +INTERPOSE(my_send, send) +INTERPOSE(my_recv, recv) +#else +int close(int fd) { + static int (*real)(int) = 0; + if (!real) real = dlsym(RTLD_NEXT, "close"); + int r = real(fd); int e = errno; note("close", fd, r, e); errno = e; return r; +} +int accept(int fd, struct sockaddr *a, socklen_t *l) { + static int (*real)(int, struct sockaddr *, socklen_t *) = 0; + if (!real) real = dlsym(RTLD_NEXT, "accept"); + int r = real(fd, a, l); int e = errno; note("accept", fd, r, e); errno = e; return r; +} +ssize_t send(int fd, const void *b, size_t n, int f) { + static ssize_t (*real)(int, const void *, size_t, int) = 0; + if (!real) real = dlsym(RTLD_NEXT, "send"); + ssize_t r = real(fd, b, n, f); int e = errno; note("send", fd, r, e); errno = e; return r; +} +ssize_t recv(int fd, void *b, size_t n, int f) { + static ssize_t (*real)(int, void *, size_t, int) = 0; + if (!real) real = dlsym(RTLD_NEXT, "recv"); + ssize_t r = real(fd, b, n, f); int e = errno; note("recv", fd, r, e); errno = e; return r; +} +#endif diff --git a/tests/checks/networking/syscalls.sh b/tests/checks/networking/syscalls.sh new file mode 100644 index 0000000..1670170 --- /dev/null +++ b/tests/checks/networking/syscalls.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# Runs the server with close/accept/send/recv traced (syscalls.c preloaded) and +# reads the return codes afterwards: +# FAIL close() returned -1 (the same fd closed twice) or accept() failed +# ok every close() succeeded once +# Other failed calls (send/recv) are listed for information; EAGAIN is normal. +# +# ircserv: wrong password, QUIT, three over-long lines, an RST-closed +# client, a kicked client, SIGINT with clients connected +# ircserv_bonus: the same, plus a full transfer, an offer that is rejected and +# a new one after it, and SIGINT in the middle of a transfer +# +# bash tests/checks/networking/syscalls.sh [repo root] [password] +# Starts its own servers on free ports (the preload has to reach them). + +set -u +ROOT=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)} +PASS=${2:-pass} +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB="$HERE/.syscalls.so" +BIG="$ROOT/files/.syscalls_big.bin" +failed=0 +ok() { echo " ok $1"; } +bad() { echo " FAIL $1"; failed=1; } + +echo "[trace library]" +if [ "$(uname)" = Darwin ]; then + cc -shared -o "$LIB" "$HERE/syscalls.c" 2>/dev/null && PRE="DYLD_INSERT_LIBRARIES=$LIB" +else + cc -shared -fPIC -o "$LIB" "$HERE/syscalls.c" -ldl 2>/dev/null && PRE="LD_PRELOAD=$LIB" +fi +[ -f "$LIB" ] && ok "built ($(basename "$LIB"))" || { bad "could not build syscalls.c (needs cc)"; exit 1; } +cleanup() { [ -n "${SRV:-}" ] && kill $SRV 2>/dev/null; rm -f "${TRACE:-}" "$LIB" "$BIG"; } +trap cleanup EXIT + +trace_run() { # $1 = ircserv | ircserv_bonus + local bin=$1 bonus=0 + [ "$bin" = ircserv_bonus ] && bonus=1 + [ -x "$ROOT/$bin" ] || { echo " no $bin at $ROOT, skipped"; return; } + echo "[$bin: every close path, traced]" + TRACE=$(mktemp -t syscalls.XXXXXX) + PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])') + [ $bonus = 1 ] && head -c 20000000 /dev/urandom > "$BIG" + ( cd "$ROOT" && exec env $PRE "./$bin" "$PORT" "$PASS" 2>"$TRACE" >/dev/null ) & + SRV=$! + for i in $(seq 1 40); do + python3 -c "import socket; socket.create_connection(('127.0.0.1', $PORT), timeout=0.5).close()" 2>/dev/null && break + sleep 0.1 + done + python3 - "$PORT" "$PASS" "$SRV" "$bonus" <<'PYEOF' +import socket, time, re, os, signal, sys, struct +PORT, PW, SRV, BONUS = int(sys.argv[1]), sys.argv[2], int(sys.argv[3]), sys.argv[4] == "1" +def reg(n): + s = socket.create_connection(("127.0.0.1", PORT)); s.settimeout(1) + s.sendall(("PASS %s\r\nNICK %s\r\nUSER %s 0 * :x\r\n" % (PW, n, n)).encode()); time.sleep(0.3) + try: s.recv(4096) + except socket.timeout: pass + return s +def offer(a, b, fname): + a.sendall(("PRIVMSG DCCBot :!send bob %s\r\n" % fname).encode()); time.sleep(0.5) + d = b.recv(4096).decode(errors="replace") + m = re.search(r"DCC SEND \S+ (\S+) (\d+) (\d+)", d) + return (int(m.group(2)), int(m.group(3))) if m else (None, None) +def pull(port): + ds = socket.create_connection(("127.0.0.1", port)); ds.settimeout(3); got = 0 + try: + while True: + c = ds.recv(65536) + if not c: break + got += len(c) + except socket.timeout: pass + ds.close(); return got +a = reg("alice"); b = reg("bob"); a.sendall(b"JOIN #x\r\n"); b.sendall(b"JOIN #x\r\n"); time.sleep(0.3) +w = socket.create_connection(("127.0.0.1", PORT)); w.sendall(b"PASS nope\r\nNICK w\r\nUSER w 0 * :w\r\n"); time.sleep(0.4); w.close() +q = reg("quitter"); q.sendall(b"QUIT :bye\r\n"); time.sleep(0.4); q.close() +o = reg("longy") +for _ in range(3): o.sendall(b"X" * 600 + b"\r\n"); time.sleep(0.2) +time.sleep(0.4); o.close() +k = reg("killed"); k.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)); k.close(); time.sleep(0.4) +a.sendall(b"KICK #x bob :out\r\n"); time.sleep(0.3) +print(" ok wrong password, QUIT, over-long close, RST close, KICK") +if BONUS: + b.sendall(b"JOIN #x\r\n"); time.sleep(0.2) + port, size = offer(a, b, "test.txt"); b.sendall(b"PRIVMSG DCCBot :!accept\r\n"); time.sleep(0.3) + got = pull(port); time.sleep(0.4) + print((" ok full transfer: %d of %d bytes" if got == size else " FAIL full transfer: %d of %d bytes") % (got, size)) + offer(a, b, "test.txt"); b.sendall(b"PRIVMSG DCCBot :!reject\r\n"); time.sleep(0.5) + port, size = offer(a, b, "test.txt"); b.sendall(b"PRIVMSG DCCBot :!accept\r\n"); time.sleep(0.4) + pull(port); time.sleep(0.4) + print(" ok offer rejected, new offer accepted and transferred") + port, size = offer(a, b, ".syscalls_big.bin"); b.sendall(b"PRIVMSG DCCBot :!accept\r\n"); time.sleep(0.4) + ds = socket.create_connection(("127.0.0.1", port)); time.sleep(0.6) # connected, never reads + os.kill(SRV, signal.SIGINT); time.sleep(1.0) + print(" ok SIGINT with a transfer in progress") +else: + os.kill(SRV, signal.SIGINT); time.sleep(0.8) + print(" ok SIGINT with clients connected") +PYEOF + kill -INT $SRV 2>/dev/null; sleep 0.5; kill $SRV 2>/dev/null; wait $SRV 2>/dev/null; SRV="" + local closes dbl acc + closes=$(grep -c "^\[sys\] close(" "$TRACE") + dbl=$(grep "^\[sys\] close(" "$TRACE" | grep -c -- "-> -1") + acc=$(grep "^\[sys\] accept(" "$TRACE" | grep -- "-> -1" | grep -vc "again\|Resource temporarily") + if [ "$dbl" -eq 0 ]; then ok "every close() succeeded once ($closes calls)"; else + bad "close() returned -1 $dbl time(s): the same fd closed twice" + grep "^\[sys\] close(" "$TRACE" | grep -B1 -- "-> -1" | sed 's/^/ /' + fi + [ "$acc" -eq 0 ] && ok "no failed accept()" || bad "accept() failed $acc time(s)" + grep "^\[sys\] \(send\|recv\)(" "$TRACE" | grep -- "-> -1" | sed 's/^\[sys\] //' | sort | uniq -c | sed 's/^ *\([0-9]*\) \(.*\)$/ \2 x\1 (info)/' + rm -f "$TRACE" "$BIG" +} +trace_run ircserv +trace_run ircserv_bonus +exit $failed diff --git a/tests/throw_test.py b/tests/checks/networking/throw.py similarity index 68% rename from tests/throw_test.py rename to tests/checks/networking/throw.py index db5f4fc..4c49680 100644 --- a/tests/throw_test.py +++ b/tests/checks/networking/throw.py @@ -34,18 +34,20 @@ def server_alive(): return False -print("== Trigger 1: MODE on a channel that does not exist ==") -print(" server up:", "YES" if server_alive() else "NO") +def ok(cond, text): + print(" %s %s" % ("ok " if cond else "FAIL", text)) + + +print("[MODE on a channel that does not exist]") +ok(server_alive(), "server up before") a = reg("t1", "u1") a.sendall(b"MODE #doesnotexist +o x\r\n") time.sleep(0.8) -print(" server still up:", - "YES (no crash; #doesnotexist may be handled without throwing)" - if server_alive() else "NO -> CRASH reproduced (server died)") +ok(server_alive(), "server still up after MODE #doesnotexist +o x") a.close() -print("== Trigger 2: non-op issues +o to an op (two connections) ==") -print(" server up:", "YES" if server_alive() else "NO") +print("[a member who is not operator sends MODE #c +o]") +ok(server_alive(), "server up before") op = reg("op", "u2") op.sendall(b"JOIN #c\r\n") time.sleep(0.2) @@ -54,7 +56,6 @@ def server_alive(): time.sleep(0.2) mem.sendall(b"MODE #c +o op\r\n") # member, not op -> Channel.cpp throws 482 time.sleep(0.8) -print(" server still up:", - "YES (crash fixed)" if server_alive() else "NO -> CRASH reproduced (server died)") +ok(server_alive(), "server still up after the 482 path") op.close() mem.close() diff --git a/tests/checks/run.sh b/tests/checks/run.sh new file mode 100755 index 0000000..8a9bed8 --- /dev/null +++ b/tests/checks/run.sh @@ -0,0 +1,155 @@ +#!/bin/bash +# Runs the checks by name or by group. macOS and Linux. +# +# bash tests/checks/run.sh usage and the list of names +# bash tests/checks/run.sh basic | networking | client | bonus | all +# bash tests/checks/run.sh ... one or more tests by name +# +# C++ tests are compiled with the project flags against the server sources, +# run, and removed again. Scripts (.py / .sh) get a fresh server on a free +# port. Keeps going after a failure. Exit status 1 if anything failed. + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +PASSWORD=pass +CXX=(c++ -Wall -Wextra -Werror -pedantic -std=c++98 "-I$ROOT/includes") +SRV=("$ROOT"/srcs/commands/*.cpp "$ROOT"/srcs/server/*.cpp "$ROOT"/srcs/utils/*.cpp) + +if [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then + B=$'\e[1m'; D=$'\e[2m'; R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; C=$'\e[36m'; M=$'\e[35m'; N=$'\e[0m' +else + B=; D=; R=; G=; Y=; C=; M=; N= +fi +[ -t 1 ] && stty sane 2>/dev/null + +# name -> file | link/server | description +spec() { + case "$1" in + basic) echo "basic/checks.sh||Makefile rules and flags, no relink, allowed functions, one poll(), fcntl form" ;; + socket) echo "networking/socket.cpp|SOCKET|line framing, 510-byte limit, POLLOUT send path (Socket class)" ;; + flood) echo "networking/flood.cpp|E2E|a channel member stops reading while the channel is flooded" ;; + probe) echo "networking/probe.py|ircserv|odd input: NUL, binary, CR CR LF, bare LF, 511 and 4096-byte lines" ;; + throw) echo "networking/throw.py|ircserv|MODE on a missing channel and +o by a non-operator do not kill the server" ;; + overlong) echo "networking/overlong.py|ircserv|417 on over-length lines, close after the third one in a row" ;; + longline) echo "networking/longline.py|ircserv|10 KB without CRLF from three clients while a fourth stays responsive" ;; + churn) echo "networking/churn.py|ircserv|20 members: broadcast storm, JOIN/PART churn, RST disconnects, a stalled member" ;; + closing) echo "networking/closing.py|ircserv|what the client gets before the server closes its link: 464, ERROR" ;; + syscalls) echo "networking/syscalls.sh|SELF|close/accept/send/recv return codes traced through every close path, both binaries (a fd closed twice, a failed accept)" ;; + parser) echo "client/parser.cpp||message grammar: prefix, command, params, trailing" ;; + reply) echo "client/reply.cpp||numeric reply formatting" ;; + state) echo "client/state.cpp|SRV|client state: nick casefolding, invites, identity, prefix, flags" ;; + registration) echo "client/registration.cpp|SRV|PASS/NICK/USER/CAP in every order, 431 432 433 461 462 464" ;; + messaging) echo "client/messaging.cpp|SRV|PING/PONG, QUIT, NOTICE, PRIVMSG and their numerics" ;; + operator) echo "client/operator.cpp|SRV|482/442 for non-operators; KICK INVITE TOPIC MODE i t k o l" ;; + bot) echo "bonus/bot.sh|ircserv_bonus|bot dialogue: !help !version !whoami !send !reject !accept, CTCP" ;; + transfer) echo "bonus/transfer.sh|ircserv_bonus|a DCC transfer through the bot: offer, accept, bytes received and compared" ;; + *) return 1 ;; + esac +} +G_BASIC="basic" +G_NET="socket flood probe throw overlong longline churn closing syscalls" +G_CLIENT="parser reply state registration messaging operator" +G_BONUS="bot transfer" +ALL="$G_BASIC $G_NET $G_CLIENT $G_BONUS" + +if [ "$#" -eq 0 ]; then + sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' + echo "groups: basic networking client bonus all" + echo "names: $ALL" + exit 0 +fi +names=() +for a in "$@"; do + case "$a" in + all) names+=($ALL) ;; basic) names+=($G_BASIC) ;; networking) names+=($G_NET) ;; + client) names+=($G_CLIENT) ;; bonus) names+=($G_BONUS) ;; *) names+=("$a") ;; + esac +done + +# Renders a test's output in one style: [section] -> section title, +# " ok text" -> [PASS] text, " FAIL text" -> [FAIL] text, the rest dimmed. +render() { + local line + while IFS= read -r line; do + case "$line" in + *" test: ["*"]: "*) continue ;; # server log lines (Logger) + " ok "*) t="${line# ok }"; echo " ${G}[PASS]${N} ${t#"${t%%[! ]*}"}" ;; + " FAIL "*) t="${line# FAIL }"; echo " ${R}[FAIL]${N} ${t#"${t%%[! ]*}"}" ;; + "["*"]"*) echo "${B}${line}${N}" ;; + "") ;; + *) echo "${D}${line}${N}" ;; + esac + done +} + +build_server() { # $1 = ircserv | ircserv_bonus + if [ "$1" = ircserv_bonus ]; then make -s -C "$ROOT" bonus >/dev/null 2>&1; else make -s -C "$ROOT" >/dev/null 2>&1; fi + [ -x "$ROOT/$1" ] +} +start_server() { # $1 = binary; sets SRV_PID PORT LOG + PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])') + LOG=$(mktemp -t ircserv_check.XXXXXX) + ( cd "$ROOT" && exec "$ROOT/$1" "$PORT" "$PASSWORD" > "$LOG" 2>&1 ) & + SRV_PID=$! + for i in $(seq 1 40); do + python3 -c "import socket; socket.create_connection(('127.0.0.1', $PORT), timeout=0.5).close()" 2>/dev/null && break + sleep 0.1 + done + echo "${D} $1 started for this test: pid $SRV_PID, port $PORT${N}" +} +stop_server() { kill $SRV_PID 2>/dev/null; wait $SRV_PID 2>/dev/null; rm -f "$LOG"; SRV_PID=""; } +trap 'if [ -n "$SRV_PID" ]; then stop_server; fi' EXIT + +passed=(); failed=() +for name in "${names[@]}"; do + line=$(spec "$name") || { echo "no such test: $name (names: $ALL)"; failed+=("$name"); continue; } + IFS='|' read -r file kind desc <<< "$line" + file="$HERE/$file"; dir="$(dirname "$file")" + echo; echo "${B}${C}[ $name ]${N} ${D}${desc}${N}" + if [ "$kind" = SELF ]; then # the script starts (and traces) its own servers + build_server ircserv || { echo " ${R}[FAIL]${N} make failed"; failed+=("$name"); continue; } + build_server ircserv_bonus || true + bash "$file" "$ROOT" "$PASSWORD" 2>&1 | render; rc=${PIPESTATUS[0]} + [ "$rc" -eq 0 ] && passed+=("$name") || failed+=("$name") + continue + fi + case "$file" in + *.cpp) + objs=() + case "$kind" in + SRV) objs=("${SRV[@]}") ;; + SOCKET) objs=("$ROOT/srcs/server/Socket.cpp" "$ROOT/srcs/server/Logger.cpp") ;; + esac + bin="$HERE/.$name.test" + if ! "${CXX[@]}" "-I$dir" -o "$bin" "$file" "${objs[@]}"; then + echo " ${R}[FAIL]${N} does not compile with the project flags"; failed+=("$name"); continue + fi + if [ "$kind" = E2E ]; then + build_server ircserv || { echo " ${R}[FAIL]${N} make failed"; failed+=("$name"); rm -f "$bin"; continue; } + "$bin" "$ROOT" 2>&1 | render; rc=${PIPESTATUS[0]} + else + "$bin" 2>&1 | render; rc=${PIPESTATUS[0]} + fi + rm -f "$bin" ;; + */basic/*.sh) + bash "$file" 2>&1 | render; rc=${PIPESTATUS[0]} ;; + *.py|*.sh) + build_server "$kind" || { echo " ${R}[FAIL]${N} make failed for $kind"; failed+=("$name"); continue; } + start_server "$kind" + out=$(mktemp) + case "$file" in + *.py) (cd "$dir" && python3 "$(basename "$file")" "$PORT" "$PASSWORD") 2>&1 | tee "$out" | render; rc=${PIPESTATUS[0]} ;; + *.sh) (cd "$dir" && bash "$(basename "$file")" 127.0.0.1 "$PORT" "$PASSWORD") 2>&1 | tee "$out" | render; rc=${PIPESTATUS[0]} ;; + esac + grep -qE "^ FAIL |Traceback" "$out" && rc=1 + kill -0 $SRV_PID 2>/dev/null || { echo " ${R}[FAIL]${N} the server died during $name"; rc=1; } + rm -f "$out"; stop_server ;; + esac + [ "$rc" -eq 0 ] && passed+=("$name") || failed+=("$name") +done +echo; echo "${M}$(printf -- '-%.0s' $(seq 1 62))${N}" +for n in "${passed[@]}"; do echo " ${G}PASS${N} $n"; done +for n in "${failed[@]}"; do echo " ${R}FAIL${N} $n"; done +echo " Results: ${G}${#passed[@]} pass${N} / ${R}${#failed[@]} fail${N} / $(( ${#passed[@]} + ${#failed[@]} )) total" +echo "${M}$(printf -- '-%.0s' $(seq 1 62))${N}" +[ ${#failed[@]} -eq 0 ] diff --git a/tests/dynamic_tester/Makefile b/tests/dynamic_tester/Makefile new file mode 100644 index 0000000..8fce675 --- /dev/null +++ b/tests/dynamic_tester/Makefile @@ -0,0 +1,88 @@ +# ft_irc tester -- convenience targets. See README.md. +# +# make everything -> artifacts/report.html +# make live everything, watching server + client windows live +# make step same, paused after every scenario (Enter = next, c = continue) +# make mandatory | robustness | load | bonus | compare +# make live-load the slow-reader load test with the live view +# make lab interactive: client windows + server log + help + /walk +# make clients N irssi or nc client windows on a launched server (tmux panes or separate windows) +# make irssi | nc same, program preselected +# make monitor btop-style live monitor (launches the server; drive it with irssi/nc) +# make attach monitor a server on the port: one you started, or the tester's while it runs +# make config print the tester.conf values +# make external test a server you started yourself +# make debug print every line sent/received +# make open open the report (macOS) make clean remove artifacts/ +# +# Overrides: make bonus PORT=6668 PASSWORD=secret +# make live SET="slow_client=drop overlong=truncate" (tester.conf values for one run) + +PYTHON ?= python3 +BINARY ?= ../../ircserv +BONUS_BINARY ?= ../../ircserv_bonus +HOST ?= 127.0.0.1 +PORT ?= 6667 +PASSWORD ?= pass +OUTDIR ?= artifacts +REPORT ?= $(OUTDIR)/report.html +SET ?= +SETS = $(foreach kv,$(SET),--set $(kv)) + +COMMON = --binary $(BINARY) --bonus-binary $(BONUS_BINARY) --host $(HOST) --port $(PORT) \ + --password $(PASSWORD) --outdir $(OUTDIR) --report $(REPORT) --no-intro $(SETS) + +.PHONY: all mandatory robustness load bonus compare terminal external debug live live-load step \ + lab irssi nc clients monitor monitor-bonus attach config open clean help + +all: + @$(PYTHON) test_irc.py --mode all $(COMMON) +mandatory: + @$(PYTHON) test_irc.py --mode mandatory $(COMMON) +robustness: + @$(PYTHON) test_irc.py --mode robustness $(COMMON) +load: + @$(PYTHON) test_irc.py --mode load $(COMMON) +bonus: + @$(PYTHON) test_irc.py --mode bonus $(COMMON) +compare: + @$(PYTHON) test_irc.py --mode compare $(COMMON) +terminal: + @$(PYTHON) test_irc.py --mode terminal $(COMMON) +external: + @$(PYTHON) test_irc.py --no-start --mode all $(COMMON) +debug: + @$(PYTHON) test_irc.py --mode all $(COMMON) --debug + +live: + @$(PYTHON) test_irc.py --mode all --live $(COMMON) +live-load: + @$(PYTHON) test_irc.py --mode load --live $(COMMON) +step: + @$(PYTHON) test_irc.py --mode all --live --step $(COMMON) + +N ?= 3 +lab: + @$(PYTHON) test_irc.py --mode lab $(COMMON) +irssi: + @$(PYTHON) test_irc.py --mode irssi $(COMMON) < /dev/tty +nc: + @$(PYTHON) test_irc.py --mode nc $(COMMON) < /dev/tty +clients: + @$(PYTHON) test_irc.py --mode clients $(COMMON) < /dev/tty + +monitor: + @$(PYTHON) irctop.py --start $(BINARY) $(PORT) $(PASSWORD) +monitor-bonus: + @$(PYTHON) irctop.py --start $(BONUS_BINARY) $(PORT) $(PASSWORD) +attach: + @$(PYTHON) irctop.py --attach $(PORT) $(PASSWORD) + +config: + @$(PYTHON) test_irc.py --show-config +open: + @open $(REPORT) 2>/dev/null || echo "Report at $(REPORT)" +clean: + @rm -rf $(OUTDIR) +help: + @$(PYTHON) test_irc.py --help diff --git a/tests/dynamic_tester/README.md b/tests/dynamic_tester/README.md new file mode 100644 index 0000000..5ca602b --- /dev/null +++ b/tests/dynamic_tester/README.md @@ -0,0 +1,131 @@ +# Dynamic Tester + +Starts the server on port 6667, drives it with real TCP clients, checks every reply against the +subject and RFC 2812, and writes one HTML report. Python 3.8 or newer, standard library only. +macOS and Linux. + +## Run + +From the repo root (the server is built first: `make`, `make bonus`): + +``` +make -C tests/dynamic_tester menu; Enter runs everything +make -C tests/dynamic_tester mandatory PASS NICK USER CAP, JOIN PART TOPIC PRIVMSG NOTICE KICK INVITE MODE, QUIT, PING +make -C tests/dynamic_tester robustness split and batched commands, line endings, 510-byte limit, what precedes a close, slow and stalled readers, reconnects, 20 clients +make -C tests/dynamic_tester load 30 clients reading one byte at a time +make -C tests/dynamic_tester bonus bot commands, file offer, the transfer, DCC relayed between clients +make -C tests/dynamic_tester compare the same bot dialogue against ircserv_bonus and ircserv, saved to two files +make -C tests/dynamic_tester live everything, with the server and client windows on screen while it runs +make -C tests/dynamic_tester step same, paused after every scenario: Enter runs the next one, c continues, p pauses again +make -C tests/dynamic_tester debug everything, every line sent and received printed +make -C tests/dynamic_tester open open the report (macOS) +make -C tests/dynamic_tester clean remove artifacts/ +``` + +`PORT= PASSWORD=` after any target changes the server the tester starts. +`make -C tests/dynamic_tester external PORT= PASSWORD=` tests a server you +started yourself. Ctrl-C stops the run; the report is written for what ran. + +## What you get + +In the terminal, one line per check: + +``` + [JOIN] join, names, topic, second member + [PASS] 353 lists both members + [FAIL] 366 ends the names list + :irc.local 366 alice #x <- the last lines received + -> send "366 :End of NAMES list" +``` + +| tag | meaning | +|---|---| +| PASS / FAIL | a subject requirement | +| NOTE | behaviour the subject leaves open; the check says what the server did and what else is common | +| INFO | a measurement (latency, memory, bytes) | +| SKIP | the check could not run (binary missing, port busy) | + + +At the end: `Results: N pass / N fail / N notes / N info / N skip / N total` and the link to the report. + +`artifacts/report.html` is one page: a summary table, then every scenario with its checks +(what was sent, what came back, what was expected and why), the client windows and the server +log for that scenario. Scenarios with a FAIL or NOTE are expanded and their windows are bordered +red or amber; click a scenario title to expand or collapse it. `artifacts/transcripts/.txt` +has the raw client and server lines; `artifacts/server_*.log` the server output. All of +`artifacts/` is ignored by git. + +## tester.conf + +Where the subject leaves a choice (bare `\n` accepted or not, what an over-long line gets, what +happens to a client that never reads, which numeric MODE returns without an argument, ...), +`tester.conf` says what to expect. Every line is commented out and shows the default; uncomment +and edit to match the server. A wrong entry appears as NOTE or FAIL in the affected checks. +`make -C tests/dynamic_tester config` prints the values; `SET="key=value"` overrides one for a run. + +## Lab + +`make -C tests/dynamic_tester lab` asks how many clients and their nicks, registers them and +shows each as a pane, the help on the left, the server log at the bottom, a menu bar under the +title and one input line. Whatever you type goes to the focused client as an IRC line. + +| keys | | +|---|---| +| Tab / Shift-Tab | next / previous stop: client panes, server log, help, menu bar (hidden panes are skipped) | +| Up/Down, Left/Right, PgUp/PgDn | scroll the focused pane | +| Enter | send the input line to the focused client; with an empty line, run the walkthrough step shown | +| Ctrl-L | layout: help + log / windows only / help only | +| Ctrl-D | leave | + +Menu bar (Tab to it, then Left/Right pick, Enter or Up/Down change, b back): what the client +windows show (all / sent / received / notes), their order (number / nick / unread), windows per +row, server-log rows, what the log shows (all / in / out / errors), help on or off, line ending, +start or step the walkthrough, add a client, quit. + +Walkthrough (`/walk` or the menu): the next step is shown on the line above the input, with the +client that will send it and what to expect. Enter runs it. Typing your own line sends that +first and leaves the step pending, so you can try an alternative before or instead of it. +`/skip` skips the step, `/back` goes back one, `/stop` ends it. The steps cover JOIN, messages, +TOPIC, +t, +o, KICK, +i and INVITE, +k, +l, the query, the bot and a transfer, QUIT. + +| command | | +|---|---| +| `/new 2`, `/new dave,eve` | more clients (five at most) | +| `/use N` | make client N the one you type to | +| `/msg `, `/join #chan` | PRIVMSG / JOIN | +| `/eol crlf\|lf\|none`, `/long 510`, `/noeol `, `/raw ` | line endings, exact lengths, partial sends | +| `/pause`, `/resume` | the client stops and restarts reading | +| `/close` | close the focused client abruptly | +| `/send bob ` then `/dcc` in bob's window | file transfer without the bot | +| `PRIVMSG DCCBot :!send bob `, `PRIVMSG DCCBot :!accept`, then `/dcc` | file transfer with the bot | +| `/help`, `/cols N`, `/log N`, `/quit` | hide help, windows per row, log rows, leave | + +Received files are saved under `artifacts/`. + +## Clients + +`make -C tests/dynamic_tester clients` (or `irssi` / `nc`) asks the program, the number of +clients and their nicks, then opens them as tmux panes in this terminal (Ctrl-b + arrow to +move, Ctrl-b z to zoom, Ctrl-b d to detach and stop the server) or as separate windows. nc +windows are registered already and take raw IRC lines. A cheat-sheet of commands is printed first. + +## Monitor + +`make -C tests/dynamic_tester monitor` starts the server and shows it live: CPU and memory +graphs, bytes and lines per second in and out, syscalls per second (strace on Linux, the server +log otherwise), connections and the poll set, probe latency, bot and transfer state, the +clients table (red = stalled), channels, the server log. + +`make -C tests/dynamic_tester attach` shows a server that is already on port 6667, and waits for +one if there is none yet. Run it in a second terminal next to the tester: it follows every +server restart between suites. The monitor's own probe client (nick `irctop`) appears in the +clients table. + +| keys | | +|---|---| +| Tab | clients -> log -> menu bar | +| Left/Right, Enter or Up/Down, b | in the menu bar: pick an option, change it, back | +| Up/Down, Left/Right | scroll the focused pane | +| q | quit | + +Plain ASCII is used when the terminal's curses has no wide-character support. diff --git a/tests/dynamic_tester/irctop.py b/tests/dynamic_tester/irctop.py new file mode 100644 index 0000000..f95a8fa --- /dev/null +++ b/tests/dynamic_tester/irctop.py @@ -0,0 +1,1423 @@ +#!/usr/bin/env python3 +""" +irctop -- a btop-style live dashboard for ircserv. + + LAUNCH mode (full picture): irctop starts the server on a pty and reads its + log live, so it sees nicks, channels, every message, the bot and the DCC + transfers, on top of what the OS shows (CPU, RSS, kernel send-queues): + + python3 irctop.py --start ../../ircserv 6667 pass + python3 irctop.py --start ../../ircserv_bonus 6667 pass + + ATTACH mode (server already running somewhere else): only the outside view -- + CPU/RSS, TCP connections on the port with their kernel Send-Q, probe latency: + + python3 irctop.py --attach 6667 pass + +Panels (fixed layout, each keeps its place; the log scrolls inside its own box): + + header pid, port, uptime, poll() call sites found in the sources, probe + round-trip in ms, server state + CPU / MEM gauge + 60 s graph of the server process (from /proc or ps) + NET bytes/s in and out of the server (from the log payloads) + MESSAGES IRC lines/s received and sent + SYSCALLS poll / accept / recv / send / close per second. Live counts need + strace (Linux). Without it the panel shows the counts derived from + the server log (recv/send lines, accepts, closes) and says so. + CONNECTIONS current, accepted, closed, stalled (kernel Send-Q growing), the + poll set (listeners + clients + transfer sockets), probe latency + BOT / DCC bot commands seen, current offer, transfer progress and result + CLIENTS per client: fd, nick, in/out lines, bytes out, kernel SendQ, idle, + channels (red = stalled) + CHANNELS members per channel + SERVER LOG scrolling, colour-coded + +Keys: Tab cycles the focus clients -> log -> menu bar. In the menu bar: left/right + pick an option (sort, graphs on/off, graph height, clients height, log + follow/paused, glyphs unicode/ascii, snapshot, quit), Enter or up/down + change it, b goes back. Up/down scroll the focused pane, End follows the + log again, q quits. Shortcuts still work: c sort, p pause, g graphs, + -/= graph height, [/] clients height, t snapshot. + +--text N prints N plain-text snapshots one second apart instead of the UI. +--no-strace never start strace (Linux only feature). + +stdlib only. macOS and Linux. +""" + +import argparse +import collections +import os +import re +import select +import socket +import subprocess +import sys +import threading +import time + +try: + import curses + HAVE_CURSES = True +except ImportError: + HAVE_CURSES = False + +try: + import pty + HAVE_PTY = True +except ImportError: + HAVE_PTY = False + +ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") +HIST = 120 # seconds of history kept for the graphs +SPARK = "▁▂▃▄▅▆▇█" +BLOCKS = " ▁▂▃▄▅▆▇█" + + +def strip_ansi(s): + return ANSI_RE.sub("", s) + + +def human(n): + n = float(n) + for unit in ("B", "K", "M", "G"): + if n < 1024 or unit == "G": + return ("%d%s" % (n, unit)) if unit == "B" else ("%.0f%s" % (n, unit)) + n /= 1024.0 + return "%.0fG" % n + + +def human_rate(n): + n = float(n) + if n < 1024: + return "%d B/s" % n + if n < 1024 * 1024: + return "%.1f KB/s" % (n / 1024) + return "%.2f MB/s" % (n / 1024 / 1024) + + +def spark(values, width): + vals = list(values)[-width:] + if not vals: + return "" + hi = max(vals) or 1 + return "".join(SPARK[min(7, int(v * 7 / hi))] for v in vals) + + +def graph_rows(values, width, rows, hi=None): + """Multi-row block graph like btop. Returns `rows` strings of `width` + chars, top row first, and the scale max used.""" + vals = list(values)[-width:] + vals = [0] * (width - len(vals)) + vals + top = hi if hi else (max(vals) if vals else 0) + if not top: + top = 1 + out = [] + levels = [min(rows * 8, int(round(v * rows * 8 / float(top)))) for v in vals] + for r in range(rows - 1, -1, -1): # r = row index from the bottom + line = [] + for lv in levels: + k = lv - r * 8 + line.append(BLOCKS[max(0, min(8, k))]) + out.append("".join(line)) + return out, top + + +def meter(width, frac): + frac = max(0.0, min(1.0, frac)) + n = int(round(frac * width)) + return "█" * n + "░" * (width - n) + + +# =========================================================================== +# State derived from the server log + samplers +# =========================================================================== + +class Client(object): + __slots__ = ("fd", "peer", "nick", "user", "registered", "chans", "msgs_in", + "msgs_out", "bytes_out", "bytes_in", "last", "since", "sendq", "closed", + "sendq_prev", "stall_ticks") + + def __init__(self, fd, peer): + self.fd = fd + self.peer = peer # "127.0.0.1:47088" + self.nick = "" + self.user = "" + self.registered = False + self.chans = set() + self.msgs_in = 0 + self.msgs_out = 0 + self.bytes_in = 0 + self.bytes_out = 0 + self.last = time.time() + self.since = time.time() + self.sendq = 0 + self.sendq_prev = 0 + self.stall_ticks = 0 + self.closed = False + + +class Offer(object): + __slots__ = ("file", "ip", "port", "size", "frm", "to", "status", "t0", "t1", "sent") + + def __init__(self, file, ip, port, size, frm, to): + self.file, self.ip, self.port, self.size = file, ip, port, size + self.frm, self.to = frm, to + self.status = "offered" + self.t0 = time.time(); self.t1 = None + self.sent = 0 + + +class State(object): + def __init__(self): + self.lock = threading.Lock() + self.clients = {} # fd -> Client + self.log = collections.deque(maxlen=4000) # (t, kind, text) + # per-second histories + self.in_hist = collections.deque(maxlen=HIST) # lines in + self.out_hist = collections.deque(maxlen=HIST) # lines out + self.bin_hist = collections.deque(maxlen=HIST) # bytes in + self.bout_hist = collections.deque(maxlen=HIST) # bytes out + self.rss_hist = collections.deque(maxlen=HIST) + self.cpu_hist = collections.deque(maxlen=HIST) + self.fd_hist = collections.deque(maxlen=HIST) + self.conn_hist = collections.deque(maxlen=HIST) # current connections + self.probe_hist = collections.deque(maxlen=HIST) + self.sys_hist = {k: collections.deque(maxlen=HIST) for k in ("poll", "accept", "recv", "send")} + self.sys_cur = {k: 0 for k in self.sys_hist} + self.sys_total = {k: 0 for k in self.sys_hist} + self.sys_source = "log" # "strace" | "log" | "none" + self.sys_note = "" + self.cur_in = 0; self.cur_out = 0; self.cur_bin = 0; self.cur_bout = 0 + self.total_in = 0; self.total_out = 0; self.total_bin = 0; self.total_bout = 0 + self.accepts = 0; self.closes = 0 + self.rss_mb = 0 + self.cpu = 0.0 + self.pid = None + self.port = None + self.started = time.time() + self.errors = 0 + self.probe_ms = None + self.fds = -1; self.socks = -1 + self.mode = "launch" + self.paused = False + self.scroll = 0 + self.sort = 0 # 0 fd, 1 traffic, 2 sendq + self.status = "" + self.poll_sites = None # (count, [(file, line)]) from the source scan + # bot / dcc + self.bot_nick = "" + self.bot_cmds = 0 + self.bot_last = "" + self.bot_replies = 0 + self.ctcp = 0 + self.offer = None + self.offers_done = 0 + self.dcc_fd = None + self.dcc_sendq = 0 + self.strace_bytes = {} # fd -> bytes sent (strace only) + + # ---- log parsing -------------------------------------------------------- + + _re_conn = re.compile(r"Client connected: fd:(\d+) .*?addr:port: (\S+)") + _re_from = re.compile(r"From FD (\d+): (.*)$") + _re_sendto = re.compile(r"Sendto FD (\d+): (.*)$") + _re_close = re.compile(r"(?:Closing client sockFd:|Client sockFd:)(\d+)") + _re_dcc = re.compile(r"DCC SEND (\S+) (\S+) (\d+) (\d+)") + + def feed(self, raw): + """One line of server output (ANSI already stripped).""" + t = time.time() + line = raw.rstrip("\r\n") + line = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d ", "", line) + line = "".join(ch if ch >= " " else "^" + chr(ord(ch) + 64) for ch in line) + kind = "info" + low = line.lower() + if "recvmsg event" in low or "sendmsg event" in low: + return # the header line; the payload follows + with self.lock: + m = self._re_conn.search(line) + if m: + fd = int(m.group(1)) + self.clients[fd] = Client(fd, m.group(2)) + self.accepts += 1 + if self.sys_source == "log": + self.sys_cur["accept"] += 1; self.sys_total["accept"] += 1 + kind = "conn" + m = self._re_from.search(line) + if m: + fd = int(m.group(1)); payload = m.group(2) + c = self.clients.get(fd) + if c is None: + c = self.clients[fd] = Client(fd, "?") + n = len(payload) + 2 + c.msgs_in += 1; c.bytes_in += n; c.last = t + self.cur_in += 1; self.total_in += 1; self.cur_bin += n; self.total_bin += n + if self.sys_source == "log": + self.sys_cur["recv"] += 1; self.sys_total["recv"] += 1 + self._on_in(c, payload) + kind = "in" + m = self._re_sendto.search(line) + if m: + fd = int(m.group(1)); payload = m.group(2) + c = self.clients.get(fd) + if c is None: + c = self.clients[fd] = Client(fd, "?") + n = len(payload) + 2 + c.msgs_out += 1; c.bytes_out += n; c.last = t + self.cur_out += 1; self.total_out += 1; self.cur_bout += n; self.total_bout += n + if self.sys_source == "log": + self.sys_cur["send"] += 1; self.sys_total["send"] += 1 + self._on_out(c, payload) + kind = "out" + m = self._re_close.search(line) + if m and ("closing client" in low or "closed connection" in low): + fd = int(m.group(1)) + c = self.clients.get(fd) + if c: + c.closed = True; c.chans.clear() + self.closes += 1 + kind = "conn" + if "[error" in low or "[except" in low or "error " in low[:60]: + kind = "err"; self.errors += 1 + if "dcc" in low or "transfer" in low or "!send" in low or "!accept" in low or "!reject" in low: + if kind in ("in", "out"): + kind = kind + "bot" + self.log.append((t, kind, line)) + + def _on_in(self, c, payload): + parts = payload.split(" ", 2) + cmd = parts[0].upper() if parts else "" + if cmd == "NICK" and len(parts) > 1: + c.nick = parts[1].lstrip(":") + elif cmd == "USER" and len(parts) > 1: + c.user = parts[1] + elif cmd == "PRIVMSG" and len(parts) > 2: + target, text = parts[1], parts[2].lstrip(":") + if self.bot_nick and target.lower() == self.bot_nick.lower(): + self.bot_cmds += 1 + self.bot_last = "%s: %s" % (c.nick or "fd%d" % c.fd, text[:40]) + if text.startswith("\x01"): + self.ctcp += 1 + elif text.startswith("!") and not self.bot_nick: + # first bot command before we know the bot nick: remember target + self.bot_last = "%s: %s" % (c.nick or "fd%d" % c.fd, text[:40]) + + def _on_out(self, c, payload): + # numerics: ":irc.local 001 nick ..." => registered + m = re.match(r":\S+ (\d{3}) (\S+)", payload) + if m: + if m.group(1) == "001": + c.registered = True; c.nick = m.group(2) + return + # ":nick!user@host CMD args" + m = re.match(r":([^!\s]+)!(\S+) (\S+)(?: (.*))?", payload) + if not m: + return + who, host, cmd, rest = m.group(1), m.group(2), m.group(3).upper(), (m.group(4) or "") + args = rest.split(" ") + if host.startswith("bot@") or who.lower().endswith("bot"): + if not self.bot_nick: + self.bot_nick = who + if who == self.bot_nick and cmd in ("PRIVMSG", "NOTICE"): + self.bot_replies += 1 + text = rest.split(" :", 1)[1] if " :" in rest else rest + d = self._re_dcc.search(text) + if d: + self.offer = Offer(d.group(1), d.group(2), int(d.group(3)), int(d.group(4)), + "?", c.nick or "fd%d" % c.fd) + self.dcc_fd = None + self.strace_bytes = {} + # the sender is the last client that talked to the bot + if ":" in self.bot_last: + self.offer.frm = self.bot_last.split(":", 1)[0] + elif self.offer: + low = text.lower() + if "rejected" in low: + self.offer.status = "rejected"; self.offer.t1 = time.time(); self.offers_done += 1 + elif "accepted" in low: + self.offer.status = "accepted, waiting for the receiver to connect" + elif "complete" in low: + self.offer.status = "complete"; self.offer.t1 = time.time(); self.offers_done += 1 + self.offer.sent = self.offer.size + elif "failed" in low or "expired" in low or "timed out" in low: + self.offer.status = "failed"; self.offer.t1 = time.time(); self.offers_done += 1 + if who == c.nick: + if cmd == "JOIN" and args and args[0]: + c.chans.add(args[0].lstrip(":")) + elif cmd == "PART" and args and args[0]: + c.chans.discard(args[0]) + elif cmd == "NICK" and args and args[0]: + c.nick = args[0].lstrip(":") + if cmd == "KICK" and len(args) >= 2 and args[1] == c.nick: + c.chans.discard(args[0]) + + # ---- per-second sampling -------------------------------------------------- + + def tick(self): + with self.lock: + self.in_hist.append(self.cur_in); self.out_hist.append(self.cur_out) + self.bin_hist.append(self.cur_bin); self.bout_hist.append(self.cur_bout) + self.cur_in = self.cur_out = self.cur_bin = self.cur_bout = 0 + self.rss_hist.append(self.rss_mb) + self.cpu_hist.append(self.cpu) + self.conn_hist.append(len([c for c in self.clients.values() if not c.closed])) + self.probe_hist.append(self.probe_ms if self.probe_ms is not None else 0) + for k in self.sys_hist: + self.sys_hist[k].append(self.sys_cur[k]); self.sys_cur[k] = 0 + for c in self.clients.values(): + if c.closed: + continue + if c.sendq > 0 and c.sendq >= c.sendq_prev: + c.stall_ticks += 1 + else: + c.stall_ticks = 0 + c.sendq_prev = c.sendq + + def channels(self): + chans = collections.defaultdict(list) + for c in self.clients.values(): + if c.closed: + continue + for ch in c.chans: + chans[ch].append(c.nick or ("fd%d" % c.fd)) + return chans + + def live_clients(self): + cl = [c for c in self.clients.values() if not c.closed] + if self.sort == 1: + cl.sort(key=lambda c: -(c.msgs_in + c.msgs_out)) + elif self.sort == 2: + cl.sort(key=lambda c: -c.sendq) + else: + cl.sort(key=lambda c: c.fd) + return cl + + def stalled(self): + return [c for c in self.live_clients() if c.sendq >= 32768 or c.stall_ticks >= 3 and c.sendq > 0] + + +# =========================================================================== +# Samplers: process stats + kernel send queues + probe latency + strace +# =========================================================================== + +_CLK = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100 +_cpu_prev = {} + + +def ps_stats(pid): + """(rss_mb, cpu_percent). Linux: /proc (CPU = delta since last call); + macOS: ps.""" + try: + with open("/proc/%d/stat" % pid) as f: + fields = f.read().rsplit(")", 1)[1].split() + utime, stime = int(fields[11]), int(fields[12]) + with open("/proc/%d/statm" % pid) as f: + rss = int(f.read().split()[1]) * os.sysconf("SC_PAGE_SIZE") // (1024 * 1024) + now = time.time(); tot = (utime + stime) / float(_CLK) + prev = _cpu_prev.get(pid) + _cpu_prev[pid] = (now, tot) + cpu = 0.0 + if prev and now > prev[0]: + cpu = (tot - prev[1]) / (now - prev[0]) * 100.0 + return rss, max(0.0, cpu) + except (OSError, IndexError, ValueError): + pass + try: + out = subprocess.check_output(["ps", "-o", "rss=,%cpu=", "-p", str(pid)], + stderr=subprocess.DEVNULL).decode().split() + return int(out[0]) // 1024, float(out[1]) + except Exception: + return 0, 0.0 + + +def fd_counts(pid): + """(open fds, of which sockets) for the process. Linux via /proc, macOS via lsof.""" + try: + d = "/proc/%d/fd" % pid + names = os.listdir(d) + socks = 0 + for n in names: + try: + if os.readlink(os.path.join(d, n)).startswith("socket:"): + socks += 1 + except OSError: + pass + return len(names), socks + except OSError: + pass + try: + out = subprocess.check_output(["lsof", "-nP", "-p", str(pid)], stderr=subprocess.DEVNULL).decode().splitlines()[1:] + socks = sum(1 for l in out if " TCP " in l or " IPv4 " in l or " IPv6 " in l) + return len(out), socks + except Exception: + return -1, -1 + + +def thread_count(pid): + try: + with open("/proc/%d/status" % pid) as f: + for line in f: + if line.startswith("Threads:"): + return int(line.split()[1]) + except OSError: + pass + try: + out = subprocess.check_output(["ps", "-M", "-p", str(pid)], stderr=subprocess.DEVNULL).decode().splitlines() + return max(0, len(out) - 1) + except Exception: + return -1 + + +def find_pid_on_port(port): + """Best effort: lsof (mac/linux) then ss (linux).""" + try: + out = subprocess.check_output(["lsof", "-nP", "-iTCP:%d" % port, "-sTCP:LISTEN", "-t"], + stderr=subprocess.DEVNULL).decode().split() + if out: + return int(out[0]) + except Exception: + pass + try: + out = subprocess.check_output(["ss", "-ltnp"], stderr=subprocess.DEVNULL).decode() + for line in out.splitlines(): + if ":%d " % port in line: + m = re.search(r"pid=(\d+)", line) + if m: + return int(m.group(1)) + except Exception: + pass + return None + + +def kernel_sendq(port): + """{peer 'ip:port': sendq_bytes} for established connections whose LOCAL end + is the server port. macOS: netstat -an; Linux: /proc/net/tcp, then ss.""" + res = {} + if sys.platform == "darwin": + try: + out = subprocess.check_output(["netstat", "-an", "-p", "tcp"], + stderr=subprocess.DEVNULL).decode() + except Exception: + return res + for line in out.splitlines(): + f = line.split() + if len(f) < 6 or not f[0].startswith("tcp"): + continue + local, foreign, state = f[3], f[4], f[5] + if state != "ESTABLISHED": + continue + lp = local.rsplit(".", 1) + if len(lp) != 2 or lp[1] != str(port): + continue + fp = foreign.rsplit(".", 1) + if len(fp) != 2: + continue + peer = "%s:%s" % (fp[0].replace("::ffff:", ""), fp[1]) + try: + res[peer] = int(f[2]) + except ValueError: + pass + return res + + def _ip4(hexaddr): + b = bytes.fromhex(hexaddr) + return "%d.%d.%d.%d" % (b[3], b[2], b[1], b[0]) + + def _ip6(hexaddr): + if hexaddr[:24].upper() == "0000000000000000FFFF0000": + return _ip4(hexaddr[24:32]) + return hexaddr.lower() + + for path, conv in (("/proc/net/tcp", _ip4), ("/proc/net/tcp6", _ip6)): + try: + with open(path) as f: + next(f) + for line in f: + fl = line.split() + if len(fl) < 5 or fl[3] != "01": + continue + laddr, lport = fl[1].rsplit(":", 1) + if int(lport, 16) != port: + continue + raddr, rport = fl[2].rsplit(":", 1) + txq = int(fl[4].split(":")[0], 16) + res["%s:%d" % (conv(raddr), int(rport, 16))] = txq + except (OSError, StopIteration, ValueError): + continue + if res: + return res + try: + out = subprocess.check_output(["ss", "-tn", "state", "established"], + stderr=subprocess.DEVNULL).decode() + except Exception: + return res + for line in out.splitlines(): + f = line.split() + if len(f) < 4 or not f[0].isdigit(): + continue + local, peer = f[2], f[3] + if not local.endswith(":%d" % port): + continue + peer = peer.replace("[::ffff:", "").replace("]", "") + try: + res[peer] = int(f[1]) + except ValueError: + pass + return res + + +def scan_poll_sites(binary): + """Count `poll(` call sites in the sources next to the binary (repo root). + Comments are stripped; #ifdef BONUS code is included. Returns (n, sites).""" + root = os.path.dirname(os.path.abspath(binary)) + files = [] + for sub in ("srcs", "src", "sources", "includes", "include", "inc"): + d = os.path.join(root, sub) + if os.path.isdir(d): + for dp, _, fns in os.walk(d): + files += [os.path.join(dp, fn) for fn in fns if fn.endswith((".cpp", ".hpp", ".h", ".cc", ".tpp"))] + if not files: + files = [os.path.join(root, fn) for fn in os.listdir(root) if fn.endswith((".cpp", ".hpp"))] + sites = [] + rx = re.compile(r"(?])poll\s*\(") + for fn in files: + try: + src = open(fn, errors="ignore").read() + except OSError: + continue + src = re.sub(r"/\*.*?\*/", lambda m: "\n" * m.group(0).count("\n"), src, flags=re.S) + for i, line in enumerate(src.split("\n"), 1): + code = line.split("//", 1)[0] + code = re.sub(r'"(?:\\.|[^"\\])*"', '""', code) + if rx.search(code): + sites.append((os.path.relpath(fn, root), i)) + return len(sites), sites + + +class Probe(threading.Thread): + """A tiny registered client that PINGs every second and records the round trip.""" + + def __init__(self, state, host, port, password): + threading.Thread.__init__(self) + self.daemon = True + self.state = state; self.host = host; self.port = port; self.password = password + self.stop = False + + def run(self): + while not self.stop: + try: + s = socket.create_connection((self.host, self.port), timeout=3) + s.sendall(("PASS %s\r\nNICK irctop\r\nUSER irctop 0 * :irctop\r\n" + % self.password).encode()) + s.settimeout(3) + buf = b"" + t0 = time.time() + while b"001" not in buf and time.time() - t0 < 3: + try: + buf += s.recv(4096) + except socket.timeout: + break + while not self.stop: + t1 = time.time() + s.sendall(b"PING :irctop\r\n") + got = b"" + while b"PONG" not in got and time.time() - t1 < 3: + try: + chunk = s.recv(4096) + except socket.timeout: + break + if not chunk: + raise OSError("closed") + got += chunk + self.state.probe_ms = (time.time() - t1) * 1000 if b"PONG" in got else None + time.sleep(1) + except OSError: + self.state.probe_ms = None + time.sleep(2) + + +class Strace(threading.Thread): + """Linux only: strace -p PID and count poll/accept/recv/send/close per second. + Also sums sendto() results per fd (used for DCC transfer progress).""" + SYSCALLS = "poll,ppoll,accept,accept4,recvfrom,recv,read,sendto,send,write,close" + _rx = re.compile(r"^(?:\[pid\s+\d+\]\s+)?(\w+)\((\d*)") + _rx_ret = re.compile(r"=\s*(-?\d+)\s*$") + + def __init__(self, state, pid): + threading.Thread.__init__(self); self.daemon = True + self.state = state; self.pid = pid; self.proc = None; self.stop = False + + def run(self): + st = self.state + try: + self.proc = subprocess.Popen(["strace", "-p", str(self.pid), "-qq", "-e", "trace=" + self.SYSCALLS, + "-s", "0"], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + except OSError: + st.sys_note = "strace not found"; return + first = self.proc.stderr.readline().decode(errors="ignore") + if "Operation not permitted" in first or "attach" in first.lower() and "denied" in first.lower(): + st.sys_note = "strace could not attach (ptrace not permitted)"; self.proc.kill(); return + st.sys_source = "strace"; st.sys_note = "live via strace" + for k in st.sys_total: + st.sys_total[k] = 0 + lines = [first] if first else [] + while not self.stop: + line = self.proc.stderr.readline() + if not line: + break + lines = [line.decode(errors="ignore")] + for l in lines: + if "resumed>" in l: + continue + m = self._rx.match(l) + if not m: + continue + name, fd = m.group(1), m.group(2) + key = {"poll": "poll", "ppoll": "poll", "accept": "accept", "accept4": "accept", + "recvfrom": "recv", "recv": "recv", "read": "recv", + "sendto": "send", "send": "send", "write": "send"}.get(name) + if key is None: + continue + if key in ("recv", "send") and fd in ("0", "1", "2"): + continue # stdout logging, not network + with st.lock: + st.sys_cur[key] += 1; st.sys_total[key] += 1 + if key == "send" and fd: + r = self._rx_ret.search(l) + if r and int(r.group(1)) > 0: + st.strace_bytes[int(fd)] = st.strace_bytes.get(int(fd), 0) + int(r.group(1)) + if st.sys_source == "strace": + st.sys_note = "strace ended" + + def end(self): + self.stop = True + if self.proc: + try: + self.proc.terminate() + except Exception: + pass + + +# =========================================================================== +# Server launcher (pty) -- keeps std::cout line-buffered +# =========================================================================== + +class Launched(object): + def __init__(self, state, binary, port, password): + self.state = state + self.proc = None + argv = [os.path.abspath(binary), str(port), password] + cwd = os.path.dirname(os.path.abspath(binary)) or "." + if HAVE_PTY: + self.master, slave = pty.openpty() + self.proc = subprocess.Popen(argv, stdout=slave, stderr=slave, cwd=cwd, close_fds=True) + os.close(slave) + threading.Thread(target=self._pump_pty, daemon=True).start() + else: + self.master = None + self.proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=cwd) + threading.Thread(target=self._pump_pipe, daemon=True).start() + state.pid = self.proc.pid + + def _pump_pty(self): + buf = b"" + while True: + try: + data = os.read(self.master, 4096) + except OSError: + break + if not data: + break + buf += data + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + self.state.feed(strip_ansi(line.decode(errors="ignore"))) + self.state.feed("[irctop] server output ended") + + def _pump_pipe(self): + for line in iter(self.proc.stdout.readline, b""): + self.state.feed(strip_ansi(line.decode(errors="ignore"))) + + def alive(self): + return self.proc is not None and self.proc.poll() is None + + def stop(self): + if self.proc is None: + return + try: + self.proc.terminate(); self.proc.wait(timeout=5) + except Exception: + try: + self.proc.kill(); self.proc.wait(timeout=3) + except Exception: + pass + + +# =========================================================================== +# Sampler thread: every second, ps + kernel sendq + tick +# =========================================================================== + +class Sampler(threading.Thread): + def __init__(self, state): + threading.Thread.__init__(self); self.daemon = True + self.state = state; self.stop = False + self.threads = -1 + + def run(self): + st = self.state + n = 0 + while not self.stop: + if st.mode == "attach": + alive = False + try: + os.kill(st.pid, 0); alive = True + except (OSError, TypeError): + pass + if not alive: + new = find_pid_on_port(st.port) + if new and new != st.pid: + st.pid = new + st.feed("[irctop] server on port %d restarted: now pid %d" % (st.port, new)) + elif new is None and st.pid: + st.feed("[irctop] server on port %d is gone, waiting for the next one" % st.port) + st.pid = None + if st.pid: + rss, cpu = ps_stats(st.pid) + st.rss_mb, st.cpu = rss, cpu + st.fds, st.socks = fd_counts(st.pid) + st.fd_hist.append(max(0, st.socks)) + if n % 5 == 0: + self.threads = thread_count(st.pid) + q = kernel_sendq(st.port) if st.port else {} + with st.lock: + for c in st.clients.values(): + if not c.closed: + c.sendq = q.get(c.peer, 0) + if st.mode == "attach": + seen = set() + for peer, sq in q.items(): + seen.add(peer) + key = hash(peer) & 0xffff + c = st.clients.get(key) + if c is None: + c = st.clients[key] = Client(key, peer) + c.sendq = sq; c.closed = False + for c in st.clients.values(): + if c.peer not in seen: + c.closed = True + off = st.offer + if off and off.status in ("accepted, waiting for the receiver to connect", "transferring"): + dq = kernel_sendq(off.port) + st.dcc_sendq = sum(dq.values()) if dq else 0 + if dq: + off.status = "transferring" + # strace: bytes sent on the one fd that is not a client fd + if st.strace_bytes: + known = set(st.clients.keys()) + cand = [b for fd, b in st.strace_bytes.items() if fd not in known] + if cand: + off.sent = max(cand) + st.tick() + n += 1 + time.sleep(1) + + +# =========================================================================== +# Text snapshot (no curses) +# =========================================================================== + +def snapshot_text(st, width=100): + up = int(time.time() - st.started) + lines = [] + ps = st.poll_sites + lines.append("ircserv pid %s port %s up %02d:%02d:%02d poll() sites %s probe %s RSS %d MB CPU %.1f%%" + % (st.pid, st.port, up // 3600, (up // 60) % 60, up % 60, + ps[0] if ps else "?", ("%.1f ms" % st.probe_ms) if st.probe_ms is not None else "-", + st.rss_mb, st.cpu)) + lines.append("net in %-12s out %-12s msgs in %3d/s out %3d/s conns %d (accepted %d, closed %d, stalled %d)" + % (human_rate((list(st.bin_hist) or [0])[-1]), human_rate((list(st.bout_hist) or [0])[-1]), + (list(st.in_hist) or [0])[-1], (list(st.out_hist) or [0])[-1], + len(st.live_clients()), st.accepts, st.closes, len(st.stalled()))) + lines.append("syscalls/s (%s): %s totals: %s" % ( + st.sys_note or st.sys_source, + " ".join("%s %d" % (k, (list(st.sys_hist[k]) or [0])[-1]) for k in ("poll", "accept", "recv", "send")), + " ".join("%s %d" % (k, st.sys_total[k]) for k in ("poll", "accept", "recv", "send")))) + listeners = max(0, st.socks - len(st.live_clients())) if st.socks >= 0 else -1 + lines.append("poll set: sockets %s = listeners %s + clients %d fds open %s msgs in %s out %s" + % (st.socks if st.socks >= 0 else "?", listeners if listeners >= 0 else "?", + len(st.live_clients()), st.fds if st.fds >= 0 else "?", + spark(st.in_hist, 20), spark(st.out_hist, 20))) + if st.bot_nick or st.offer: + o = st.offer + lines.append("bot %s commands %d replies %d ctcp %d last: %s" % (st.bot_nick or "?", st.bot_cmds, st.bot_replies, st.ctcp, st.bot_last)) + if o: + lines.append("dcc %s -> %s %s (%s) port %d %s%s" % (o.frm, o.to, o.file, human(o.size), o.port, o.status, + (" sent %s" % human(o.sent)) if o.sent else "")) + lines.append("-" * width) + lines.append("%-4s %-12s %-3s %-16s %6s %6s %8s %8s %6s %s" + % ("fd", "nick", "reg", "peer", "in", "out", "bytesOut", "SendQ", "idle", "channels")) + now = time.time() + for c in st.live_clients(): + lines.append("%-4s %-12s %-3s %-16s %6d %6d %8s %8s %5ds %s" + % (c.fd, (c.nick or "-")[:12], "Y" if c.registered else "-", c.peer[:16], + c.msgs_in, c.msgs_out, human(c.bytes_out), human(c.sendq), + int(now - c.last), ",".join(sorted(c.chans)))) + lines.append("-" * width) + chans = st.channels() + if chans: + for ch in sorted(chans): + lines.append("%-14s (%d): %s" % (ch, len(chans[ch]), " ".join(sorted(chans[ch])))) + lines.append("-" * width) + with st.lock: + tail = list(st.log)[-12:] + for (t, kind, text) in tail: + lines.append("%s %s" % (time.strftime("%H:%M:%S", time.localtime(t)), text[: width - 9])) + return "\n".join(lines) + + +# =========================================================================== +# Curses UI -- boxed panels, btop style +# =========================================================================== + +# Two glyph sets. Python's curses on macOS is often linked against Apple's old +# non-wide ncurses, which counts a multi-byte character as several cells and +# corrupts them on redraw; there we fall back to plain ASCII automatically. +GLYPHS = { + "unicode": dict(blocks=" ▁▂▃▄▅▆▇█", fill="█", empty="░", tl="╭", tr="╮", bl="╰", br="╯", h="─", v="│", + sep="─", ok="✓", bad="✗", arrow="→", more="…", updown="↑/↓", lr="←/→"), + "ascii": dict(blocks=" .:-=+*#@", fill="#", empty="-", tl="+", tr="+", bl="+", br="+", h="-", v="|", + sep="-", ok="OK", bad="X", arrow="->", more="...", updown="up/down", lr="left/right"), +} + + +def curses_is_wide(): + """True when this Python's curses was built against a wide-char ncurses.""" + return hasattr(curses, "unget_wch") + + +def ui(stdscr, st, launched, sampler, probe, glyphs="auto"): + curses.curs_set(0); stdscr.nodelay(True); stdscr.timeout(250); stdscr.keypad(True) + curses.start_color(); curses.use_default_colors() + curses.init_pair(1, curses.COLOR_GREEN, -1); curses.init_pair(2, curses.COLOR_YELLOW, -1) + curses.init_pair(3, curses.COLOR_RED, -1); curses.init_pair(4, curses.COLOR_CYAN, -1) + curses.init_pair(5, curses.COLOR_MAGENTA, -1); curses.init_pair(6, curses.COLOR_BLUE, -1) + curses.init_pair(7, curses.COLOR_WHITE, -1) + C_GRN, C_YEL, C_RED, C_CYN, C_MAG, C_BLU, C_WHT = [curses.color_pair(i) for i in range(1, 8)] + DIM = curses.A_DIM + if glyphs == "auto": + glyphs = "unicode" if curses_is_wide() else "ascii" + G = GLYPHS[glyphs] + + # ---- view options (the menu bar edits these) -------------------------------- + opts = {"sort": 0, "graphs": True, "graph_rows": 0, "client_rows": 0, "follow": True, + "glyphs": glyphs, "bytes": "human"} + MENU = [ # (key, label, kind) kind: cycle / toggle / int / action + ("sort", "sort", "cycle"), ("graphs", "graphs", "toggle"), ("graph_rows", "graph height", "int"), + ("client_rows", "clients height", "int"), ("follow", "log", "toggle"), ("glyphs", "glyphs", "cycle"), + ("snapshot", "snapshot", "action"), ("quit", "quit", "action"), + ] + SORTS = ("fd", "most traffic", "biggest queue") + focus = 1 # 0 = clients table, 1 = log, 2 = menu bar + menu_i = 0 + client_scroll = 0 + hscroll = {0: 0, 1: 0} # horizontal scroll of the clients table / log + KEYS = ("poll", "accept", "recv", "send") + hint = "" + + def put(y, x, s, attr=0): + h, w = stdscr.getmaxyx() + if 0 <= y < h and 0 <= x < w: + lim = w - x - (1 if y == h - 1 else 0) + try: + stdscr.addstr(y, x, s[: max(0, lim)], attr) + except curses.error: + pass + + def box(y, x, hh, ww, title, attr=C_CYN, focused=False): + """Box; returns (iy, ix, ih, iw) of the inside.""" + if hh < 2 or ww < 4: + return (y + 1, x + 1, max(0, hh - 2), max(0, ww - 2)) + put(y, x, G["tl"] + G["h"] * (ww - 2) + G["tr"], attr) + for i in range(1, hh - 1): + put(y + i, x, G["v"], attr); put(y + i, x + ww - 1, G["v"], attr) + put(y + hh - 1, x, G["bl"] + G["h"] * (ww - 2) + G["br"], attr) + if title: + put(y, x + 2, " %s " % title[: ww - 6], attr | curses.A_BOLD | (curses.A_REVERSE if focused else 0)) + return (y + 1, x + 1, hh - 2, ww - 2) + + def sep(y, x, w_, attr=DIM): + """Thin separator inside a panel (btop style).""" + put(y, x, G["sep"] * max(0, w_), attr) + + def grad(frac): + return C_GRN if frac < 0.5 else (C_YEL if frac < 0.8 else C_RED) + + def graph(y, x, rows, width, hist, hi=None, color=None): + """Block graph coloured per column by its height (green→yellow→red).""" + if rows <= 0 or width <= 0: + return 0 + vals = list(hist)[-width:] + vals = [0] * (width - len(vals)) + vals + top = hi if hi else (max(vals) if vals else 0) + top = top or 1 + blocks = G["blocks"] + levels = [min(rows * 8, int(round(v * rows * 8 / float(top)))) for v in vals] + for r in range(rows): # r = 0 is the top row + rb = rows - 1 - r # row index from the bottom + for i, lv in enumerate(levels): + k = lv - rb * 8 + if k <= 0: + continue + ch = blocks[min(8, k)] + frac = vals[i] / float(top) + put(y + r, x + i, ch, color if color is not None else grad(frac)) + return top + + def meter(width, frac): + frac = max(0.0, min(1.0, frac)); n = int(round(frac * width)) + return G["fill"] * n + G["empty"] * (width - n) + + def clamp_rows(val, lo, hi_): + return max(lo, min(hi_, val)) + + def scale(hist, floor): + """Graph ceiling: the recent peak, never below `floor` (so a quiet + server shows small bars instead of a full-height wall).""" + return max(float(floor), float(max(list(hist) or [0]))) + + while True: + h, w = stdscr.getmaxyx(); stdscr.erase() + up = int(time.time() - st.started) + alive = (launched.alive() if launched else True) + st.sort = opts["sort"]; st.paused = not opts["follow"] + clients = st.live_clients(); chans = st.channels(); stalled = st.stalled() + ps = st.poll_sites + G = GLYPHS[opts["glyphs"]] + + # ---- row 0: header ---------------------------------------------------------- + pollstr = ("poll() sites %d %s" % (ps[0], G["ok"] if ps[0] == 1 else G["bad"])) if ps else "poll() sites ?" + hdr = " irctop pid %s port %s up %02d:%02d:%02d %s threads %s probe %s %s" % ( + st.pid, st.port, up // 3600, (up // 60) % 60, up % 60, pollstr, + sampler.threads if sampler.threads >= 0 else "?", + ("%.1f ms" % st.probe_ms) if st.probe_ms is not None else "no PONG", + "RUNNING" if alive else "SERVER EXITED") + if w < 110 or h < 36: + hdr += " (terminal %dx%d; 110x36+ shows everything)" % (w, h) + put(0, 0, hdr.ljust(w - 1), curses.A_REVERSE | (0 if alive else C_RED)) + if ps and ps[0] != 1: + put(0, hdr.find("poll()"), "poll() sites %d %s" % (ps[0], G["bad"]), curses.A_REVERSE | C_RED | curses.A_BOLD) + + # ---- row 1: menu bar ---------------------------------------------------------- + def menu_value(key): + if key == "sort": + return SORTS[opts["sort"]] + if key == "graphs": + return "on" if opts["graphs"] else "off" + if key == "graph_rows": + return "%+d" % opts["graph_rows"] if opts["graph_rows"] else "auto" + if key == "client_rows": + return "%+d" % opts["client_rows"] if opts["client_rows"] else "auto" + if key == "follow": + return "follow" if opts["follow"] else "paused" + if key == "glyphs": + return opts["glyphs"] + return "" + x = 0 + put(1, 0, " " * (w - 1), DIM) + for i, (key, label, kind) in enumerate(MENU): + val = menu_value(key) + txt = " %s%s " % (label, (": " + val) if val else "") + sel = (focus == 2 and i == menu_i) + put(1, x, txt, (curses.A_REVERSE | curses.A_BOLD) if sel else (C_CYN if focus == 2 else DIM)) + x += len(txt) + 1 + tail = (" %s pick Enter/%s change b back" % (G["lr"], G["updown"])) if focus == 2 else " Tab: menu" + put(1, min(x, max(0, w - len(tail) - 1)), tail, C_YEL if focus == 2 else DIM) + + # ---- panels --------------------------------------------------------------------- + base = 9 if h >= 44 else (8 if h >= 40 else 7) + ah = clamp_rows(base + opts["graph_rows"], 5, 16) + y = 2 + + def draw_panels(): + nonlocal y + cw = w // 4 + widths = [cw, cw, cw, w - 3 * cw] + xs = [0, cw, 2 * cw, 3 * cw] + + # CPU + iy, ix, ih, iw = box(y, xs[0], ah, widths[0], "CPU") + cpu = st.cpu + put(iy, ix, "%5.1f%% " % cpu, grad(min(1.0, cpu / 100.0)) | curses.A_BOLD) + put(iy, ix + 7, meter(max(4, iw - 8), min(1.0, cpu / 100.0)), grad(min(1.0, cpu / 100.0))) + sep(iy + 1, ix, iw) + cpu_hi = max(10.0, max(list(st.cpu_hist) or [0.0])) + graph(iy + 2, ix, ih - 3, iw, st.cpu_hist, hi=cpu_hi) + put(iy + ih - 1, ix, ("scale %d%% 2 min" % cpu_hi)[:iw], DIM) + + # MEM + iy, ix, ih, iw = box(y, xs[1], ah, widths[1], "MEMORY") + rss_hi = max(list(st.rss_hist) or [1]) or 1 + put(iy, ix, "RSS %d MB" % st.rss_mb, C_CYN | curses.A_BOLD) + put(iy, ix + 11, ("peak %d MB open fds %s" % (rss_hi, st.fds if st.fds >= 0 else "?"))[: iw - 11], DIM) + sep(iy + 1, ix, iw) + graph(iy + 2, ix, ih - 3, iw, st.rss_hist, hi=max(rss_hi, 1) * 1.25, color=C_CYN) + trend = "" + if len(st.rss_hist) >= 30: + d = list(st.rss_hist)[-1] - list(st.rss_hist)[-30] + trend = ("growing +%d MB in 30 s" % d) if d > 0 else "flat" + put(iy + ih - 1, ix, ("resident memory %s" % trend)[:iw], C_RED if trend.startswith("growing") else DIM) + + # NET + iy, ix, ih, iw = box(y, xs[2], ah, widths[2], "NETWORK bytes/s") + bi = (list(st.bin_hist) or [0])[-1]; bo = (list(st.bout_hist) or [0])[-1] + gr = max(1, (ih - 3) // 2) + put(iy, ix, ("in %-11s total %s" % (human_rate(bi), human(st.total_bin)))[:iw], C_GRN | curses.A_BOLD) + graph(iy + 1, ix, gr, iw, st.bin_hist, hi=scale(st.bin_hist, 2048), color=C_GRN) + sep(iy + 1 + gr, ix, iw) + put(iy + 2 + gr, ix, ("out %-11s total %s" % (human_rate(bo), human(st.total_bout)))[:iw], C_YEL | curses.A_BOLD) + graph(iy + 3 + gr, ix, ih - 3 - gr, iw, st.bout_hist, hi=scale(st.bout_hist, 2048), color=C_YEL) + + # MESSAGES + iy, ix, ih, iw = box(y, xs[3], ah, widths[3], "MESSAGES IRC lines/s") + mi = (list(st.in_hist) or [0])[-1]; mo = (list(st.out_hist) or [0])[-1] + put(iy, ix, ("received %4d/s total %d" % (mi, st.total_in))[:iw], C_GRN | curses.A_BOLD) + graph(iy + 1, ix, gr, iw, st.in_hist, hi=scale(st.in_hist, 20), color=C_GRN) + sep(iy + 1 + gr, ix, iw) + put(iy + 2 + gr, ix, ("sent %4d/s total %d" % (mo, st.total_out))[:iw], C_YEL | curses.A_BOLD) + graph(iy + 3 + gr, ix, ih - 3 - gr, iw, st.out_hist, hi=scale(st.out_hist, 20), color=C_YEL) + + # ---- panel row B: SYSCALLS | CONNECTIONS | BOT / DCC ------------------------ + y += ah + bh = ah + sw = w // 3 + bws = [sw, sw, w - 2 * sw]; bxs = [0, sw, 2 * sw] + + src = st.sys_source + title = "SYSCALLS per second (%s)" % ("live, strace" if src == "strace" else "counted from the server log") + iy, ix, ih, iw = box(y, bxs[0], bh, bws[0], title, C_MAG) + col = max(10, iw // 4) + for i, k in enumerate(KEYS): + cur = (list(st.sys_hist[k]) or [0])[-1] + xx = ix + i * col + if xx + 6 > ix + iw: + break + cw_ = min(col - 1, ix + iw - xx) + put(iy, xx, ("%-6s" % k), C_MAG | curses.A_BOLD) + put(iy + 1, xx, ("%5d/s" % cur)[:cw_], curses.A_BOLD) + if ih >= 7: + put(iy + 2, xx, ("total %d" % st.sys_total[k])[:cw_], DIM) + graph(iy + 4, xx, ih - 5, cw_, st.sys_hist[k], hi=scale(st.sys_hist[k], 20), color=C_MAG) + else: + graph(iy + 3, xx, ih - 4, cw_, st.sys_hist[k], hi=scale(st.sys_hist[k], 20), color=C_MAG) + sep(iy + (3 if ih >= 7 else 2), ix, iw) + note = st.sys_note + if src != "strace": + note = "recv/send = logged lines, accept = logged connections; poll() itself is not visible without strace (Linux)" + put(iy + ih - 1, ix, note[:iw], DIM) + + iy, ix, ih, iw = box(y, bxs[1], bh, bws[1], "CONNECTIONS", C_BLU) + listeners = max(0, st.socks - len(clients)) if st.socks >= 0 else -1 + dcc = 0 + if st.offer and st.offer.status in ("offered", "accepted, waiting for the receiver to connect", "transferring"): + dcc = 1 if st.offer.status == "offered" else 2 + txt = "open %d accepted %d closed %d " % (len(clients), st.accepts, st.closes) + put(iy, ix, txt[:iw], curses.A_BOLD) + if stalled: + put(iy, ix + len(txt), (" %d STALLED " % len(stalled))[: max(0, iw - len(txt))], C_RED | curses.A_BOLD | curses.A_REVERSE) + else: + put(iy, ix + len(txt), "stalled 0"[: max(0, iw - len(txt))], C_GRN) + if listeners >= 0: + extra = listeners - dcc + put(iy + 1, ix, ("poll set: %d sockets = %d listener%s + %d clients%s" % ( + st.socks, max(0, extra), "" if extra == 1 else "s", len(clients), + (" + %d transfer" % dcc) if dcc else ""))[:iw], C_BLU) + else: + put(iy + 1, ix, "poll set: ? (no /proc or lsof)", DIM) + sep(iy + 2, ix, iw) + pm = st.probe_ms + lat = ("latency %s (a registered client PINGs every second)" % ( + ("%.1f ms" % pm) if pm is not None else "no PONG within 3 s")) + lat_attr = (C_RED | curses.A_BOLD) if pm is None else (grad(min(1.0, pm / 200.0)) | curses.A_BOLD) + if ih >= 8: + cg = max(1, (ih - 6) // 2) + put(iy + 3, ix, ("open connections over time (scale %d)" % scale(st.conn_hist, 4))[:iw], DIM) + graph(iy + 4, ix, cg, iw, st.conn_hist, hi=scale(st.conn_hist, 4), color=C_BLU) + sep(iy + 4 + cg, ix, iw) + put(iy + 5 + cg, ix, lat[:iw], lat_attr) + graph(iy + 6 + cg, ix, ih - 6 - cg, iw, st.probe_hist, hi=scale(st.probe_hist, 20)) + else: # compact: latency line + one graph + put(iy + 3, ix, lat[:iw], lat_attr) + graph(iy + 4, ix, ih - 4, iw, st.probe_hist, hi=scale(st.probe_hist, 20)) + + iy, ix, ih, iw = box(y, bxs[2], bh, bws[2], "BOT / FILE TRANSFER", C_GRN) + if not st.bot_nick and not st.offer: + put(iy, ix, "no bot seen yet", DIM) + put(iy + 1, ix, "launch ircserv_bonus and send, from a client:", DIM) + put(iy + 2, ix, " PRIVMSG :!help", DIM) + else: + put(iy, ix, ("bot %s commands %d replies %d ctcp %d" % (st.bot_nick or "?", st.bot_cmds, st.bot_replies, st.ctcp))[:iw], curses.A_BOLD) + put(iy + 1, ix, ("last command %s" % st.bot_last)[:iw], DIM) + sep(iy + 2, ix, iw) + o = st.offer + if o: + put(iy + 3, ix, ("offer %s %s %s %s (%s) port %d" % (o.frm, G["arrow"], o.to, o.file, human(o.size), o.port))[:iw], C_GRN) + stt = o.status + colr = C_GRN if stt == "complete" else (C_RED if stt in ("rejected", "failed") else C_YEL) + dur = ((o.t1 or time.time()) - o.t0) + stl = "status %s %.1f s" % (stt, dur) + put(iy + 4, ix, stl[:iw], colr | curses.A_BOLD) + ry, rx = (iy + 5, ix) if ih >= 7 else (iy + 4, ix + len(stl) + 3) # compact: same row + rw = iw - (rx - ix) + if stt == "transferring" or o.sent: + frac = min(1.0, o.sent / float(o.size)) if (o.size and o.sent) else 0.0 + if o.sent: + lbl = "sent %s / %s " % (human(o.sent), human(o.size)) + put(ry, rx, lbl[:rw], C_GRN) + put(ry, rx + len(lbl), (meter(max(4, rw - len(lbl) - 6), frac) + " %3d%%" % int(frac * 100))[: max(0, rw - len(lbl))], C_GRN) + else: + put(ry, rx, ("kernel queue on the transfer socket %s (byte count needs strace)" % human(st.dcc_sendq))[:rw], C_YEL) + elif stt == "complete": + put(ry, rx, ("%s delivered in %.1f s (%s)" % (human(o.size), dur, human_rate(o.size / dur if dur else 0)))[:rw], C_GRN) + if ih >= 8: + put(iy + ih - 1, ix, ("offers finished %d" % st.offers_done)[:iw], DIM) + y += bh + + if opts["graphs"]: + draw_panels() + else: + put(2, 1, "graphs hidden (menu: graphs on)", DIM); y = 3 + + # ---- clients | channels --------------------------------------------------------- + rest = h - y - 1 + ch_rows = clamp_rows(max(5, min(16, int(rest * 0.5))) + opts["client_rows"], 4, max(4, rest - 4)) + left_w = max(60, int(w * 0.64)) + iy, ix, ih, iw = box(y, 0, ch_rows, left_w, "CLIENTS %d sorted by %s" % (len(clients), SORTS[opts["sort"]]), C_CYN, focus == 0) + put(iy, ix, "%-4s %-12s %-11s %8s %8s %9s %11s %5s %s" % ( + "fd", "nick", "state", "lines in", "lines out", "bytes out", "unread by it", "idle", "channels"), curses.A_BOLD) + sep(iy + 1, ix, iw) + body = ih - 2 + if len(clients) > body: + body = max(1, ih - 3) + client_scroll = max(0, min(client_scroll, max(0, len(clients) - body))) + now = time.time() + for i, c in enumerate(clients[client_scroll: client_scroll + body]): + is_stalled = c in stalled + attr = C_RED | curses.A_BOLD if is_stalled else (C_YEL if c.sendq > 0 else 0) + state = "stalled" if is_stalled else ("registered" if c.registered else "connecting") + row = "%-4s %-12s %-11s %8d %8d %9s %11s %4ds %s" % ( + c.fd, (c.nick or "-")[:12], state, c.msgs_in, c.msgs_out, + human(c.bytes_out), human(c.sendq), int(now - c.last), ",".join(sorted(c.chans))) + put(iy + 2 + i, ix, row[hscroll[0]: hscroll[0] + iw].ljust(iw), attr) + if len(clients) > body: + put(iy + ih - 1, ix, ("%s %d/%d shown, Tab to CLIENTS then %s" % (G["more"], min(body, len(clients) - client_scroll), len(clients), G["updown"]))[:iw], DIM) + iy, ix, ih, iw = box(y, left_w, ch_rows, w - left_w, "CHANNELS %d" % len(chans), C_CYN) + put(iy, ix, "%-14s %7s %s" % ("channel", "members", "who"), curses.A_BOLD) + sep(iy + 1, ix, iw) + for i, ch in enumerate(sorted(chans)[: ih - 2]): + put(iy + 2 + i, ix, ("%-14s %7d %s" % (ch, len(chans[ch]), " ".join(sorted(chans[ch]))))[:iw]) + if st.mode == "attach": + put(iy + 2, ix, "(attach mode: no server log, so no nicks/channels)"[:iw], C_YEL) + + # ---- server log ----------------------------------------------------------------- + y += ch_rows + with st.lock: + entries = list(st.log) + lh = h - y - 1 + iy, ix, ih, iw = box(y, 0, lh, w, "SERVER LOG %d lines%s" % ( + len(entries), " [paused]" if st.paused else ("" if opts["follow"] and st.scroll == 0 else " [scrolled, End to follow]")), C_CYN, focus == 1) + if ih > 0: + if opts["follow"] and focus != 1: + st.scroll = 0 + end = len(entries) - st.scroll + for i, (t, kind, text) in enumerate(entries[max(0, end - ih): max(0, end)]): + attr = {"in": C_GRN, "out": C_YEL, "inbot": C_GRN | curses.A_BOLD, "outbot": C_MAG | curses.A_BOLD, + "err": C_RED | curses.A_BOLD, "conn": C_CYN}.get(kind, 0) + put(iy + i, ix, ("%s %s" % (time.strftime("%H:%M:%S", time.localtime(t)), text))[hscroll[1]: hscroll[1] + iw].ljust(iw), attr) + + foot = (" q quit Tab cycle focus (clients / log / menu) %s scroll %s scroll sideways End follow log t text snapshot " + "'unread by it' = bytes in the kernel that this client has not read yet (red = stalled)" % (G["updown"], G["lr"])) + if hint: + foot = " " + hint + put(h - 1, 0, foot.ljust(w - 1), curses.A_REVERSE) + stdscr.refresh() + + # ---- keys ------------------------------------------------------------------------- + try: + k = stdscr.getch() + except curses.error: + k = -1 + if k == -1: + continue + hint = "" + if focus == 2: # menu bar + key, label, kind = MENU[menu_i] + if k in (curses.KEY_LEFT, ord("h")): + menu_i = (menu_i - 1) % len(MENU) + elif k in (curses.KEY_RIGHT, ord("l"), 9): + menu_i = (menu_i + 1) % len(MENU) + if k == 9 and menu_i == 0: + focus = 0 + elif k in (ord("b"), ord("B"), 27): + focus = 1 + elif k in (ord("q"), ord("Q")): + break + elif k in (10, 13, curses.KEY_ENTER, curses.KEY_UP, curses.KEY_DOWN, ord(" ")): + step = -1 if k == curses.KEY_DOWN else 1 + if key == "sort": + opts["sort"] = (opts["sort"] + step) % 3 + elif key == "graphs": + opts["graphs"] = not opts["graphs"] + elif key == "graph_rows": + opts["graph_rows"] = max(-4, min(8, opts["graph_rows"] + step)) + elif key == "client_rows": + opts["client_rows"] = max(-6, min(12, opts["client_rows"] + step)) + elif key == "follow": + opts["follow"] = not opts["follow"] + if opts["follow"]: + st.scroll = 0 + elif key == "glyphs": + opts["glyphs"] = "ascii" if opts["glyphs"] == "unicode" else "unicode" + elif key == "snapshot": + sys.__stdout__.write(snapshot_text(st) + "\n"); sys.__stdout__.flush() + hint = "snapshot written to the terminal behind irctop (visible after q)" + elif key == "quit": + break + continue + if k in (ord("q"), ord("Q")): + break + elif k == 9: + focus = {0: 1, 1: 2}[focus] + elif k == curses.KEY_BTAB: + focus = {0: 2, 1: 0}[focus] + elif k in (curses.KEY_UP, ord("k")): + if focus == 1: + st.scroll = min(len(entries), st.scroll + 1); opts["follow"] = False + else: + client_scroll = max(0, client_scroll - 1) + elif k in (curses.KEY_DOWN, ord("j")): + if focus == 1: + st.scroll = max(0, st.scroll - 1); opts["follow"] = (st.scroll == 0) + else: + client_scroll += 1 + elif k in (curses.KEY_LEFT, curses.KEY_RIGHT) and focus in (0, 1): + hscroll[focus] = max(0, hscroll[focus] + (8 if k == curses.KEY_RIGHT else -8)) + elif k in (curses.KEY_PPAGE, curses.KEY_NPAGE): + if focus == 1: + st.scroll = max(0, min(len(entries), st.scroll + (10 if k == curses.KEY_PPAGE else -10))); opts["follow"] = (st.scroll == 0) + else: + client_scroll = max(0, client_scroll + (-10 if k == curses.KEY_PPAGE else 10)) + elif k in (curses.KEY_END, ord("G")): + st.scroll = 0; opts["follow"] = True; hscroll[1] = 0 + elif k in (ord("t"), ord("T")): + sys.__stdout__.write(snapshot_text(st) + "\n"); sys.__stdout__.flush() + hint = "snapshot written to the terminal behind irctop (visible after q)" + elif k in (ord("c"), ord("C")): + opts["sort"] = (opts["sort"] + 1) % 3 + elif k in (ord("p"), ord("P")): + opts["follow"] = not opts["follow"] + elif k in (ord("g"),): + opts["graphs"] = not opts["graphs"] + elif k == ord("-"): + opts["graph_rows"] = max(-4, opts["graph_rows"] - 1) + elif k in (ord("="), ord("+")): + opts["graph_rows"] = min(8, opts["graph_rows"] + 1) + elif k == ord("["): + opts["client_rows"] = max(-6, opts["client_rows"] - 1) + elif k == ord("]"): + opts["client_rows"] = min(12, opts["client_rows"] + 1) + elif k == curses.KEY_RESIZE: + pass + + +# =========================================================================== +# main +# =========================================================================== + +def main(): + p = argparse.ArgumentParser(description="btop-style live dashboard for ircserv.") + g = p.add_mutually_exclusive_group(required=True) + g.add_argument("--start", nargs=3, metavar=("BINARY", "PORT", "PASSWORD"), + help="launch the server and read its log live (full picture)") + g.add_argument("--attach", nargs=2, metavar=("PORT", "PASSWORD"), + help="monitor a server that is already running (external view only)") + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--text", type=int, default=0, metavar="N", + help="print N plain-text snapshots one second apart instead of the curses UI") + p.add_argument("--no-probe", action="store_true", help="don't keep a probe client connected") + p.add_argument("--no-strace", action="store_true", help="never attach strace (Linux)") + p.add_argument("--ascii", action="store_true", help="draw with plain ASCII (auto when curses lacks wide-char support)") + p.add_argument("--unicode", action="store_true", help="force the unicode glyphs") + a = p.parse_args() + + st = State() + launched = None + if a.start: + binary, port, password = a.start[0], int(a.start[1]), a.start[2] + if not os.path.isfile(binary): + here = os.path.dirname(os.path.abspath(__file__)) + for cand in (os.path.join(here, "..", "..", os.path.basename(binary)), os.path.join(os.getcwd(), os.path.basename(binary))): + if os.path.isfile(cand): + binary = os.path.abspath(cand); break + else: + print("binary not found: %s\n run from tests/tester: python3 irctop.py --start ../../ircserv 6667 pass\n" + " or from the repo root: python3 tests/tester/irctop.py --start ./ircserv 6667 pass" % binary); sys.exit(1) + st.mode = "launch"; st.port = port + try: + st.poll_sites = scan_poll_sites(binary) + except Exception: + st.poll_sites = None + launched = Launched(st, binary, port, password) + t0 = time.time() + while time.time() - t0 < 6: + try: + socket.create_connection((a.host, port), timeout=0.5).close(); break + except OSError: + if not launched.alive(): + print("server exited during startup; last log lines:") + for (_, _, txt) in list(st.log)[-8:]: + print(" " + txt) + sys.exit(1) + time.sleep(0.1) + else: + port, password = int(a.attach[0]), a.attach[1] + st.mode = "attach"; st.port = port + st.pid = find_pid_on_port(port) + if st.pid is None: + st.feed("[irctop] nothing on port %d yet, waiting (start the server or the tester now)" % port) + else: + st.feed("[irctop] attached to pid %d on port %d -- external view: CPU/RSS, kernel SendQ per connection, probe latency" % (st.pid, port)) + + sampler = Sampler(st); sampler.start() + probe = None + if not a.no_probe: + probe = Probe(st, a.host, port, password); probe.start() + tracer = None + if sys.platform.startswith("linux") and not a.no_strace and st.pid: + tracer = Strace(st, st.pid); tracer.start() + elif sys.platform == "darwin": + st.sys_note = "macOS: syscall counts need root (dtrace); showing log-derived counts" + + try: + if a.text: + for i in range(a.text): + time.sleep(1) + print(snapshot_text(st)); print() + elif not HAVE_CURSES or not sys.stdout.isatty(): + print("no curses/tty available; use --text N for snapshots") + else: + curses.wrapper(ui, st, launched, sampler, probe, "ascii" if a.ascii else ("unicode" if a.unicode else "auto")) + except KeyboardInterrupt: + pass + finally: + sampler.stop = True + if probe: + probe.stop = True + if tracer: + tracer.end() + if launched: + launched.stop() + + +if __name__ == "__main__": + main() diff --git a/tests/dynamic_tester/test_irc.py b/tests/dynamic_tester/test_irc.py new file mode 100644 index 0000000..7489e62 --- /dev/null +++ b/tests/dynamic_tester/test_irc.py @@ -0,0 +1,3674 @@ +#!/usr/bin/env python3 +""" +ft_irc tester -- an inspectable, evaluator-facing test harness for 42 ft_irc servers. + +Give this folder to anyone doing the project. It drives a server over real TCP, +checks it against the subject + RFC 2812, and records everything so you can SEE +what happened: every line each client sent/received, the server's own log per +scenario, and for each check what it tests, why, expected vs. actual, and how to +fix it. Statuses: + + PASS (green) required behaviour works + FAIL (red) a real problem + NOTE (amber) NOT a failure: an optional RFC extra, or a design choice the + subject leaves open -- shown with your choice and the alternatives + SKIP (grey) could not run (e.g. no bonus binary) + +Implementation profile +---------------------- +The subject leaves several things open (line endings, over-long lines, what to do +with a client that stops reading, ...). Those are NOT judged; they are described +by a PROFILE you can set to match your design, so the tester checks what you +intended rather than guessing: + + python3 test_irc.py --show-config # every option, default, meaning + python3 test_irc.py --set slow_client=drop # override one + (or put key=value lines in tests/tester/tester.conf, or use the menu) + +Modes +----- + mandatory subject command set + numerics + robustness framing, line limits, slow readers, churn, many clients + load slow-reader load test: N clients that read 1 byte at a time + bonus DCC bot + real file transfer (needs the bonus binary) + compare with-bot vs no-bot capture saved to files + all everything + terminal manual raw IRC terminal +Add --live to watch the server window and the clients' windows move while it runs. + +Design notes +------------ +* The server is launched on a pseudo-terminal so its stdout stays line-buffered + and the "server screen" can be captured per scenario in real time. +* Each launched server gets a fresh free port, so phases never race each other + for a port, and the tester waits until the server actually accepts. +* stdlib only. macOS and Linux. +""" + +import argparse +import atexit +import collections +import signal +import datetime +import html +import io +import os +import re +import shlex +import select +import socket +import subprocess +import sys +import threading +import time + +try: + import pty + HAVE_PTY = True +except ImportError: + HAVE_PTY = False + +try: + import curses + HAVE_CURSES = True +except ImportError: + HAVE_CURSES = False + +# helpers shared with irctop.py when it sits next to this file +try: + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from irctop import kernel_sendq, ps_stats, spark as _spark, fd_counts, State as MonState +except Exception: + MonState = None + + def fd_counts(pid): + return -1, -1 + # standalone fallbacks + def kernel_sendq(port): + return {} + + def ps_stats(pid): + try: + out = subprocess.check_output(["ps", "-o", "rss=,%cpu=", "-p", str(pid)], + stderr=subprocess.DEVNULL).decode().split() + return int(out[0]) // 1024, float(out[1]) + except Exception: + return 0, 0.0 + + def _spark(values, width): + vals = list(values)[-width:] + if not vals: + return "" + hi = max(vals) or 1 + return "".join("▁▂▃▄▅▆▇█"[min(7, int(v * 7 / hi))] for v in vals) + + +# =========================================================================== +# Implementation profile (non-mandatory design choices) +# =========================================================================== + +# key, default, kind (list of choices | int | float | str), description +PROFILE_SPEC = [ + ("line_endings", "lf_ok", ["lf_ok", "crlf_only"], + "Accept a bare \\n as a line ending (lf_ok, what nc/evaluators send) or require \\r\\n only."), + ("max_line", 510, int, + "Max message content in bytes, NOT counting CRLF. RFC 2812: 510 (512 with CRLF)."), + ("overlong", "reject", ["reject", "truncate", "ignore"], + "What an over-long line gets: reject (417 ERR_INPUTTOOLONG or close), truncate to max_line, or ignore silently."), + ("overlong_strikes", 3, int, + "Close the link after N consecutive over-long lines. 0 = never close for this."), + ("slow_client", "keep", ["keep", "drop"], + "A client that never reads: keep it (its queue grows) or drop it when sends fail."), + ("missing_mode_arg", "any", ["461", "specific", "any"], + "Reply to MODE +k/+l/+o with no argument: 461 ERR_NEEDMOREPARAMS, a specific numeric (467/696), or any defined reply."), + ("user_modes", "none", ["none", "supported"], + "Whether user modes (MODE ...) exist. The subject only requires channel modes."), + ("welcome_burst", "yes", ["yes", "no"], + "Whether 002 003 004 005 follow 001 (clients like irssi expect them; the subject doesn't require them)."), + ("casemapping", "rfc1459", ["rfc1459", "ascii", "none"], + "How nicks and channel names are case-folded when compared."), + ("nonmember_send", "reject", ["reject", "deliver"], + "PRIVMSG/NOTICE to a channel you are not in: reject (404, the +n default) or deliver."), + ("bot_nick", "DCCBot", str, "Nick of the bonus bot (bonus only)."), + ("clients", 20, int, "Client count for the many-clients scenario."), + ("slow_clients", 30, int, "Client count for the slow-reader load scenario."), + ("rcvbuf", 2048, int, + "Receive buffer in bytes for stalled/slow test clients. Smaller fills the pipe sooner."), + ("flood_mb", 4, int, "MB to queue toward a stalled client in the flood scenario."), + ("timeout", 4.0, float, "Seconds to wait for a reply in each check."), +] + +PROFILE = {} +PROFILE_SOURCE = {} + + +def profile_defaults(): + return dict((k, d) for (k, d, _, _) in PROFILE_SPEC) + + +def profile_set(key, value, source): + spec = dict((k, (d, kind, desc)) for (k, d, kind, desc) in PROFILE_SPEC) + if key not in spec: + raise ValueError("unknown option %r (see --show-config)" % key) + d, kind, _ = spec[key] + if isinstance(kind, list): + if value not in kind: + raise ValueError("%s must be one of %s" % (key, "/".join(kind))) + PROFILE[key] = value + elif kind is int: + PROFILE[key] = int(value) + elif kind is float: + PROFILE[key] = float(value) + else: + PROFILE[key] = str(value) + PROFILE_SOURCE[key] = source + + +def load_profile(conf_path, sets): + PROFILE.clear(); PROFILE_SOURCE.clear() + PROFILE.update(profile_defaults()) + for k in PROFILE: + PROFILE_SOURCE[k] = "default" + if conf_path and os.path.isfile(conf_path): + for line in open(conf_path): + line = line.split("#", 1)[0].strip() + if not line or "=" not in line: + continue + k, v = [x.strip() for x in line.split("=", 1)] + profile_set(k, v, os.path.basename(conf_path)) + for s in sets or []: + if "=" not in s: + raise ValueError("--set needs key=value, got %r" % s) + k, v = [x.strip() for x in s.split("=", 1)] + profile_set(k, v, "--set") + + +def show_config(): + print("\nImplementation profile -- the subject leaves these open; set them to match YOUR design.\n") + print(" %-18s %-12s %-10s %s" % ("option", "current", "source", "meaning / choices")) + print(" " + "-" * 100) + for (k, d, kind, desc) in PROFILE_SPEC: + cur = PROFILE.get(k, d) + choices = ("[%s]" % "|".join(kind)) if isinstance(kind, list) else ("(%s)" % kind.__name__) + print(" %-18s %-12s %-10s %s %s" % (k, cur, PROFILE_SOURCE.get(k, "default"), desc, choices)) + print("\n set with: --set key=value (repeatable) or key=value lines in tester.conf\n") + + +def configure_interactive(): + show_config() + ans = ask("Edit values? [y/N] ", "n").strip().lower() + if ans not in ("y", "yes"): + return + print("\nEnter = keep current, 'b' = back (keeps what you changed so far). Options in [brackets].\n") + for (k, d, kind, desc) in PROFILE_SPEC: + cur = PROFILE.get(k, d) + choices = ("[%s]" % "|".join(kind)) if isinstance(kind, list) else "" + print(" %s -- %s %s" % (k, desc, choices)) + ans = ask(" %s [%s]: " % (k, cur), "b").strip() + if ans.lower() == "b": + break + if ans: + try: + profile_set(k, ans, "menu") + except ValueError as e: + print(" ignored: %s" % e) + print("\nprofile now: %s\n" % profile_summary()) + + +def profile_summary(): + """Only what differs from the defaults, for banners.""" + d = profile_defaults() + diff = ["%s=%s" % (k, PROFILE[k]) for (k, _, _, _) in PROFILE_SPEC if PROFILE.get(k) != d[k]] + return ", ".join(diff) if diff else "defaults" + + +# =========================================================================== +# Terminal style +# =========================================================================== + +GREEN = "\033[32m"; RED = "\033[31m"; YELLOW = "\033[33m" +CYAN = "\033[36m"; BLUE = "\033[34m"; MAGENTA = "\033[35m" +DIM = "\033[2m"; RESET = "\033[0m"; BOLD = "\033[1m" +ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def strip_ansi(s): + return ANSI_RE.sub("", s) + + +FT_BANNER = r""" + _ _ ____ __ _ _ + | || | |___ \ / _| |_ (_)_ __ ___ + | || |_ __) | | |_| __| | | '__/ __| + |__ _|/ __/ | _| |_ | | | | (__ + |_| |_____| |_| \__| |_|_| \___| tester +""" + + +# =========================================================================== +# Result model +# =========================================================================== + +PASS = "PASS"; FAIL = "FAIL"; SKIP = "SKIP"; NOTE = "NOTE"; INFO = "INFO" +STATUS_COLOR = {PASS: GREEN, FAIL: RED, SKIP: DIM, NOTE: YELLOW, INFO: CYAN} + + +class Check(object): + __slots__ = ("name", "status", "what", "why", "expect", "got", "fix", "dur_ms") + + def __init__(self, name, status, what="", why="", expect="", got="", fix="", dur_ms=0.0): + self.name = name; self.status = status; self.what = what; self.why = why + self.expect = expect; self.got = got; self.fix = fix; self.dur_ms = dur_ms + + +class Scenario(object): + def __init__(self, key, title, group, blurb): + self.key = key; self.title = title; self.group = group; self.blurb = blurb + self.checks = [] + self.events = [] # (t, label, direction, line) + self.server_lines = [] # (t, text) + self.t0 = None; self.t1 = None + self.metrics = [] + self.state = "pending" # pending / running / done + + def counts(self): + c = {PASS: 0, FAIL: 0, SKIP: 0, NOTE: 0, INFO: 0} + for ch in self.checks: + c[ch.status] = c.get(ch.status, 0) + 1 + return c + + +class Report(object): + def __init__(self, args): + self.args = args + self.scenarios = [] + self.current = None + self.server = None + self.server_cwd = os.getcwd() + self.t_start = time.time() + self.lock = threading.Lock() + self.planned = [] # (key, title, group) for the live scenario list + self.step = False # live view: pause after every scenario until told to go on + self.waiting = False + self.go = threading.Event() + + def begin(self, key, title, group, blurb): + sc = Scenario(key, title, group, blurb) + sc.t0 = time.time(); sc.state = "running" + with self.lock: + self.scenarios.append(sc) + self.current = sc + PENDING.clear() + print("\n%s%s[ %s ]%s %s%s%s" % (BOLD, CYAN, title, RESET, DIM, blurb, RESET)) + return sc + + def end(self): + sc = self.current + if sc is None: + return + sc.t1 = time.time() + if self.server is not None: + time.sleep(0.05) + sc.server_lines = self.server.slice(sc.t0, time.time()) + sc.state = "done" + with self.lock: + self.current = None + if self.step: # live view step mode: hold here until the viewer says next / continue + self.go.clear(); self.waiting = True + self.go.wait(); self.waiting = False + + def record(self, name, status, what="", why="", expect="", got="", fix="", dur_ms=0.0): + ch = Check(name, status, what, why, expect, got, fix, dur_ms) + if self.current is not None: + self.current.checks.append(ch) + col = STATUS_COLOR.get(status, "") + print(" %s[%s]%s %s" % (col, status, RESET, name)) + if status == FAIL: + for l in strip_ansi(got or "").splitlines()[-4:]: + print(" %s%s%s" % (YELLOW, l, RESET)) + if fix: + print(" %s-> %s%s" % (DIM, fix, RESET)) + return ch + + def event(self, t, label, direction, line): + if self.current is not None: + self.current.events.append((t, label, direction, line)) + + def metric(self, label, value): + if self.current is not None: + self.current.metrics.append((label, value)) + print(" %s%s: %s%s" % (DIM, label, value, RESET)) + + def totals(self): + c = {PASS: 0, FAIL: 0, SKIP: 0, NOTE: 0, INFO: 0} + for sc in self.scenarios: + for k, v in sc.counts().items(): + c[k] += v + return c + + +REPORT = None +RUNNING_SERVERS = [] +PENDING = {} +LABELS = {} +T0 = time.time() +TIMEOUT = 4.0 +PASSWORD = "pass" + + +# =========================================================================== +# Wire logging +# =========================================================================== + +def wire(s, direction, text): + label = LABELS.get(s, "fd?") + for line in text.replace("\r", "").split("\n"): + if line == "": + continue + if REPORT is not None: + REPORT.event(time.time(), label, direction, line) + if REPORT is not None and REPORT.args.debug: + col = CYAN if direction == "<<" else (YELLOW if direction == ">>" else DIM) + print(" %s%7.3f %-12s %s %s%s" % (DIM, time.time() - T0, label, direction, col + line, RESET)) + + +# =========================================================================== +# Server process on a pty +# =========================================================================== + +class ServerProc(object): + def __init__(self, binary, port, password, logpath, cwd=None): + self.binary = binary; self.port = port; self.password = password + self.logpath = logpath; self.cwd = cwd + self.proc = None; self.buffer = []; self.lock = threading.Lock() + self.master = None + self.logf = open(logpath, "w") + RUNNING_SERVERS.append(self) + self.msgs_in = collections.deque(maxlen=120) # per-second counts for live view + self.msgs_out = collections.deque(maxlen=120) + self._cin = 0; self._cout = 0; self._last_tick = time.time() + self._start() + + def _start(self): + argv = [self.binary, str(self.port), self.password] + if HAVE_PTY: + self.master, slave = pty.openpty() + self.proc = subprocess.Popen(argv, stdout=slave, stderr=slave, cwd=self.cwd, close_fds=True) + os.close(slave) + threading.Thread(target=self._pump_pty, daemon=True).start() + else: + self.proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=self.cwd) + threading.Thread(target=self._pump_pipe, daemon=True).start() + + def _append(self, raw_line): + t = time.time() + clean = strip_ansi(raw_line.rstrip("\r\n")) + low = clean.lower() + with self.lock: + self.buffer.append((t, clean)) + if "from fd" in low: + self._cin += 1 + elif "sendto fd" in low: + self._cout += 1 + if t - self._last_tick >= 1.0: + self.msgs_in.append(self._cin); self.msgs_out.append(self._cout) + self._cin = 0; self._cout = 0; self._last_tick = t + self.logf.write(raw_line if raw_line.endswith("\n") else raw_line + "\n") + self.logf.flush() + + def _pump_pty(self): + buf = b"" + while True: + try: + data = os.read(self.master, 4096) + except OSError: + break + if not data: + break + buf += data + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + self._append(line.decode(errors="ignore")) + if buf: + self._append(buf.decode(errors="ignore")) + + def _pump_pipe(self): + for line in iter(self.proc.stdout.readline, b""): + self._append(line.decode(errors="ignore")) + + def alive(self): + return self.proc is not None and self.proc.poll() is None + + def slice(self, t0, t1): + with self.lock: + return [(t, txt) for (t, txt) in self.buffer if t0 <= t <= t1] + + def tail(self, n=40): + with self.lock: + return [txt for (_, txt) in self.buffer[-n:]] + + def stop(self): + if self in RUNNING_SERVERS: + RUNNING_SERVERS.remove(self) + if self.proc is None: + return + try: + self.proc.terminate(); self.proc.wait(timeout=5) + except Exception: + try: + self.proc.kill(); self.proc.wait(timeout=3) + except Exception: + pass + try: + if self.master is not None: + os.close(self.master) + except OSError: + pass + try: + self.logf.flush(); self.logf.close() + except Exception: + pass + + +# =========================================================================== +# Socket helpers +# =========================================================================== + +def can_connect(host, port): + try: + socket.create_connection((host, port), timeout=1.5).close() + return True + except OSError: + return False + + +def find_free_port(host): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind((host, 0)); return s.getsockname()[1] + finally: + s.close() + + +def connect(rcvbuf=None): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if rcvbuf: + s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf) + s.settimeout(3) + s.connect((REPORT.args.host, REPORT.args.port)) + s.setblocking(False) + return s + + +def send(s, line): + wire(s, ">>", line) + s.sendall((line.rstrip("\r\n") + "\r\n").encode()) + + +def send_raw(s, data): + shown = data.decode(errors="ignore").replace("\r", "\\r").replace("\n", "\\n") + wire(s, ">>", shown) + s.sendall(data) + + +def _read_more(s, wait): + r, _, _ = select.select([s], [], [], wait) + if s not in r: + return True + try: + chunk = s.recv(4096) + except socket.error: + return False + if not chunk: + return False + text = chunk.decode(errors="ignore") + wire(s, "<<", text) + PENDING[s] = PENDING.get(s, "") + text + return True + + +def _take_through(s, needle): + buf = PENDING.get(s, "") + pos = buf.find(needle) + if pos < 0: + return None + end = buf.find("\n", pos) + end = len(buf) if end < 0 else end + 1 + PENDING[s] = buf[end:] + return buf[:end] + + +def recv_until(s, needle, timeout=None): + timeout = TIMEOUT if timeout is None else timeout + end = time.time() + timeout + while True: + got = _take_through(s, needle) + if got is not None: + return True, got + left = end - time.time() + if left <= 0: + break + if not _read_more(s, min(left, 0.2)): + break + return False, PENDING.get(s, "") + + +def recv_any(s, needles, timeout=None): + timeout = TIMEOUT if timeout is None else timeout + end = time.time() + timeout + while True: + for needle in needles: + got = _take_through(s, needle) + if got is not None: + return True, got + left = end - time.time() + if left <= 0: + break + if not _read_more(s, min(left, 0.2)): + break + return False, PENDING.get(s, "") + + +def drain(s, timeout=0.4): + end = time.time() + timeout + while time.time() < end: + before = len(PENDING.get(s, "")) + if not _read_more(s, max(0.01, end - time.time())): + break + if len(PENDING.get(s, "")) == before: + break + return PENDING.pop(s, "") + + +def closed(s, wait=0.4): + end = time.time() + wait + while time.time() < end: + r, _, _ = select.select([s], [], [], max(0.01, end - time.time())) + if s in r: + try: + if s.recv(1) == b"": + return True + except (BlockingIOError, socket.error): + return False + return False + + +def register(s, nick, realname=None): + realname = realname or nick + LABELS[s] = nick + send(s, "PASS %s" % PASSWORD); send(s, "NICK %s" % nick); send(s, "USER %s 0 * :%s" % (nick, realname)) + return recv_until(s, "001 %s" % nick, timeout=3) + + +def safe_close(*sockets): + for s in sockets: + PENDING.pop(s, None); LABELS.pop(s, None) + try: + s.close() + except OSError: + pass + + +def server_rss_mb(): + srv = REPORT.server + if srv is None or getattr(srv, "proc", None) is None: + return -1 + return ps_stats(srv.proc.pid)[0] + + + +def _kill_servers(): + """atexit / signal safety net: never leave a launched ircserv behind.""" + for srv in list(RUNNING_SERVERS): + try: + srv.stop() + except Exception: + pass + + +def _on_signal(signum, frame): + # SIGTERM/SIGHUP behave like Ctrl-C: unwind through the finally blocks, which stop the + # server, restore the terminal and still write the report for what has run. + raise KeyboardInterrupt() + + +def ask(prompt, default=""): + """input() that treats Ctrl-D (EOF) as 'use the default' instead of crashing.""" + try: + return input(prompt) + except EOFError: + print() + return default + + +# =========================================================================== +# Check helpers +# =========================================================================== + +def ok_fail(name, ok, what, why, expect, got, fix): + REPORT.record(name, PASS if ok else FAIL, what=what, why=why, expect=expect, + got=strip_ansi(got or "").strip(), fix=fix if not ok else "") + + +def note(name, what, why, expect, got, note_text): + """A design choice or optional extra: never a failure.""" + REPORT.record(name, NOTE, what=what, why=why, expect=expect, + got=strip_ansi(got or "").strip(), fix=note_text) + + +def subset(name, strict_ok, subset_ok, what, why, expect, got, note_text): + if strict_ok: + REPORT.record(name, PASS, what=what, why=why, expect=expect, got=strip_ansi(got or "").strip()) + elif subset_ok: + note(name, what, why, expect, got, note_text) + else: + REPORT.record(name, FAIL, what=what, why=why, expect=expect, got=strip_ansi(got or "").strip(), + fix="Neither the required nor an acceptable alternative response was seen.") + + +def skip(name, reason): + REPORT.record(name, SKIP, what=reason) + + + +# =========================================================================== +# DESIGN: one poll() for everything, non-blocking fds, no fork +# =========================================================================== + +def _strip_c_comments(text): + text = re.sub(r"/\*.*?\*/", lambda m: " " * m.group(0).count("\n") * 0 + "\n" * m.group(0).count("\n"), text, flags=re.S) + return re.sub(r"//[^\n]*", "", text) + + +def _source_files(root): + dirs = [d for d in (os.path.join(root, "srcs"), os.path.join(root, "src"), os.path.join(root, "includes"), + os.path.join(root, "inc"), os.path.join(root, "include")) if os.path.isdir(d)] or [root] + out = [] + for d in dirs: + for base, _, names in os.walk(d): + if any(x in base for x in ("/tests", "/test", "/build", "/.git")): + continue + for n in names: + if n.endswith((".cpp", ".hpp", ".cc", ".h", ".c", ".tpp")): + out.append(os.path.join(base, n)) + return sorted(out) + + +def _find_calls(files, pattern, root, include_bonus=True): + """Call sites matching pattern as 'file:line'. Tracks #ifdef BONUS / #if defined(BONUS) + regions so the mandatory build can be judged without bonus-only code.""" + rx = re.compile(pattern) + hits = []; bonus_hits = [] + for f in files: + try: + text = _strip_c_comments(open(f, errors="ignore").read()) + except OSError: + continue + stack = [] # True for a BONUS region, False for any other #if + for i, line in enumerate(text.split("\n"), 1): + st = line.strip() + if st.startswith("#if"): + stack.append(bool(re.search(r"\bBONUS\b", st))) + elif st.startswith("#endif"): + if stack: + stack.pop() + elif st.startswith("#else") and stack: + stack[-1] = not stack[-1] if stack[-1] else stack[-1] + if rx.search(line): + loc = "%s:%d" % (os.path.relpath(f, root), i) + if any(stack): + bonus_hits.append(loc) + else: + hits.append(loc) + return (hits + bonus_hits) if include_bonus else hits + + +def _threads_and_children(pid): + threads = -1; children = -1 + try: + for line in open("/proc/%d/status" % pid): + if line.startswith("Threads:"): + threads = int(line.split()[1]); break + except OSError: + try: + out = subprocess.check_output(["ps", "-M", "-p", str(pid)], stderr=subprocess.DEVNULL).decode().splitlines() + threads = max(0, len(out) - 1) + except Exception: + pass + try: + out = subprocess.run(["pgrep", "-P", str(pid)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout.decode().split() + children = len(out) + except Exception: + pass + return threads, children + + +def sc_single_poll(): + REPORT.begin("poll", "One poll() for everything, non-blocking, no fork (subject core rule)", "Mandatory", + "The subject: a single poll() (or equivalent) handles every read and write, fds are " + "non-blocking, no forking, and errno must not drive decisions after read/write. " + "Checked in the sources next to the binary and on the running process.") + root = os.path.dirname(os.path.abspath(REPORT.args.binary)) + files = _source_files(root) + if not files: + skip("Source scan", "no .cpp/.hpp files found under %s (only the running-process checks apply)" % root) + else: + REPORT.metric("Source files scanned", "%d under %s" % (len(files), root)) + polls_all = _find_calls(files, r"(? stop or close) instead of errno.") + # running-process evidence + srv = REPORT.server + if srv is not None and srv.proc is not None: + a = connect(); register(a, "pl_a"); b = connect(); register(b, "pl_b") + send(a, "JOIN #pl"); send(b, "JOIN #pl"); time.sleep(0.2) + for i in range(30): + send(a, "PRIVMSG #pl :tick %d" % i) + drain(b, 0.3) + thr, kids = _threads_and_children(srv.proc.pid) + ok_fail("Runs as a single thread with no child processes (while serving 2 clients)", + (thr in (1, -1)) and (kids in (0, -1)) and not (thr == -1 and kids == -1), + "Count threads and children of the server pid under traffic.", + "One process, one thread, one poll loop is the design the subject asks for.", + "threads=1, children=0.", "threads=%s children=%s" % (thr if thr >= 0 else "?", kids if kids >= 0 else "?"), + "Extra threads or children mean concurrency is not coming from the single poll().") + # optional: syscall census on Linux with strace + if sys.platform.startswith("linux") and not REPORT.args.no_start: + try: + st = subprocess.Popen(["strace", "-c", "-f", "-p", str(srv.proc.pid)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + time.sleep(0.3) + for i in range(40): + send(a, "PRIVMSG #pl :census %d" % i) + send(b, "PING :x"); recv_until(b, "PONG", timeout=2); drain(b, 0.3) + time.sleep(0.5); st.send_signal(2) + out = st.communicate(timeout=5)[1].decode(errors="ignore") + counts = {} + for line in out.splitlines(): + f = line.split() + if len(f) >= 5 and f[-1].isidentifier(): + try: + counts[f[-1]] = int(f[3]) + except ValueError: + pass + if counts: + polln = counts.get("poll", 0) + counts.get("ppoll", 0) + others = sum(counts.get(k, 0) for k in ("select", "pselect6", "epoll_wait", "epoll_pwait", "kevent")) + io = sum(counts.get(k, 0) for k in ("recvfrom", "recv", "read", "sendto", "send", "write")) + ok_fail("Syscall census: poll() is the only multiplexer used (strace)", polln > 0 and others == 0, + "strace -c on the running server during traffic.", "Direct evidence of the mechanism, not just the source.", + "poll > 0, no select/epoll.", "poll=%d others=%d recv/send=%d" % (polln, others, io), "Only poll() should appear.") + REPORT.metric("strace census", "poll=%d, recv/send=%d, other multiplexers=%d" % (polln, io, others)) + else: + skip("Syscall census (strace)", "strace produced no counts (ptrace not permitted here?)") + except (OSError, subprocess.TimeoutExpired): + skip("Syscall census (strace)", "strace not available") + else: + skip("Syscall census (strace)", "Linux + launched server only (macOS has no strace; use the source scan + thread check)") + safe_close(a, b) + REPORT.end() + +# =========================================================================== +# MANDATORY scenarios +# =========================================================================== + +def sc_authentication(): + REPORT.begin("auth", "Authentication (PASS/NICK/USER)", "Mandatory", + "The subject requires a password-gated connection: wrong/missing passwords are " + "refused and a correct handshake yields 001.") + c = connect(); LABELS[c] = "wrongpass" + send(c, "PASS wrongpass"); send(c, "NICK w1"); send(c, "USER w1 0 * :w") + ok, buf = recv_until(c, "464", timeout=2) + was_closed = closed(c, 1.0) + ok_fail("Wrong password -> 464, then the link is closed", ok and was_closed, + "Client registers with the wrong server password.", + "The subject makes the password mandatory; RFC 2812 answers with 464 ERR_PASSWDMISMATCH before closing.", + "464 ERR_PASSWDMISMATCH, then EOF.", buf or ("" if was_closed else ""), + "Send 464 and make sure it is flushed to the socket before the fd is closed (a close right after " + "queueing it drops the line).") + safe_close(c) + + c = connect(); LABELS[c] = "nopass" + send(c, "NICK n1"); send(c, "USER n1 0 * :n") + ok, buf = recv_until(c, "464", timeout=2) + was_closed = closed(c, 1.0) + ok_fail("Missing PASS -> 464, then the link is closed", ok and was_closed, + "Client sends NICK/USER but never PASS.", "No password must be treated like a wrong password.", + "464, then EOF.", buf or ("" if was_closed else ""), + "Never complete registration without an accepted PASS; send 464 and flush it before closing.") + safe_close(c) + + c = connect() + ok, buf = register(c, "authgood") + ok_fail("Correct PASS/NICK/USER -> 001 welcome", ok, "A correct handshake.", + "The happy path must produce the welcome.", "001 RPL_WELCOME.", buf, + "Send 001 once PASS, NICK and USER are all satisfied.") + got = buf + drain(c, 0.4) + if PROFILE["welcome_burst"] == "yes": + for code, label in (("002", "RPL_YOURHOST"), ("003", "RPL_CREATED"), ("004", "RPL_MYINFO"), ("005", "RPL_ISUPPORT")): + ok_fail("Welcome burst includes %s %s" % (code, label), code in got, + "Optional welcome numerics after 001.", "Your profile says you send them (welcome_burst=yes).", + "%s present." % code, got, "Send %s after 001, or set welcome_burst=no." % code) + else: + note("Welcome burst 002-005", "Optional numerics after 001.", + "The subject doesn't require them; irssi and friends like them.", + "002-005 after 001 (optional)", got, + "Your profile says welcome_burst=no. Alternative: send 002 003 004 005 for client compatibility.") + safe_close(c) + + c = connect(); LABELS[c] = "capclient" + send(c, "CAP LS 302") + ok, buf = recv_until(c, "CAP", timeout=2) + subset("CAP LS 302 before registration -> a CAP * LS reply (IRCv3 handshake irssi/HexChat send first)", + ok and " LS" in buf, not buf.strip(), + "The first thing modern clients send.", "If CAP is unknown and answered with 421 or ignored, most clients still carry on; answering CAP LS keeps them happy.", + ":server CAP * LS :", buf, + "Silence is tolerated by most clients; answering CAP LS with an (empty) list is the clean choice. 421 here can confuse some clients.") + send(c, "PASS %s" % PASSWORD); send(c, "NICK capu"); send(c, "USER c 0 * :c"); send(c, "CAP END") + ok, buf = recv_until(c, "001 capu", timeout=3) + ok_fail("Registration completes with CAP LS ... CAP END around it", ok, "The full irssi-style handshake.", + "CAP negotiation must not block or break registration.", "001 capu.", buf, "Treat CAP END as a no-op and register normally.") + safe_close(c) + + c = connect(); register(c, "reauth") + send(c, "PASS %s" % PASSWORD) + ok, buf = recv_until(c, "462", timeout=2) + subset("PASS after registration -> 462 ERR_ALREADYREGISTERED", ok, ("462" in buf) or (not buf.strip()), + "Re-sending PASS on a registered connection.", "RFC 2812: registration commands are refused once registered.", + "462.", buf, "Silently ignoring it is acceptable; 462 is the RFC reply.") + send(c, "USER x 0 * :x") + ok, buf = recv_until(c, "462", timeout=2) + subset("USER after registration -> 462 ERR_ALREADYREGISTERED", ok, ("462" in buf) or (not buf.strip()), + "Re-sending USER after registration.", "Same rule as PASS.", "462.", buf, + "Silently ignoring it is acceptable; 462 is the RFC reply.") + safe_close(c) + REPORT.end() + + +def sc_nick(): + REPORT.begin("nick", "NICK", "Mandatory", + "Nickname assignment, change broadcast, duplicate and malformed rejection, and the " + "order of NICK/USER.") + c = connect(); ok, buf = register(c, "nick_a") + ok_fail("Registration with a valid nick", ok, "Register nick_a.", "Baseline.", "001.", buf, "See Authentication.") + send(c, "NICK nick_a2") + ok, buf = recv_until(c, "NICK", timeout=2) + ok_fail("NICK change is echoed/broadcast", ok, "Change nick_a -> nick_a2.", + "A live nick change must be announced so peers stay consistent.", + ":nick_a!... NICK nick_a2", buf, "Broadcast the NICK to the user and every channel they share.") + + c2 = connect(); register(c2, "dup_taken") + c3 = connect(); LABELS[c3] = "dup_try" + send(c3, "PASS %s" % PASSWORD); send(c3, "NICK dup_taken"); send(c3, "USER d 0 * :d") + ok, buf = recv_until(c3, "433", timeout=2) + ok_fail("Duplicate nick -> 433 ERR_NICKNAMEINUSE", ok, "Second client claims an in-use nick.", + "Nicks must be unique.", "433.", buf, "Reject the NICK; don't register a clash.") + + c4 = connect(); LABELS[c4] = "badnick" + send(c4, "PASS %s" % PASSWORD); send(c4, "NICK 1nvalid!"); send(c4, "USER b 0 * :b") + ok, buf = recv_until(c4, "432", timeout=2) + subset("Erroneous nick -> 432 ERR_ERRONEUSNICKNAME", ok, ("432" in buf) or ("001" not in buf), + "Nick with a leading digit and '!'.", "RFC nick grammar forbids these.", + "432.", buf, "Not validating is acceptable as long as the bad nick is never registered; 432 is the RFC reply.") + + # USER before NICK: correct behaviour is to WAIT (no reply) until NICK arrives. + c5 = connect(); LABELS[c5] = "nonick" + send(c5, "PASS %s" % PASSWORD); send(c5, "USER u 0 * :u") + got = drain(c5, 0.8) + ok_fail("USER before NICK -> registration waits (no 001 yet)", "001" not in got, + "PASS and USER are sent, NICK never is.", + "Registration needs all three; nothing to reply until NICK arrives. Silence is correct.", + "no 001.", got, "If 001 appears here the server registers a client with no nickname.") + send(c5, "NICK late") + ok, buf = recv_until(c5, "001 late", timeout=2) + ok_fail("...then NICK arrives -> 001 completes the registration", ok, "NICK after PASS+USER.", + "NICK/USER order must not matter.", "001 late.", buf, "Complete registration once all three are present, in any order.") + + c6 = connect(); LABELS[c6] = "barenick" + send(c6, "PASS %s" % PASSWORD); send(c6, "NICK") + ok, buf = recv_until(c6, "431", timeout=2) + ok_fail("Bare NICK (no parameter) -> 431 ERR_NONICKNAMEGIVEN", ok, "NICK with no nickname.", + "This is what 431 is for.", "431.", buf, "Return 431 when NICK has no parameter.") + safe_close(c, c2, c3, c4, c5, c6) + REPORT.end() + + +def sc_join(): + REPORT.begin("join", "JOIN", "Mandatory", "Joining, the names burst, broadcast to members, and rejects.") + a = connect(); b = connect(); register(a, "join_a"); register(b, "join_b") + send(a, "JOIN #room") + ok, buf = recv_until(a, "JOIN #room", timeout=2) + ok_fail("JOIN -> joiner receives its own JOIN", ok, "Alice joins #room.", + "The server confirms membership by echoing the JOIN.", ":join_a!... JOIN #room", buf, "Echo the JOIN to the joiner.") + ok, buf = recv_until(a, "353", timeout=2) + ok_fail("JOIN -> 353 RPL_NAMREPLY", ok, "The names list.", "Clients build the member list from 353.", + "353 ... :@join_a", buf, "Send 353 with the member list (operator prefixed @).") + ok, buf = recv_until(a, "366", timeout=2) + ok_fail("JOIN -> 366 RPL_ENDOFNAMES", ok, "End of names.", "366 ends the list.", "366.", buf, "Send 366 after 353.") + send(b, "JOIN #room") + ok, buf = recv_until(a, "JOIN #room", timeout=2) + ok_fail("Second JOIN is broadcast to existing members", ok, "Bob joins; Alice should see it.", + "Members must learn who joins.", ":join_b!... JOIN #room", buf, "Fan out the JOIN to all members.") + send(a, "JOIN nohash") + ok, buf = recv_until(a, "403", timeout=2) + subset("JOIN a name without a valid prefix -> 403", ok, ("403" in buf) or ("476" in buf), + "JOIN 'nohash' (no #/&).", "Channel names must start with a channel type char.", + "403 (or 476).", buf, "Either numeric is defensible.") + send(a, "JOIN #a,#b,#c"); got = drain(a, 0.6) + subset("Comma-separated JOIN #a,#b,#c joins several", got.count("JOIN #") >= 2, "JOIN #" in got, + "One JOIN with a comma list.", "RFC allows a target list; optional for the subject.", + "a JOIN for each of #a,#b,#c.", got, "Only the first joined -- acceptable; splitting on ',' is the RFC behaviour.") + send(a, "JOIN") + ok, buf = recv_until(a, "461", timeout=2) + ok_fail("JOIN with no params -> 461 ERR_NEEDMOREPARAMS", ok, "Bare JOIN.", "Missing parameter.", "461.", buf, "Return 461.") + safe_close(a, b) + REPORT.end() + + +def sc_part(): + REPORT.begin("part", "PART", "Mandatory", "Leaving a channel, broadcast, and the not-a-member reject.") + a = connect(); b = connect(); register(a, "part_a"); register(b, "part_b") + send(a, "JOIN #p"); send(b, "JOIN #p"); time.sleep(0.2) + send(a, "PART #p :bye") + ok, buf = recv_until(b, "PART", timeout=2) + ok_fail("PART broadcast to remaining members", ok, "Alice parts #p.", "Members must see departures.", + ":part_a!... PART #p :bye", buf, "Fan out PART before removing the member.") + send(a, "PART #p") + ok, buf = recv_until(a, "442", timeout=2) + ok_fail("PART a channel you're not in -> 442 ERR_NOTONCHANNEL", ok, "Alice parts again.", + "Can't leave what you're not in.", "442.", buf, "Return 442 when the sender is not a member.") + send(a, "PART #ghost") + ok, buf = recv_until(a, "403", timeout=2) + subset("PART a non-existent channel -> 403", ok, ("403" in buf) or ("442" in buf), + "PART #ghost.", "Unknown channel.", "403 (or 442).", buf, "Both seen in the wild.") + safe_close(a, b) + REPORT.end() + + +def sc_topic(): + REPORT.begin("topic", "TOPIC", "Mandatory", "Set/query topic, +t restriction, and that a rejected change does not stick.") + a = connect(); b = connect(); register(a, "topic_a"); register(b, "topic_b") + send(a, "JOIN #t"); send(b, "JOIN #t"); time.sleep(0.2) + send(a, "TOPIC #t :hello world") + ok, buf = recv_until(b, "TOPIC #t :hello world", timeout=2) + ok_fail("TOPIC set -> broadcast to members", ok, "Alice sets the topic.", "A new topic must reach everyone.", + ":topic_a!... TOPIC #t :hello world", buf, "Broadcast the TOPIC to all members.") + send(b, "TOPIC #t") + ok, buf = recv_until(b, "332", timeout=2) + ok_fail("TOPIC query -> 332 RPL_TOPIC", ok, "Bob queries the topic.", "Query returns the current topic.", + "332 #t :hello world", buf, "Answer a paramless TOPIC with 332 (331 if unset).") + send(a, "MODE #t +t"); time.sleep(0.2) + send(b, "TOPIC #t :hijack") + ok, buf = recv_until(b, "482", timeout=2) + ok_fail("+t: non-op TOPIC change -> 482", ok, "Bob (non-op) sets topic under +t.", "+t restricts TOPIC to operators.", + "482.", buf, "Gate TOPIC on operator status when +t is set.") + send(b, "TOPIC #t") + ok, buf = recv_until(b, "332", timeout=2) + ok_fail("Rejected TOPIC did not change the topic", ok and "hello world" in buf and "hijack" not in buf, + "Re-query after the rejected change.", "A refused change must not mutate state.", + "332 still 'hello world'.", buf, "Only apply the topic after the op check passes.") + safe_close(a, b) + REPORT.end() + + +def sc_privmsg(): + REPORT.begin("privmsg", "PRIVMSG / NOTICE", "Mandatory", "Channel + private delivery, no self-echo, and the error numerics.") + a = connect(); b = connect(); register(a, "pm_a"); register(b, "pm_b") + send(a, "JOIN #m"); send(b, "JOIN #m"); time.sleep(0.2) + send(a, "PRIVMSG #m :hi all") + ok, buf = recv_until(b, "PRIVMSG #m :hi all", timeout=2) + ok_fail("PRIVMSG to channel reaches other members", ok, "Alice messages #m.", "Core messaging.", + ":pm_a!... PRIVMSG #m :hi all", buf, "Fan out to members except the sender.") + ok_fail("Sender does not receive its own channel message", "hi all" not in drain(a, 0.3), + "Alice's own stream.", "IRC never echoes your own PRIVMSG.", "nothing", "", "Exclude the sender when fanning out.") + send(a, "PRIVMSG pm_b :just you") + ok, buf = recv_until(b, "PRIVMSG pm_b :just you", timeout=2) + ok_fail("PRIVMSG to a nick is delivered privately", ok, "Alice DMs Bob.", "Private messaging.", + ":pm_a!... PRIVMSG pm_b :just you", buf, "Route nick targets to that client.") + send(a, "NOTICE pm_b :note") + ok, buf = recv_until(b, "NOTICE", timeout=2) + ok_fail("NOTICE to a nick is delivered", ok, "Alice NOTICEs Bob.", "NOTICE = PRIVMSG without error replies.", + ":pm_a!... NOTICE pm_b :note", buf, "Deliver NOTICE like PRIVMSG; never answer it with an error.") + send(a, "PRIVMSG ghost :hi") + ok, buf = recv_until(a, "401", timeout=2) + ok_fail("PRIVMSG unknown nick -> 401 ERR_NOSUCHNICK", ok, "Message a missing nick.", "Undeliverable target must be reported.", + "401.", buf, "Return 401 when the nick isn't found.") + send(a, "PRIVMSG #m") + ok, buf = recv_until(a, "412", timeout=2) + subset("PRIVMSG with no text -> 412 ERR_NOTEXTTOSEND", ok, ("412" in buf) or ("461" in buf), + "PRIVMSG target, no message.", "No body to deliver.", "412 (or 461).", buf, "412 is the RFC reply; 461 acceptable.") + send(a, "PRIVMSG") + ok, buf = recv_until(a, "411", timeout=2) + subset("PRIVMSG with no recipient -> 411 ERR_NORECIPIENT", ok, ("411" in buf) or ("461" in buf), + "Bare PRIVMSG.", "No target.", "411 (or 461).", buf, "411 is the RFC reply; 461 acceptable.") + send(a, "NOTICE ghost :x"); got = drain(a, 0.6) + ok_fail("NOTICE to unknown nick stays silent (no 401)", "401" not in got, "NOTICE a missing nick.", + "NOTICE must never generate an error reply.", "silence", got, "Suppress error numerics on the NOTICE path.") + # non-member send + o = connect(); register(o, "outsider"); drain(b, 0.2) + send(o, "PRIVMSG #m :from outside"); got_o = drain(o, 0.6); got_b = drain(b, 0.6) + if PROFILE["nonmember_send"] == "reject": + ok_fail("PRIVMSG to a channel you're not in -> rejected (404), not delivered", + ("404" in got_o or not ("from outside" in got_b)) and "from outside" not in got_b, + "An outsider messages #m.", "Your profile: nonmember_send=reject (the common +n behaviour).", + "404 ERR_CANNOTSENDTOCHAN to the sender; nothing at the members.", got_o + got_b, + "Reject non-member sends, or set nonmember_send=deliver if you allow them.") + else: + ok_fail("PRIVMSG to a channel you're not in -> delivered (your profile allows it)", "from outside" in got_b, + "An outsider messages #m.", "Your profile: nonmember_send=deliver.", "message at the members.", + got_b, "Deliver, or set nonmember_send=reject.") + safe_close(a, b, o) + REPORT.end() + + +def sc_kick(): + REPORT.begin("kick", "KICK", "Mandatory", "Operator kicks, broadcast, and the non-op / bad-target rejects.") + a = connect(); b = connect(); c = connect(); register(a, "kick_a"); register(b, "kick_b"); register(c, "kick_c") + send(a, "JOIN #k"); send(b, "JOIN #k"); send(c, "JOIN #k"); time.sleep(0.3) + send(a, "KICK #k kick_b :bye") + ok, buf = recv_until(b, "KICK #k kick_b", timeout=2) + ok_fail("KICK by op -> target receives KICK", ok, "Alice kicks Bob.", "Operators remove members.", + ":kick_a!... KICK #k kick_b :bye", buf, "Send the KICK to the target and the channel.") + ok, buf = recv_until(c, "KICK", timeout=2) + ok_fail("KICK is broadcast to the channel", ok, "Carol should see it.", "Everyone learns of the removal.", + "KICK in Carol's stream.", buf, "Fan out the KICK to all members.") + send(c, "KICK #k kick_a :nope") + ok, buf = recv_until(c, "482", timeout=2) + ok_fail("KICK by non-op -> 482", ok, "Carol (non-op) kicks.", "Only ops may kick.", "482.", buf, "Gate KICK on operator status.") + send(a, "KICK #ghost kick_b :x") + ok, buf = recv_until(a, "403", timeout=2) + ok_fail("KICK on a missing channel -> 403", ok, "Kick in a channel that doesn't exist.", "Unknown channel.", "403.", buf, "Return 403 first.") + d = connect(); register(d, "kick_d") + send(a, "KICK #k kick_d :outsider") + ok, buf = recv_until(a, "441", timeout=2) + ok_fail("KICK a non-member -> 441 ERR_USERNOTINCHANNEL", ok, "Kick someone not in the channel.", + "Target must be a member.", "441.", buf, "Return 441 when the target isn't in the channel.") + send(a, "KICK") + ok, buf = recv_until(a, "461", timeout=2) + ok_fail("KICK with no params -> 461", ok, "Bare KICK.", "Missing params.", "461.", buf, "Return 461.") + safe_close(a, b, c, d) + REPORT.end() + + +def sc_invite(): + REPORT.begin("invite", "INVITE", "Mandatory", "Invitations, the 341 reply, and +i enforcement.") + a = connect(); b = connect(); c = connect(); register(a, "inv_a"); register(b, "inv_b"); register(c, "inv_c") + send(a, "JOIN #i"); time.sleep(0.2) + send(a, "INVITE inv_b #i") + ok, buf = recv_until(b, "INVITE", timeout=2) + ok_fail("INVITE -> target receives it", ok, "Alice invites Bob.", "Invited user is notified.", + ":inv_a!... INVITE inv_b #i", buf, "Send the INVITE to the target.") + ok, buf = recv_until(a, "341", timeout=2) + ok_fail("INVITE -> inviter gets 341 RPL_INVITING", ok, "Confirmation to Alice.", "The inviter is told it worked.", + "341 inv_a #i inv_b", buf, "Send 341 back.") + send(a, "MODE #i +i"); time.sleep(0.2) + send(c, "JOIN #i") + ok, buf = recv_until(c, "473", timeout=2) + ok_fail("+i: uninvited JOIN -> 473 ERR_INVITEONLYCHAN", ok, "Carol tries to join +i.", "Invite-only must block the uninvited.", + "473.", buf, "Reject non-invited joins under +i.") + send(a, "INVITE inv_c #i"); recv_until(c, "INVITE", timeout=2) + send(c, "JOIN #i") + ok, buf = recv_until(c, "JOIN #i", timeout=2) + ok_fail("+i: invited user can JOIN", ok, "Carol joins after invite.", "An invite must actually admit.", + ":inv_c!... JOIN #i", buf, "Record the invite and let that user through once.") + safe_close(a, b, c) + REPORT.end() + + +def sc_mode(): + REPORT.begin("mode", "MODE (i t k o l)", "Mandatory", "Every required channel mode, the query form, and the error numerics.") + a = connect(); b = connect(); c = connect(); register(a, "mode_a"); register(b, "mode_b"); register(c, "mode_c") + send(a, "JOIN #x"); send(b, "JOIN #x"); send(c, "JOIN #x"); time.sleep(0.3) + send(a, "MODE #x +o mode_b") + ok, buf = recv_until(b, "+o mode_b", timeout=2) + ok_fail("+o grants operator", ok, "Alice ops Bob.", "Operator delegation.", ":mode_a!... MODE #x +o mode_b", buf, "Apply +o and broadcast.") + send(a, "MODE #x -o mode_b") + ok, buf = recv_until(b, "MODE", timeout=2) + ok_fail("-o removes operator", ok, "Alice de-ops Bob.", "Operator removal.", ":mode_a!... MODE #x -o mode_b", buf, "Apply -o and broadcast.") + + send(a, "JOIN #keyc"); time.sleep(0.2); drain(a, 0.2) + send(a, "MODE #keyc +k secret"); time.sleep(0.2) + d = connect(); register(d, "mode_d"); send(d, "JOIN #keyc") + ok, buf = recv_until(d, "475", timeout=2) + ok_fail("+k: JOIN without key -> 475 ERR_BADCHANNELKEY", ok, "Dave joins a keyed channel with no key.", + "+k gates entry on a key.", "475.", buf, "Check the key before admitting.") + send(d, "JOIN #keyc secret") + ok, buf = recv_until(d, "JOIN #keyc", timeout=2) + ok_fail("+k: JOIN with the right key works", ok, "Dave joins with the key.", "Correct key admits.", "JOIN #keyc", buf, "Admit on match.") + + send(a, "JOIN #limc"); time.sleep(0.2); drain(a, 0.2) + send(a, "MODE #limc +l 1"); time.sleep(0.2) + e = connect(); register(e, "mode_e"); send(e, "JOIN #limc") + ok, buf = recv_until(e, "471", timeout=2) + ok_fail("+l: JOIN over the limit -> 471 ERR_CHANNELISFULL", ok, "Channel limited to 1; a second client joins.", + "+l caps membership.", "471.", buf, "Reject when member count would exceed the limit.") + send(a, "MODE #limc -l"); time.sleep(0.2) + send(e, "JOIN #limc") + ok, buf = recv_until(e, "JOIN #limc", timeout=2) + ok_fail("-l: JOIN works after the limit is cleared", ok, "Same client retries after -l.", "Removing +l lifts the cap.", + "JOIN #limc", buf, "Clear the stored limit on -l.") + + send(a, "MODE #x") + ok, buf = recv_until(a, "324", timeout=2) + subset("MODE query -> 324 RPL_CHANNELMODEIS", ok, "324" in buf, "Alice queries current modes.", + "Clients read modes via 324.", "324 #x +...", buf, "Answer a paramless MODE with 324.") + send(a, "MODE #x +t"); recv_until(a, "+t", 1); drain(a, 0.2) + send(c, "MODE #x +i") + ok, buf = recv_until(c, "482", timeout=2) + ok_fail("MODE by non-op -> 482", ok, "Carol (non-op) sets a mode.", "Only ops change modes.", "482.", buf, "Gate MODE changes on op status.") + send(a, "MODE #x +Z") + ok, buf = recv_until(a, "472", timeout=2) + subset("Unknown mode flag -> 472 ERR_UNKNOWNMODE", ok, ("472" in buf) or ("501" in buf) or (not buf.strip()), + "An undefined mode letter.", "Unknown modes should be reported.", "472.", buf, "Ignoring it is tolerable; 472 is the RFC reply.") + + send(a, "JOIN #argc"); time.sleep(0.2); drain(a, 0.3) + pol = PROFILE["missing_mode_arg"] + for cmd, lbl in (("MODE #argc +k", "+k without key"), ("MODE #argc +o", "+o without nick"), ("MODE #argc +l", "+l without number")): + send(a, cmd) + ok, buf = recv_until(a, "461", timeout=2) + specific = any(x in buf for x in ("467", "696", "472")) + if pol == "461": + ok_fail("%s -> 461 ERR_NEEDMOREPARAMS" % lbl, ok, "A mode that needs an argument, given none.", + "Your profile: missing_mode_arg=461.", "461.", buf, "Return 461, or set missing_mode_arg=specific/any.") + elif pol == "specific": + ok_fail("%s -> a specific numeric (467/696)" % lbl, specific or ok, "A mode that needs an argument, given none.", + "Your profile: missing_mode_arg=specific.", "467 ERR_KEYSET / 696 ERR_INVALIDMODEPARAM (461 also accepted).", buf, + "Return a defined numeric, or set missing_mode_arg=any.") + else: + subset("%s -> which numeric? (461 is the RFC one; 467/696 are more specific alternatives)" % lbl, ok, specific or (not buf.strip()), + "Send the mode letter with its argument missing (e.g. MODE #chan +k with no key).", + "Not judged by the subject. RFC 2812 answers 461 ERR_NEEDMOREPARAMS; some servers answer 467 ERR_KEYSET or 696 ERR_INVALIDMODEPARAM, which is more precise.", + "461 (RFC) - or 467/696.", buf, + "PASS = 461. NOTE = your server answered %s. Both are defined replies; set missing_mode_arg=specific to make this a PASS for your choice." % ("467/696" if specific else "nothing")) + + drain(a, 0.3) + send(a, "MODE mode_a +i") + ok, buf = recv_until(a, "502", timeout=1) + if PROFILE["user_modes"] == "supported": + ok_fail("MODE on a nickname (user mode) -> handled (221/501/502)", ("221" in buf) or ("502" in buf) or ("501" in buf), + "Setting a user mode.", "Your profile: user_modes=supported.", "221/501/502.", buf, "Handle user modes, or set user_modes=none.") + else: + note("MODE on a nickname (a USER mode, e.g. MODE alice +i) - not a channel mode, not required by the subject", + "Send MODE +i.", "The subject only requires channel modes (i t k o l on #channels); user modes are outside its scope.", + "any consistent handling (403 / 501 / ignore).", buf, + "Your profile: user_modes=none, so this is informational. Alternative: implement 221/501 user modes.") + safe_close(a, b, c, d, e) + REPORT.end() + + +def sc_quit(): + REPORT.begin("quit", "QUIT & abrupt disconnect", "Mandatory", "Graceful QUIT broadcast, and that a hard drop still tells the peers.") + a = connect(); b = connect(); register(a, "quit_a"); register(b, "quit_b") + send(a, "JOIN #q"); send(b, "JOIN #q"); time.sleep(0.2) + send(a, "QUIT :see ya") + ok, buf = recv_until(b, "QUIT", timeout=2) + ok_fail("QUIT is broadcast to channel members", ok, "Alice quits with a message.", "Members must learn of a departure.", + ":quit_a!... QUIT :... see ya", buf, "Broadcast QUIT to every shared channel, once.") + ok, abuf = recv_until(a, "ERROR", timeout=2) + subset("The quitting client gets ERROR :Closing link before EOF", ok, True, + "Read alice's own socket after her QUIT.", + "RFC 2812 3.1.7: the server acknowledges QUIT with an ERROR message; irssi shows it as the disconnect reason.", + "ERROR :Closing link ..., then EOF", abuf or "", + "The ERROR line is queued and the fd closed in the same loop iteration, before poll() reports POLLOUT: " + "wait for the pending output to be sent, then close.") + safe_close(a) + a2 = connect(); register(a2, "drop_a"); send(a2, "JOIN #q"); time.sleep(0.2); drain(b, 0.3) + a2.close() + ok, buf = recv_until(b, "QUIT", timeout=3) + subset("Abrupt disconnect -> peers still get a QUIT", ok, True, + "Alice's socket is closed with no QUIT.", "A dropped client must not linger in the member list.", + ":drop_a!... QUIT :", buf, + "Not broadcast: the member list goes stale for peers. Alternative: on EOF, broadcast a QUIT to the client's channels.") + safe_close(b) + REPORT.end() + + +def sc_ping_errors(): + REPORT.begin("ping", "PING/PONG & unknown commands", "Mandatory", "Liveness handshake and the catch-all error numerics.") + c = connect(); register(c, "ping_c") + send(c, "PING :token") + ok, buf = recv_until(c, "PONG", timeout=2) + ok_fail("PING -> PONG with the token", ok and "token" in buf, "Alice pings.", "Keepalive.", ":server PONG server :token", buf, "Answer PING with PONG echoing the token.") + send(c, "PING") + ok, buf = recv_until(c, "409", timeout=1) + subset("PING with no token -> 409 ERR_NOORIGIN", ok, ("409" in buf) or (not buf.strip()), "Bare PING.", "No origin to echo.", "409.", buf, "Ignoring is tolerable; 409 is the RFC reply.") + send(c, "NOTACOMMAND") + ok, buf = recv_until(c, "421", timeout=2) + ok_fail("Unknown command -> 421 ERR_UNKNOWNCOMMAND", ok, "Garbage command.", "Unknown verbs must be reported, not crash.", "421.", buf, "Return 421.") + safe_close(c) + REPORT.end() + + +def sc_operator_and_isolation(): + REPORT.begin("ops", "Operator model & channel isolation", "Mandatory", "Creator gets op, granted ops work, and channels don't leak into each other.") + a = connect(); b = connect(); register(a, "op_a"); register(b, "op_b") + send(a, "JOIN #o"); send(b, "JOIN #o"); time.sleep(0.2) + send(b, "KICK #o op_a :haha") + ok, buf = recv_until(b, "482", timeout=2) + ok_fail("Channel creator holds op (non-op kick -> 482)", ok, "Bob tries to kick the creator.", "First joiner is operator.", "482.", buf, "Grant op to the channel creator.") + send(a, "MODE #o +o op_b"); time.sleep(0.2) + cc = connect(); register(cc, "op_c"); send(cc, "JOIN #o"); time.sleep(0.2) + send(b, "KICK #o op_c :now i can") + ok, buf = recv_until(cc, "KICK #o op_c", timeout=2) + ok_fail("A newly-granted op can KICK", ok, "Bob (now op) kicks Carol.", "Delegated op must be real.", "KICK reaches Carol.", buf, "Honor granted ops.") + a2 = connect(); b2 = connect(); register(a2, "iso_a"); register(b2, "iso_b") + send(a2, "JOIN #one"); send(a2, "JOIN #two"); send(b2, "JOIN #one"); time.sleep(0.3) + send(a2, "PRIVMSG #two :only two"); got = drain(b2, 0.6) + ok_fail("Message in a channel you're not in never arrives", "only two" not in got, "Alice posts to #two; Bob is only in #one.", + "Channels are isolated.", "nothing in Bob's stream", got, "Fan out strictly to the target channel's members.") + safe_close(a, b, cc, a2, b2) + REPORT.end() + + +def sc_casemapping(): + REPORT.begin("case", "Case-insensitivity", "Mandatory", "Nicks and channel names compare case-insensitively (rfc1459 / ascii).") + a = connect(); register(a, "Case_A") + b = connect(); LABELS[b] = "case_try" + send(b, "PASS %s" % PASSWORD); send(b, "NICK case_a"); send(b, "USER c 0 * :c") + ok, buf = recv_until(b, "433", timeout=2) + a2 = connect(); register(a2, "chan_a"); b2 = connect(); register(b2, "chan_b") + send(a2, "JOIN #Room"); time.sleep(0.2); drain(a2, 0.2) + send(b2, "JOIN #room"); time.sleep(0.2) + send(a2, "PRIVMSG #Room :same channel") + ok2, buf2 = recv_until(b2, "same channel", timeout=2) + if PROFILE["casemapping"] == "none": + note("Nick/channel case folding", "Register 'Case_A' then 'case_a'; join #Room and #room.", + "Your profile: casemapping=none (case-sensitive).", "433 and one shared channel if folding", + buf + buf2, "Case-sensitive names are unusual on IRC. Alternative: fold case (rfc1459 or ascii) when comparing nicks and channels.") + else: + ok_fail("Nick differing only in case is a duplicate -> 433", ok or ("001" not in (buf + drain(b, 0.3))), + "Register 'Case_A' then 'case_a'.", "IRC nicks are case-insensitive (your profile: %s)." % PROFILE["casemapping"], + "433.", buf, "Fold case when comparing nicks.") + ok_fail("#Room and #room are the same channel", ok2, "Two clients join with different case.", + "Channel names are case-insensitive.", "Bob receives Alice's message.", buf2, "Fold case on channel keys.") + safe_close(a, b, a2, b2) + REPORT.end() + + +# =========================================================================== +# ROBUSTNESS scenarios +# =========================================================================== + +def sc_framing(): + REPORT.begin("framing", "Line framing: fragmented, batched, split-in-time", "Robustness", + "The subject's core poll requirement: a command may arrive in pieces or several at once. " + "The parser must buffer correctly and never block.") + c = connect(); LABELS[c] = "frag" + send_raw(c, ("PASS %s\r\nN" % PASSWORD).encode()); time.sleep(0.15) + send_raw(c, b"ICK frag\r\nUSER frag 0 * :f\r\n") + ok, buf = recv_until(c, "001 frag", timeout=3) + ok_fail("Command split across two packets still parses", ok, "PASS + 'N', then 'ICK frag...'.", + "recv() boundaries must not split commands.", "001 frag.", buf, "Buffer per fd until you see \\n; never parse partial lines.") + safe_close(c) + c = connect(); LABELS[c] = "batch" + send_raw(c, ("PASS %s\r\nNICK batch\r\nUSER batch 0 * :b\r\nJOIN #b\r\nPRIVMSG #b :hi\r\n" % PASSWORD).encode()) + ok, buf = recv_until(c, "JOIN #b", timeout=3) + ok_fail("Several commands in one packet all run", ok and "001 batch" in buf, "Five commands in one write.", + "One recv() can carry many lines.", "001 ... JOIN #b.", buf, "Loop over every complete line in the buffer.") + safe_close(c) + c = connect(); LABELS[c] = "split_time" + send_raw(c, ("PASS %s\r\nNICK spli" % PASSWORD).encode()); time.sleep(1.2) + send_raw(c, b"t\r\nUSER split 0 * :s\r\n") + ok, buf = recv_until(c, "001 split", timeout=3) + ok_fail("Half a command, a pause, then the rest (nc + Ctrl-D case)", ok, "'NICK spli' ... 1.2s ... 't'.", + "The subject explicitly tests partial sends.", "001 split.", buf, "Keep the partial line across poll cycles; never busy-wait.") + safe_close(c) + # the exact evaluator recipe: (printf 'com'; sleep 2; printf 'man'; sleep 2; printf 'd\r\n') | nc + c = connect(); register(c, "pieces"); drain(c, 0.2) + LABELS[c] = "pieces" + send_raw(c, b"PRI"); time.sleep(2.0) + send_raw(c, b"VMSG pieces :arrived in th"); time.sleep(2.0) + send_raw(c, b"ree pieces\r\n") + ok, buf = recv_until(c, "arrived in three pieces", timeout=3) + ok_fail("One command sent in 3 pieces, 2 s apart, executes once complete", ok, + "'PRI' ... 2s ... 'VMSG pieces :arrived in th' ... 2s ... 'ree pieces\\r\\n' -- the printf/sleep/nc recipe.", + "Nothing may run until the line is complete, and the server must stay responsive meanwhile.", + "the echoed PRIVMSG only after the final piece.", buf, + "Append to the per-fd buffer on every recv(); parse only complete lines.") + safe_close(c) + REPORT.end() + + +def sc_line_endings(): + REPORT.begin("eol", "Line endings: CRLF, bare LF, CR-CR-LF", "Robustness", + "RFC uses CRLF, but nc and many evaluators send a bare LF. Your profile says which you accept.") + c = connect(); LABELS[c] = "lf" + send_raw(c, ("PASS %s\nNICK lfuser\nUSER lfuser 0 * :l\n" % PASSWORD).encode()) + ok, buf = recv_until(c, "001 lfuser", timeout=2) + if PROFILE["line_endings"] == "lf_ok": + ok_fail("Bare LF line endings accepted", ok, "Register using only \\n.", "Your profile: line_endings=lf_ok.", + "001 lfuser.", buf, "Accept \\n as well as \\r\\n, or set line_endings=crlf_only.") + else: + note("Bare LF line endings", "Register using only \\n.", "Your profile: line_endings=crlf_only (strict RFC).", + "with crlf_only nothing happens until \\r\\n arrives", buf, + "Strict CRLF is RFC-correct; plain nc on Linux sends \\n, so with crlf_only an evaluator typing into nc sees no reply. Alternative: accept both.") + safe_close(c) + c = connect(); LABELS[c] = "crlf" + send_raw(c, ("PASS %s\r\nNICK crlfuser\r\nUSER crlfuser 0 * :c\r\n" % PASSWORD).encode()) + ok, buf = recv_until(c, "001 crlfuser", timeout=2) + ok_fail("CRLF line endings accepted", ok, "Register using \\r\\n.", "CRLF is the RFC standard and must always work.", "001 crlfuser.", buf, "If this fails, line framing is broken.") + safe_close(c) + c = connect(); LABELS[c] = "crcrlf" + send_raw(c, ("PASS %s\r\nNICK crcr\r\r\nUSER crcr 0 * :x\r\n" % PASSWORD).encode()) + got = recv_until(c, "001", timeout=2)[1] + drain(c, 0.3) + subset("CR-CR-LF handled sanely (no crash, defined result)", "001 crcr" in got, ("432" in got) or ("001" in got) or (not got.strip()), + "A stray extra \\r before \\n.", "printf '...\\r\\n' | nc -c can emit \\r\\r\\n.", + "either the extra CR is stripped (001) or the nick is rejected (432) -- but no crash.", got, + "Your server rejects the nick with the stray CR (defined, no crash). Alternative: strip trailing CRs before parsing.") + safe_close(c) + REPORT.end() + + +def sc_line_limit(): + mx = PROFILE["max_line"]; pol = PROFILE["overlong"]; strikes = PROFILE["overlong_strikes"] + REPORT.begin("limit", "Line length limit (%d bytes)" % mx, "Robustness", + "RFC caps a message at 512 bytes incl. CRLF (510 of content). Your profile: max_line=%d, " + "overlong=%s, close after %d in a row." % (mx, pol, strikes)) + c = connect(); register(c, "lim") + body_ok = "PRIVMSG lim :" + ("a" * (mx - len("PRIVMSG lim :"))) + send(c, body_ok) + ok, buf = recv_until(c, "PRIVMSG lim :aaa", timeout=2) + ok_fail("%d-byte line is accepted (echoed back)" % mx, ok, "A message exactly max_line bytes of content.", + "The maximum legal length must work.", "echoed back to the sender.", buf, + "If this is rejected, the limit is off by a byte or two (check whether you count CRLF).") + drain(c, 0.3) + body_over = "PRIVMSG lim :" + ("b" * (mx + 1 - len("PRIVMSG lim :"))) + send(c, body_over) + ok417, buf = recv_until(c, "417", timeout=2) + more = drain(c, 0.3); allgot = buf + more + if pol == "reject": + ok_fail("%d-byte line -> rejected (417 or close)" % (mx + 1), ok417 or closed(c, 0.2) or "Closing link" in allgot, + "One byte over the limit.", "Your profile: overlong=reject.", "417 ERR_INPUTTOOLONG (or the link closed).", allgot, + "Reject over-long input with 417, or set overlong=truncate/ignore.") + elif pol == "truncate": + ok_fail("%d-byte line -> truncated echo" % (mx + 1), "PRIVMSG lim :bbb" in allgot and "417" not in allgot, + "One byte over the limit.", "Your profile: overlong=truncate.", "a truncated echo, no 417.", allgot, + "Truncate to max_line, or set overlong=reject.") + else: + ok_fail("%d-byte line -> ignored (no crash)" % (mx + 1), "417" not in allgot and "bbb" not in allgot, + "One byte over the limit.", "Your profile: overlong=ignore.", "nothing.", allgot, "Ignore silently, or set overlong=reject/truncate.") + if strikes > 0: + # fresh client so the earlier over-long line doesn't count; send each line and WAIT + # for its reply so the lines arrive in separate recv() calls (coalescing would hide the rule) + safe_close(c); c = connect(); register(c, "lim2"); drain(c, 0.3) + per_line = []; dropped = False; saw_error_text = False + for i in range(strikes): + send(c, body_over) + ok_r, r = recv_any(c, ["417", "Closing link"], timeout=1.5) + r = r + drain(c, 0.3) + if "Closing link" in r: + saw_error_text = True + if closed(c, 0.3): + dropped = True + per_line.append("line %d -> %s%s" % (i + 1, "417 then " if "417" in r else "", "link closed" + (" (ERROR text received)" if saw_error_text else " (bare EOF, no ERROR text)"))) + break + per_line.append("line %d -> %s" % (i + 1, "417" if "417" in r else (r.strip()[:40] or "no reply"))) + if not dropped: + time.sleep(1.0) + dropped = closed(c, 0.5) + if dropped: + per_line.append("closed after a further 1 s") + evidence = "; ".join(per_line) + ok_fail("%d over-long lines in a row -> link closed" % strikes, dropped, + "Send the over-long line %d times, waiting for each reply." % strikes, + "Your profile: overlong_strikes=%d." % strikes, "the link closed after line %d." % strikes, evidence, + "Count consecutive over-long lines per client and close past the limit -- or set overlong_strikes=0 if you don't do this.") + if dropped and not saw_error_text: + note("Close reason not delivered before the close", "The link was closed after the over-long lines.", + "Clients like irssi show the ERROR :Closing link text as the reason they were disconnected.", + "ERROR :Closing link ... then EOF", evidence, + "The socket closed with a bare EOF: the ERROR line was queued but not flushed before close. " + "Alternative: flush the write buffer for that fd right before closing it.") + else: + note("Over-long lines never close the link", "Repeat the over-long line.", "Your profile: overlong_strikes=0.", + "kept open", "", "Alternative: drop a client after N consecutive over-long lines (cheap flood hardening).") + safe_close(c) + REPORT.end() + + +def sc_empty_and_weird(): + REPORT.begin("weird", "Empty lines, whitespace, odd input", "Robustness", "Blank lines and whitespace must be ignored, not answered with errors or crashes.") + c = connect(); register(c, "weird"); drain(c, 0.2) + for label, payload in (("empty line (CRLF)", b"\r\n"), ("empty line (bare LF)", b"\n"), + ("spaces only (CRLF)", b" \r\n"), ("spaces only (bare LF)", b" \n")): + send_raw(c, payload); got = drain(c, 0.5) + ok_fail("%s is ignored (no reply)" % label, "421" not in got and not got.strip(), + "Send %r." % payload.decode(), "A line with no command must be skipped silently.", + "silence.", got, "Skip lines that tokenise to nothing (after trimming spaces).") + send_raw(c, b"\t\r\n"); got = drain(c, 0.5) + subset("tab-only line: silence (tab trimmed) or 421 (tab is not a separator)", not got.strip(), "421" in got, + "Send a line containing only a tab.", "RFC 2812 only knows the space as separator, so a tab is a (bogus) command name.", + "silence or 421.", got, "Your server answers 421 ERR_UNKNOWNCOMMAND, like ergo does. Alternative design: trim tabs like spaces and stay silent.") + send(c, " PING :spaced"); got = drain(c, 0.6) + subset("Leading spaces before a command handled sanely", "PONG" in got, ("421" in got) or (not got.strip()) or ("PONG" in got), + "' PING :spaced'.", "Some parsers choke on leading spaces.", "a PONG or a clean 421 -- not a crash.", got, + "RFC lets you skip leading spaces; erroring is tolerable, crashing is not.") + safe_close(c) + REPORT.end() + + +def sc_recovery(): + REPORT.begin("recovery", "Slow reader: brief stall then recover", "Robustness", + "A client that stalls briefly then reads again must NOT be dropped and must get its buffered lines in order.") + a = connect(); register(a, "rec_a") + b = connect(rcvbuf=4096); LABELS[b] = "rec_b(slow)"; register(b, "rec_b") + send(a, "JOIN #r"); send(b, "JOIN #r"); time.sleep(0.3); drain(a, 0.2); drain(b, 0.2) + for i in range(200): + send(a, "PRIVMSG #r :line%03d" % i) + time.sleep(0.5) + got = b""; end = time.time() + 3 + while time.time() < end: + r, _, _ = select.select([b], [], [], 0.2) + if b in r: + try: + chunk = b.recv(65536) + except socket.error: + break + if not chunk: + break + got += chunk + text = got.decode(errors="ignore") + lines = [l for l in text.split("\r\n") if "PRIVMSG #r :line" in l] + ordered = all("line%03d" % i in lines[i] for i in range(min(len(lines), 50))) + ok_fail("Short stall then read: buffered lines arrive, in order, no drop", len(lines) >= 50 and ordered and not closed(a, 0.2), + "Bob stops reading during a 200-line burst, then drains.", "The kernel absorbs a modest backlog; a brief stall is normal.", + ">=50 complete lines, in order; Alice still connected.", "got %d lines, ordered=%s" % (len(lines), ordered), + "If Bob is dropped here, slow-client handling is firing on a client that was fine.") + REPORT.metric("Backlog lines delivered after recovery", str(len(lines))) + safe_close(a, b) + REPORT.end() + + +def sc_flood_drop(): + pol = PROFILE["slow_client"]; mb = PROFILE["flood_mb"] + REPORT.begin("flood", "Stalled reader: responsiveness & slow-client policy", "Robustness", + "One member never reads while its channel is flooded. Required: the server keeps serving " + "everyone else. Your profile says what happens to the stalled client itself: slow_client=%s." % pol) + a = connect(); register(a, "fl_a"); ctrl = connect(); register(ctrl, "fl_ctrl") + b = connect(rcvbuf=PROFILE["rcvbuf"]); LABELS[b] = "fl_b(stalled)"; register(b, "fl_b") + send(a, "JOIN #f"); send(b, "JOIN #f"); send(ctrl, "JOIN #f"); time.sleep(0.3); drain(a, 0.2); drain(ctrl, 0.2) + base_rss = server_rss_mb() + line = "PRIVMSG #f :" + ("y" * 400) + target = int(mb * 1e6 / (len(line) + 2)) + dropped = False; worst_ping = 0.0; sent = 0; t_start = time.time() + while time.time() - t_start < 25 and sent < target: + for _ in range(50): + send(a, line) + sent += 50 + t1 = time.time(); send(ctrl, "PING :p%d" % sent); recv_until(ctrl, "PONG", timeout=2) + worst_ping = max(worst_ping, (time.time() - t1) * 1000) + got = drain(a, 0.02) + if "QUIT" in got or closed(b, 0.01): + dropped = True; break + end_rss = server_rss_mb() + queued_mb = sent * (len(line) + 2) / 1e6 + ok_fail("Server stays responsive to other clients while one is stalled", worst_ping < 500, + "A control client is PINGed throughout while fl_b never reads.", + "The subject requires the server to keep handling everyone; one stalled client must never stall the poll loop.", + "control PING round-trip well under 500 ms.", "worst PING %.1f ms" % worst_ping, + "A big spike means send() is blocking or the loop is stuck on the stalled fd.") + rss_note = ("server RSS %d -> %d MB after ~%.1f MB queued" % (base_rss, end_rss, queued_mb)) if base_rss >= 0 else ("~%.1f MB queued" % queued_mb) + if pol == "drop": + ok_fail("Stalled client is dropped (slow_client=drop)", dropped, "fl_b never reads while #f is flooded.", + "Your profile: drop a client whose sends keep failing.", "fl_b dropped (QUIT to the channel).", rss_note, + "Count consecutive failed sends and close the socket past a limit, or change slow_client.") + else: + REPORT.record("Stalled client kept (slow_client=keep): %s" % (("never dropped; " + rss_note) if not dropped else "dropped"), INFO, + what="fl_b never reads while #f is flooded with ~%.1f MB." % queued_mb, + why="Design choice recorded for information: the server keeps a client that stops reading; its queue grows in server memory.", + expect="kept (keep policy)") + REPORT.metric("Worst control-client PING during the flood", "%.1f ms" % worst_ping) + REPORT.metric("Backlog queued to the stalled client", "~%.1f MB" % queued_mb) + if base_rss >= 0: + REPORT.metric("Server RSS (before -> after)", "%d -> %d MB" % (base_rss, end_rss)) + safe_close(a, ctrl, b) + REPORT.end() + + +def human_bytes(n): + return ("%dk" % (n // 1024)) if n < 1048576 else ("%.0f MB" % (n / 1048576.0)) + + +def sc_nick_reuse(): + REPORT.begin("reuse", "Nick freed after disconnect; rapid reconnect", "Robustness", + "A dropped client's nick must be reusable at once, and the server must survive many quick connect/disconnect cycles.") + a = connect(); register(a, "taken_nick"); safe_close(a); time.sleep(0.3) + b = connect(); ok, buf = register(b, "taken_nick") + ok_fail("A disconnected client's nick is free again", ok, "Register a nick, drop, re-register it.", + "State from a gone client must be cleaned up.", "001.", buf, "Release the nick when the client is reaped.") + safe_close(b) + survived = True + for i in range(40): + try: + s = connect(); send(s, "PASS %s" % PASSWORD); send(s, "NICK churn%d" % i); send(s, "USER c 0 * :c"); send(s, "JOIN #churn") + time.sleep(0.01); s.close() + except OSError: + survived = False; break + time.sleep(0.5) + probe = connect(); ok, buf = register(probe, "after_churn") + ok_fail("Server still healthy after 40 rapid connect/disconnect cycles", survived and ok, "40 clients connect, join, and drop fast.", + "Reaping must not leak fds or corrupt state.", "a fresh client still registers.", buf, "Look for fd leaks or use-after-free in the reap path.") + safe_close(probe) + REPORT.end() + + +def sc_load(): + n = PROFILE["clients"] + REPORT.begin("load", "Many clients in one channel (%d)" % n, "Robustness", + "N clients register, join one channel, and a broadcast reaches the last one; broadcast latency measured.") + socks = [] + try: + registered = 0 + for i in range(n): + s = connect(); ok, _ = register(s, "load%02d" % i) + if ok: + registered += 1; socks.append(s) + else: + s.close() + ok_fail("All %d clients register" % n, registered == n, "Open N connections and register each.", "Many fds at once.", + "%d/%d." % (n, n), "%d/%d" % (registered, n), "If some fail: fd limit (ulimit -n) or an accept() cap.") + if len(socks) < 2: + skip("Broadcast with many clients", "not enough clients registered"); REPORT.end(); return + for s in socks: + send(s, "JOIN #load") + time.sleep(0.5) + for s in socks: + drain(s, 0.05) + t1 = time.time(); send(socks[0], "PRIVMSG #load :broadcast ping") + ok, buf = recv_until(socks[-1], "broadcast ping", timeout=3); dt = (time.time() - t1) * 1000 + ok_fail("Broadcast reaches the last of %d members" % len(socks), ok, "First posts; last should receive.", "Fan-out at scale.", + "the message in the last client's stream.", buf, "Check the member iteration on send.") + REPORT.metric("Broadcast latency to %d members" % len(socks), "%.1f ms" % dt) + finally: + safe_close(*socks) + REPORT.end() + + +# =========================================================================== +# LOAD: slow-reader load test (N clients reading 1 byte at a time) +# =========================================================================== + +def sc_slow_reader_load(): + n = PROFILE["slow_clients"]; pol = PROFILE["slow_client"] + REPORT.begin("slowload", "Slow-reader load: %d clients reading 1 byte at a time" % n, "Load", + "All clients join one channel and keep talking while reading only 1 byte every 10 ms " + "(slow-loris). Shows how the server copes: responsiveness to a normal client, memory, " + "what it does with the slow ones (your profile: slow_client=%s), and data integrity." % pol) + ctrl = connect(); register(ctrl, "sl_ctrl"); send(ctrl, "JOIN #sl"); time.sleep(0.2); drain(ctrl, 0.2) + clients = [] + for i in range(n): + try: + s = connect(rcvbuf=PROFILE["rcvbuf"]) + LABELS[s] = "sl%02d" % i + send(s, "PASS %s" % PASSWORD); send(s, "NICK sl%02d" % i); send(s, "USER s 0 * :s"); send(s, "JOIN #sl") + clients.append([s, b"", False]) # sock, received bytes, dropped + except OSError: + break + time.sleep(0.6) + # drain the setup burst normally first + for c in clients: + try: + while True: + r, _, _ = select.select([c[0]], [], [], 0.05) + if not r: + break + d = c[0].recv(65536) + if not d: + c[2] = True; break + except OSError: + pass + base_rss = server_rss_mb() + text = "x" * 256 + first_drop = None; rounds = 0; worst_ping = 0.0; t0 = time.time() + while rounds < 40 and time.time() - t0 < 25: + rounds += 1 + for i, c in enumerate(clients): + if c[2]: + continue + try: + c[0].sendall(("PRIVMSG #sl :r%02d c%02d %s\r\n" % (rounds, i, text)).encode()) + except OSError: + c[2] = True + # everyone reads ONE byte + t_round = time.time() + while time.time() - t_round < 0.2: + for c in clients: + if c[2]: + continue + try: + r, _, _ = select.select([c[0]], [], [], 0) + if r: + d = c[0].recv(1) + if not d: + c[2] = True + if first_drop is None: + first_drop = rounds + else: + c[1] += d + except (BlockingIOError, socket.error): + pass + time.sleep(0.01) + t1 = time.time(); send(ctrl, "PING :sl%d" % rounds); okp, _ = recv_until(ctrl, "PONG", timeout=3) + worst_ping = max(worst_ping, (time.time() - t1) * 1000) + alive = sum(1 for c in clients if not c[2]) + if first_drop is not None and rounds >= first_drop + 5: + break + if alive == 0: + break + end_rss = server_rss_mb() + dropped = sum(1 for c in clients if c[2]) + # integrity of what the slow clients did receive (1 byte at a time) + total_lines = 0; malformed = 0 + for c in clients: + parts = c[1].split(b"\r\n") + for p in parts[:-1]: + if p: + total_lines += 1 + if not (p.startswith(b":") and (b" PRIVMSG " in p or b" JOIN " in p or b" QUIT " in p or b" 3" in p[:12] or b" 4" in p[:12])): + malformed += 1 + ok_fail("Server stays responsive to a normal client under slow-reader load", worst_ping < 500, + "A control client is PINGed every round.", "The subject requires everyone else to keep being served.", + "PING round-trip well under 500 ms.", "worst %.1f ms over %d rounds" % (worst_ping, rounds), + "A spike means the loop is blocked on slow fds.") + ok_fail("Data delivered to slow readers is intact (complete lines, no corruption)", malformed == 0, + "Reassemble each slow client's byte stream.", "Partial writes must never interleave or corrupt lines.", + "0 malformed lines.", "%d lines, %d malformed" % (total_lines, malformed), "Check partial-send handling: keep the unsent tail and resume from it.") + if pol == "keep": + REPORT.record("Slow readers kept (slow_client=keep): %d/%d still connected after %d rounds; first drop: %s; RSS %d -> %d MB" + % (n - dropped, n, rounds, first_drop or "none", base_rss, end_rss), INFO, + what="%d clients read 1 byte per 10 ms while the channel is busy." % n, + why="Design choice recorded for information: slow clients are kept, their backlog accumulates server-side.", + expect="kept (keep policy)") + else: + ok_fail("Slow readers get dropped by your policy (slow_client=%s)" % pol, dropped > 0, + "%d clients read 1 byte per 10 ms while the channel is busy." % n, "Your profile drops slow clients when sends fail.", + "at least some slow clients dropped.", "dropped %d/%d, first at round %s" % (dropped, n, first_drop), + "If none are dropped, the drop rule isn't firing for a never-reading client.") + REPORT.metric("Rounds / first drop", "%d / %s" % (rounds, first_drop or "none")) + REPORT.metric("Slow clients dropped", "%d / %d" % (dropped, n)) + REPORT.metric("Worst control PING", "%.1f ms" % worst_ping) + if base_rss >= 0: + REPORT.metric("Server RSS (before -> after)", "%d -> %d MB" % (base_rss, end_rss)) + safe_close(ctrl, *[c[0] for c in clients]) + REPORT.end() + + +# =========================================================================== +# BONUS scenarios +# =========================================================================== + +def sc_bot_commands(): + bot = PROFILE["bot_nick"] + REPORT.begin("bot", "Bonus 1 - The bot answers commands (no file involved)", "Bonus", + "A client sends PRIVMSG :!version / !whoami / !help / a bogus command / CTCP VERSION / CTCP PING " + "and the bot must reply. Nothing about files here. Skipped if no bot answers (mandatory binary).") + c = connect(); register(c, "bot_u"); drain(c, 0.3) + send(c, "PRIVMSG %s :!version" % bot) + ok, buf = recv_any(c, ["I am", bot], timeout=3) + if not ok and ("401" in buf or not buf.strip()): + skip("Bot present", "No bot answered PRIVMSG %s :!version (mandatory binary, or bot_nick differs -- set bot_nick=...)." % bot) + safe_close(c); REPORT.end(); return + ok_fail("!version -> bot identifies itself", ok, "Ask the bot its version.", "The bot must respond to commands.", "a PRIVMSG back naming the bot.", buf, "Route PRIVMSG to the bot nick into the bot handler.") + send(c, "PRIVMSG %s :!whoami" % bot) + ok, buf = recv_until(c, "You are bot_u", timeout=3) + ok_fail("!whoami -> bot echoes the caller", ok, "Ask who am I.", "Bot reads the caller's identity.", "You are bot_u.", buf, "Return the caller nick.") + send(c, "PRIVMSG %s :!help" % bot) + ok, buf = recv_any(c, ["!send", "help"], timeout=3) + ok_fail("!help -> bot lists commands", ok, "Ask for help.", "Discoverability.", "a help line.", buf, "Return the help text.") + send(c, "PRIVMSG %s :!bogus" % bot) + ok, buf = recv_any(c, ["Unknown", "unknown"], timeout=3) + ok_fail("Unknown bot command -> 'Unknown command'", ok, "A made-up command.", "Graceful handling of nonsense.", "an 'Unknown command' reply.", buf, "Add the fallback branch.") + send(c, "PRIVMSG %s :\x01VERSION\x01" % bot) + ok, buf = recv_any(c, ["\x01VERSION", bot], timeout=3) + subset("CTCP VERSION -> bot replies", ok, True, "Send CTCP VERSION.", "Real clients probe with CTCP.", "a CTCP VERSION reply.", buf.replace("\x01", ""), "Optional polish.") + send(c, "PRIVMSG %s :\x01PING 12345\x01" % bot) + ok, buf = recv_any(c, ["PING 12345"], timeout=3) + subset("CTCP PING -> bot echoes the token", ok, True, "Send CTCP PING 12345.", "CTCP ping round-trip.", "PING 12345 echoed.", buf.replace("\x01", ""), "Optional polish.") + safe_close(c) + REPORT.end() + + +def sc_bot_dcc_offer(): + bot = PROFILE["bot_nick"] + REPORT.begin("dcc_offer", "Bonus 2 - File OFFER through the bot (!send / DCC SEND / !reject / !accept) - no bytes moved yet", "Bonus", + "An 'offer' is the bot telling the receiver that a file is available: after alice sends " + "PRIVMSG :!send bob , the bot answers alice 'offer sent' and sends bob a DCC SEND " + "line. The file is NOT transferred here; bob only replies !reject (frees the slot) or !accept (the bot opens " + "its listener and waits). This scenario checks that dialogue, and that a second offer while one is active is refused.") + a = connect(); b = connect(); register(a, "dcc_a"); register(b, "dcc_b"); drain(a, 0.2); drain(b, 0.2) + fname = ensure_sample_file() + if fname is None: + skip("DCC offer", "could not create files/ next to the server binary"); safe_close(a, b); REPORT.end(); return + send(a, "PRIVMSG %s :!send dcc_b %s" % (bot, fname)) + oka, bufa = recv_any(a, ["offer sent", "busy", "not found", "401"], timeout=3) + if not oka or "401" in bufa: + skip("DCC offer", "bot did not accept !send (mandatory binary or bot off)."); safe_close(a, b); REPORT.end(); return + ok_fail("!send -> sender told the offer was sent", "offer sent" in bufa, "Alice offers a file to Bob.", "The offer path must acknowledge.", "'offer sent to dcc_b'.", bufa, "Acknowledge a successful offer.") + ok, bufb = recv_any(b, ["DCC SEND"], timeout=3) + ok_fail("Target receives a DCC SEND offer line", ok, "Bob should get the DCC SEND.", "The offer must reach the target with ip/port/size.", "DCC SEND %s " % fname, bufb.replace("\x01", ""), "Deliver the DCC SEND CTCP to the target.") + send(b, "PRIVMSG %s :!reject" % bot) + ok, buf = recv_any(b, ["rejected", "reject"], timeout=3) + ok_fail("!reject frees the slot", ok, "Bob rejects.", "A rejected offer must release resources.", "'transfer rejected'.", buf, "Close the offer on reject.") + send(a, "PRIVMSG %s :!send dcc_b %s" % (bot, fname)); recv_any(a, ["offer sent"], timeout=3); recv_any(b, ["DCC SEND"], timeout=3) + send(b, "PRIVMSG %s :!accept" % bot) + ok, buf = recv_any(b, ["accepted", "accept"], timeout=3) + ok_fail("!accept on a fresh offer works", ok, "Bob accepts the re-sent offer.", "Accept must move the transfer to active.", "'transfer accepted'.", buf, "Mark the offer accepted.") + send(a, "PRIVMSG %s :!send dcc_b %s" % (bot, fname)) + ok, buf = recv_any(a, ["busy", "offer sent"], timeout=3) + subset("A second offer while one is active -> 'bot busy'", "busy" in buf, True, "Alice offers again mid-transfer.", "A single-slot bot must refuse concurrent offers.", "'bot busy'.", buf, "A multi-slot bot is an equally valid design.") + safe_close(a, b) + REPORT.end() + + +def sc_dcc_transfer(): + bot = PROFILE["bot_nick"] + REPORT.begin("dcc_xfer", "Bonus 3 - The actual FILE TRANSFER: receiver connects to the DCC port and gets the bytes", "Bonus", + "Same offer as Bonus 2, then the receiver does what a real client does after !accept: it connects to the " + "ip:port from the DCC SEND line and reads until EOF. Passes only if the byte count equals the file size. " + "This is the step that depends on the server driving the transfer socket from its poll loop.") + fname = ensure_sample_file() + if fname is None: + skip("DCC transfer", "could not create the sample file"); REPORT.end(); return + root = REPORT.server_cwd or os.getcwd() + try: + size_expected = os.path.getsize(os.path.join(root, "files", fname)) + except OSError: + size_expected = 0 + a = connect(); b = connect(); register(a, "xfer_a"); register(b, "xfer_b"); drain(a, 0.3); drain(b, 0.3) + send(a, "PRIVMSG %s :!send xfer_b %s" % (bot, fname)) + oka, ba = recv_any(a, ["offer sent", "busy", "not found", "401"], timeout=4) + if ("busy" in ba) or ("not found" in ba) or ("401" in ba) or not oka: + subset("Real DCC file transfer completes", False, True, "Offer a file to a second client, accept, receive.", + "Needs a free transfer slot and a working listener.", "the file's bytes over TCP.", ba.replace("\x01", ""), + "Slot busy / offer refused in this run -- the offer-lifecycle scenario already exercised the bot.") + safe_close(a, b); REPORT.end(); return + okb, bb = recv_any(b, ["DCC SEND"], timeout=4) + if not okb: + REPORT.record("Receiver gets the DCC SEND offer", FAIL, what="The target should receive a DCC SEND line.", why="Needed to open the data connection.", + expect="DCC SEND ", got=bb.replace("\x01", ""), fix="Deliver the offer to the target's fd.") + safe_close(a, b); REPORT.end(); return + m = re.search(r"DCC SEND \S+ (\S+) (\d+) (\d+)", bb) + if not m: + REPORT.record("Parse DCC ip/port/size from the offer", FAIL, what="Read ip/port/size out of the DCC SEND line.", why="The receiver needs them.", + expect="DCC SEND ", got=bb.replace("\x01", ""), fix="Check the DCC SEND field layout.") + safe_close(a, b); REPORT.end(); return + ip, port, size = m.group(1), int(m.group(2)), int(m.group(3)) + if not re.match(r"^\d+\.\d+\.\d+\.\d+$", ip): + try: + n = int(ip); ip = "%d.%d.%d.%d" % ((n >> 24) & 255, (n >> 16) & 255, (n >> 8) & 255, n & 255) + except ValueError: + ip = REPORT.args.host + REPORT.record("Parse DCC ip/port/size from the offer", PASS, what="Read ip/port/size from the DCC SEND line.", why="Needed to open the data connection.", + expect="ip, port, size", got="ip=%s port=%d size=%d" % (ip, port, size)) + send(b, "PRIVMSG %s :!accept" % bot); time.sleep(0.3) + data = b""; err = None + try: + ds = socket.create_connection((ip, port), timeout=5); ds.setblocking(False) + end = time.time() + 8 + while time.time() < end: + try: + # a normal keepalive from the sender (irssi sends "PING LAG"); it also wakes a poll + # loop whose transfer socket is polled only once per iteration + send(a, "PING LAG%d" % int(time.time() * 1000)) + except OSError: + pass + r, _, _ = select.select([ds], [], [], 0.25) + if ds in r: + try: + chunk = ds.recv(8192) + except socket.error: + break + if not chunk: + break + data += chunk + if size and len(data) >= size: + break + ds.close() + except OSError as e: + err = e + exp = size or size_expected + if err is not None: + subset("Real DCC file transfer completes", False, True, "Connect to the listener and read to EOF.", "This is the actual file transfer.", + "%d bytes" % exp, "connection error: %s" % err, "Could not open the DCC data socket in this environment; the offer path is verified above.") + else: + ok_fail("Real DCC file transfer completes (byte count matches)", exp > 0 and len(data) == exp, "Connect to the listener, read to EOF.", + "The transfer must deliver the whole file.", "%d bytes" % exp, "received %d bytes" % len(data), + "A short read means the server never accepted on its DCC listener or never sent the chunks: the transfer " + "socket is not being serviced by the poll loop (if the listener/data fds are only polled once per loop iteration, " + "the loop must actually iterate; if they are polled with a separate poll() that was removed, nothing drives them).") + REPORT.metric("DCC bytes received", "%d / %d" % (len(data), exp)) + safe_close(a, b) + REPORT.end() + + +def sc_dcc_relay(): + REPORT.begin("dcc_relay", "Bonus 4 - DCC lines between two ordinary clients pass through the server untouched (no bot)", "Bonus", + "Two normal users exchange a PRIVMSG whose text is a CTCP DCC SEND/CHAT line (what irssi sends for /dcc). " + "The server only has to relay it byte-for-byte; no bot, no file, no listener involved.") + a = connect(); b = connect(); register(a, "rel_a"); register(b, "rel_b") + send(a, "PRIVMSG rel_b :\x01DCC SEND hello.txt 2130706433 9000 12\x01") + ok, buf = recv_until(b, "DCC SEND hello.txt", timeout=2) + ok_fail("DCC SEND relayed verbatim through PRIVMSG", ok, "Alice sends a DCC SEND CTCP to Bob.", "The server must not mangle CTCP payloads.", + "the exact DCC SEND line at Bob.", buf.replace("\x01", ""), "Relay the trailing parameter byte-for-byte.") + send(a, "PRIVMSG rel_b :\x01DCC CHAT chat 2130706433 9001\x01") + ok, buf = recv_until(b, "DCC CHAT chat", timeout=2) + ok_fail("DCC CHAT relayed verbatim through PRIVMSG", ok, "Alice sends a DCC CHAT CTCP.", "Same passthrough requirement.", + "the exact DCC CHAT line at Bob.", buf.replace("\x01", ""), "Same relay path as DCC SEND.") + safe_close(a, b) + REPORT.end() + + + +def sc_bonus_poll(): + REPORT.begin("bonus_poll", "Bonus 0 - One poll() in the bonus build", "Bonus", + "The one-poll() rule is a subject rule for the whole program; the bonus is only graded if the " + "mandatory part is perfect, and evaluators read the code. Counts poll( sites INCLUDING #ifdef BONUS code.") + root = os.path.dirname(os.path.abspath(REPORT.args.bonus_binary)) + files = _source_files(root) + if not files: + skip("Bonus source scan", "no sources under %s" % root); REPORT.end(); return + polls = _find_calls(files, r"(?> PRIVMSG %s :%s" % (bot, cmd)); send(c, "PRIVMSG %s :%s" % (bot, cmd)) + _, b1 = recv_any(c, needles, timeout=3) + for l in strip_ansi(b1).replace("\r", "").split("\n"): + if l: + lines.append("<< " + l) + safe_close(c) + return "=== %s ===\n(target nick: %s)\n" % (tag, bot) + "\n".join(lines) + "\n" + + +# =========================================================================== +# Suites +# =========================================================================== + +MANDATORY = [sc_single_poll, sc_authentication, sc_nick, sc_join, sc_part, sc_topic, sc_privmsg, sc_kick, sc_invite, + sc_mode, sc_quit, sc_ping_errors, sc_operator_and_isolation, sc_casemapping] +ROBUSTNESS = [sc_framing, sc_line_endings, sc_line_limit, sc_empty_and_weird, sc_recovery, sc_flood_drop, + sc_nick_reuse, sc_load] +LOAD = [sc_slow_reader_load] +BONUS_MAIN = [sc_bonus_poll, sc_bot_commands, sc_bot_dcc_offer, sc_dcc_relay] +BONUS_XFER = [sc_dcc_transfer] + +TITLES = { + "sc_single_poll": ("One poll()", "Mandatory"), "sc_authentication": ("Authentication", "Mandatory"), "sc_nick": ("NICK", "Mandatory"), "sc_join": ("JOIN", "Mandatory"), + "sc_part": ("PART", "Mandatory"), "sc_topic": ("TOPIC", "Mandatory"), "sc_privmsg": ("PRIVMSG / NOTICE", "Mandatory"), + "sc_kick": ("KICK", "Mandatory"), "sc_invite": ("INVITE", "Mandatory"), "sc_mode": ("MODE", "Mandatory"), + "sc_quit": ("QUIT", "Mandatory"), "sc_ping_errors": ("PING / errors", "Mandatory"), + "sc_operator_and_isolation": ("Operators & isolation", "Mandatory"), "sc_casemapping": ("Case-insensitivity", "Mandatory"), + "sc_framing": ("Line framing", "Robustness"), "sc_line_endings": ("Line endings", "Robustness"), + "sc_line_limit": ("Line length limit", "Robustness"), "sc_empty_and_weird": ("Odd input", "Robustness"), + "sc_recovery": ("Slow reader recovers", "Robustness"), "sc_flood_drop": ("Stalled reader", "Robustness"), + "sc_nick_reuse": ("Nick reuse / churn", "Robustness"), "sc_load": ("Many clients", "Robustness"), + "sc_slow_reader_load": ("Slow-reader load", "Load"), + "sc_bonus_poll": ("Bonus 0 one poll()", "Bonus"), "sc_bot_commands": ("Bonus 1 bot commands", "Bonus"), "sc_bot_dcc_offer": ("Bonus 2 file offer", "Bonus"), + "sc_dcc_relay": ("Bonus 4 DCC relay", "Bonus"), "sc_dcc_transfer": ("Bonus 3 file transfer", "Bonus"), +} + + +# =========================================================================== +# Server management per phase +# =========================================================================== + +def start_phase_server(binary): + args = REPORT.args + if args.no_start: + if not can_connect(args.host, args.port): + print("%s[ERROR]%s cannot reach %s:%d and --no-start was given." % (RED, RESET, args.host, args.port)) + raise SystemExit(1) + REPORT.server = None; REPORT.server_cwd = os.getcwd() + print("%sUsing external server at %s:%d%s" % (GREEN, args.host, args.port, RESET)) + return None + if not os.path.isfile(binary): + print("%s[skip phase]%s binary %r not found." % (YELLOW, RESET, binary)) + return None + cwd = os.path.dirname(os.path.abspath(binary)) or "." + logpath = os.path.join(args.outdir, "server_%s.log" % os.path.basename(binary)) + # Always the configured port (6667 unless --port): the server is restarted + # on it between phases; SO_REUSEADDR makes the rebind immediate. If someone + # else holds the port we say who and stop instead of silently moving. + if can_connect(args.host, args.port): + print("%s[ERROR]%s port %d is already in use by another program:" % (RED, RESET, args.port)) + print(" " + (port_owner(args.port) or "(lsof/ss not available to name it)")) + print(" stop it, or run with --port (make PORT=)") + raise SystemExit(1) + deadline = time.time() + 8; tail = [] + while time.time() < deadline: + srv = ServerProc(os.path.abspath(binary), args.port, args.password, logpath, cwd=cwd) + ready = False; t_end = time.time() + 6 + while time.time() < t_end: + if not srv.alive(): + break + if can_connect(args.host, args.port): + ready = True; break + time.sleep(0.1) + if ready: + REPORT.server = srv; REPORT.server_cwd = cwd + print("%sStarted %s (pid %d) on port %d%s" % (GREEN, os.path.basename(binary), srv.proc.pid, args.port, RESET)) + return srv + tail = srv.tail(6); srv.stop(); time.sleep(0.5) # bind not possible yet: retry + print("%s[ERROR]%s %s did not come up on port %d:" % (RED, RESET, binary, args.port)) + for l in tail: + print(" " + l) + raise SystemExit(1) + + +def port_owner(port): + """'pid 123 ircserv' for whatever listens on the port, via lsof (mac/linux) or ss.""" + try: + out = subprocess.check_output(["lsof", "-nP", "-iTCP:%d" % port, "-sTCP:LISTEN"], stderr=subprocess.DEVNULL).decode().splitlines() + if len(out) > 1: + f = out[1].split() + return "pid %s %s (kill %s)" % (f[1], f[0], f[1]) + except Exception: + pass + try: + out = subprocess.check_output(["ss", "-ltnp"], stderr=subprocess.DEVNULL).decode() + for line in out.splitlines(): + if ":%d " % port in line: + return line.strip() + except Exception: + pass + return "" + + +def stop_phase_server(srv): + if srv is not None: + srv.stop(); time.sleep(0.4) + REPORT.server = None + + +def run_suite(scenarios): + for sc in scenarios: + try: + sc() + except (ConnectionRefusedError, ConnectionResetError, BrokenPipeError, OSError) as e: + if REPORT.current is None: + REPORT.begin(sc.__name__, sc.__name__, "?", "") + REPORT.record(sc.__name__, FAIL, what="Scenario crashed.", got="%s: %s" % (type(e).__name__, e), + fix="The server may have died; check its log slice for this scenario.") + REPORT.end() + + +def run_everything(mode, extra_files): + """The whole plan, as the terminal mode runs it; also used by --live in a worker thread.""" + args = REPORT.args + if mode in ("mandatory", "robustness", "load", "all"): + srv = start_phase_server(args.binary) + if mode == "mandatory": + run_suite(MANDATORY) + elif mode == "robustness": + run_suite(ROBUSTNESS) + elif mode == "load": + run_suite(LOAD) + else: + run_suite(MANDATORY); run_suite(ROBUSTNESS); run_suite(LOAD) + stop_phase_server(srv) + if mode in ("bonus", "all"): + srv = start_phase_server(args.bonus_binary) + if REPORT.server is not None or args.no_start: + run_suite(BONUS_MAIN) + stop_phase_server(srv) + if args.no_start: + run_suite(BONUS_XFER) + else: + srv = start_phase_server(args.bonus_binary) + if REPORT.server is not None: + run_suite(BONUS_XFER) + stop_phase_server(srv) + if mode in ("compare", "all"): + with_txt = no_txt = None + srv = start_phase_server(args.bonus_binary) + if REPORT.server is not None or args.no_start: + with_txt = capture_bot_dialog("WITH BOT (bonus binary)") + stop_phase_server(srv) + srv = start_phase_server(args.binary) + if REPORT.server is not None or args.no_start: + no_txt = capture_bot_dialog("NO BOT (mandatory binary)") + stop_phase_server(srv) + if with_txt is not None: + open(os.path.join(args.outdir, "bot_with.log"), "w").write(with_txt) + extra_files.append(("artifacts/bot_with.log", "bot dialogue on the bonus binary")) + if no_txt is not None: + open(os.path.join(args.outdir, "bot_without.log"), "w").write(no_txt) + extra_files.append(("artifacts/bot_without.log", "same dialogue on the mandatory binary (bot absent)")) + REPORT.begin("compare", "With-bot vs no-bot capture", "Compare", + "The same bot dialogue against both binaries, saved to files so the difference is on paper.") + if with_txt and no_txt: + ok_fail("Bonus binary: the bot answers !version/!help", "I am" in with_txt, "Run the dialogue on the bonus binary.", + "Proves the bot is wired in.", "an 'I am ' style reply.", with_txt, "Route PRIVMSG to the bot nick into the bot.") + ok_fail("Mandatory binary: no bot (nick just 401s / silent)", ("401" in no_txt) or ("I am" not in no_txt), + "Same dialogue on the mandatory binary.", "The bot is a bonus-only feature.", "401 / no bot reply.", no_txt, "") + REPORT.record("Captured both transcripts to files", INFO, what="bot_with.log and bot_without.log written for a side-by-side diff.") + else: + skip("With/no-bot capture", "one of the binaries was unavailable.") + REPORT.end() + + +# =========================================================================== +# HTML report +# =========================================================================== + +def esc(s): + return html.escape(s or "") + + +def colorize_client_line(direction, line): + cls = {"<<": "rx", ">>": "tx", "--": "note"}.get(direction, "note") + arrow = {"<<": "←", ">>": "→", "--": "·"}.get(direction, "·") + return '
%s%s
' % (cls, arrow, esc(line.replace("\x01", ""))) + + +def colorize_server_line(text): + low = text.lower(); cls = "srv" + if "recvmsg" in low or "from fd" in low: + cls = "srv rx" + elif "sendmsg" in low or "sendto fd" in low: + cls = "srv tx" + elif "error" in low or "closing link" in low or "except" in low: + cls = "srv err" + elif "info" in low or "connect" in low: + cls = "srv info" + return '
%s
' % (cls, esc(text)) + + +def scenario_panes_html(sc): + order = []; by_label = {} + for (t, label, d, line) in sc.events: + if label not in by_label: + by_label[label] = []; order.append(label) + by_label[label].append((d, line)) + cnt = sc.counts() + mark = "bad" if cnt.get(FAIL) else ("warn" if cnt.get(NOTE) else "") + panes = []; cap = 500 + for label in order: + rows = by_label[label]; trunc = "" + if len(rows) > cap: + trunc = '
... (%d more lines)
' % (len(rows) - cap); rows = rows[:cap] + body = "".join(colorize_client_line(d, l) for (d, l) in rows) + trunc + panes.append('
%s
%s
' + % (mark, esc(label), body or '
(no traffic)
')) + srv_rows = sc.server_lines; trunc = "" + if len(srv_rows) > cap: + trunc = '
... (%d more lines)
' % (len(srv_rows) - cap); srv_rows = srv_rows[:cap] + srv_body = "".join(colorize_server_line(txt) for (_, txt) in srv_rows) + trunc + panes.append('
SERVER log
%s
' + % (mark, srv_body or '
(no server output captured for this window)
')) + return '
%s
' % "".join(panes) + + +def scenario_checks_html(sc): + rows = [] + for ch in sc.checks: + pill = '%s' % (ch.status.lower(), ch.status) + detail = [] + if ch.what: + detail.append('
What: %s
' % esc(ch.what)) + if ch.why: + detail.append('
Why it matters: %s
' % esc(ch.why)) + if ch.expect: + ecls = "d exp" if ch.status == FAIL else "d" + detail.append('
Expected: %s
' % (ecls, esc(ch.expect))) + if ch.status in (FAIL, NOTE) and ch.got: + gcls = "d got bad" if ch.status == FAIL else "d got" + detail.append('
What actually happened:
%s
' % (gcls, esc(ch.got[-600:]))) + if ch.status in (FAIL, NOTE) and ch.fix: + lead = "How to fix" if ch.status == FAIL else "Your choice, and the alternatives" + fcls = "d fix bad" if ch.status == FAIL else "d fix" + detail.append('
%s: %s
' % (fcls, lead, esc(ch.fix))) + rows.append('
%s %s
%s
' + % (ch.status.lower(), pill, esc(ch.name), "".join(detail))) + return "".join(rows) + + +def scenario_html(sc, idx): + c = sc.counts(); dur = (sc.t1 - sc.t0) * 1000 if sc.t1 else 0 + badges = [] + for st in (FAIL, NOTE, PASS, SKIP): + if c.get(st): + badges.append('%d %s' % (st.lower(), c[st], st)) + metrics = "" + if sc.metrics: + metrics = '
' + "".join('%s: %s' % (esc(k), esc(v)) for (k, v) in sc.metrics) + '
' + open_attr = "open" if (c.get(FAIL) or c.get(NOTE)) else "" + banner = "" + if c.get(FAIL): + banner = ('
This scenario has %d issue(s). The red checks say what was expected vs. what happened; ' + 'the red-bordered windows are the clients and server involved.
' % c[FAIL]) + elif c.get(NOTE): + banner = ('
%d note(s) here — optional extras or design choices, NOT failures. ' + 'Each one states your choice and the alternatives.
' % c[NOTE]) + return """ +
+ %s%s%s%.0f ms +
%s
+ %s + %s +
%s
+
Screens — every client's window and the server log for this scenario (problematic ones bordered)
+ %s +
+ """ % (open_attr, sc.group.lower(), esc(sc.group), esc(sc.title), "".join(badges), dur, esc(sc.blurb), banner, metrics, + scenario_checks_html(sc), scenario_panes_html(sc)) + + +def profile_html(): + d = profile_defaults() + rows = [] + for (k, _, kind, desc) in PROFILE_SPEC: + cur = PROFILE.get(k); changed = cur != d[k] + rows.append('%s%s%s%s' + % ("changed" if changed else "", esc(k), esc(str(cur)), esc(str(d[k])), esc(desc))) + return ('
Implementation profile used for this run — %s' + '%s
optionthis rundefaultmeaning
' + '
Set with --set key=value, a tester.conf, or the menu. ' + 'These describe design choices the subject leaves open; they change what the tester expects, never whether a subject requirement passes.
' + % (esc(profile_summary()), "".join(rows))) + + +def write_html(path, extra_files): + t = REPORT.totals(); total = sum(t.values()) + passed = t[PASS]; failed = t[FAIL]; notes = t[NOTE]; skipped = t[SKIP] + rate = (100.0 * passed / (passed + failed)) if (passed + failed) else 100.0 + when = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") + body = []; cur = None + for i, sc in enumerate(REPORT.scenarios): + if sc.group != cur: + cur = sc.group; body.append('

%s

' % (sc.group.lower(), esc(sc.group))) + body.append(scenario_html(sc, i)) + files_html = "" + if extra_files: + items = "".join('
  • %s — %s
  • ' % (esc(n), esc(desc)) for (n, desc) in extra_files) + files_html = '
    Companion files (same folder):
      %s
    ' % items + doc = """ + +ft_irc test report +
    +

    ft_irc test report

    +
    %s · server %s · mandatory %s, bonus %s
    +
    +
    %.0f%%
    Pass rate
    +
    %d
    Passed
    +
    %d
    Failed
    +
    %d
    Notes (optional)
    +
    Skipped
    +
    %d
    Total checks
    +
    +
    PASS required behaviour works · FAIL a real problem · NOTE (amber) = not a failure: an optional RFC extra or a design choice the subject leaves open, shown with your choice and the alternatives · each check expands to what it tests, why, expected vs. what happened, and how to fix. Panes show every line each client sent (→) and received (←) plus the server log.
    +%s +%s +%s +%s +
    Generated by tests/tester/test_irc.py · outputs under tests/tester/artifacts/
    +
    +""" % (when, esc(REPORT.args.host), esc(REPORT.args.binary), esc(REPORT.args.bonus_binary), + rate, passed, failed, notes, skipped, total, profile_html(), files_html, "".join(body), summary_html()) + with open(path, "w") as f: + f.write(doc) + + +def summary_html(): + """End-of-report summary: per-group counts, every FAIL and NOTE in one place, all metrics.""" + groups = collections.OrderedDict() + fails = []; notes_ = []; metrics = [] + for sc in REPORT.scenarios: + c = sc.counts(); g = groups.setdefault(sc.group, {PASS: 0, FAIL: 0, NOTE: 0, SKIP: 0}) + for k in (PASS, FAIL, NOTE, SKIP): + g[k] += c.get(k, 0) + for ch in sc.checks: + if ch.status == FAIL: + fails.append((sc.title, ch.name, ch.fix)) + elif ch.status == NOTE: + notes_.append((sc.title, ch.name)) + for (k, v) in sc.metrics: + metrics.append((sc.title, k, v)) + rows = "".join('%s%d%d%d%d' + % (esc(g), c[PASS], c[FAIL], c[NOTE], c[SKIP]) for (g, c) in groups.items()) + fl = "".join('
  • %s — %s
    %s
  • ' % (esc(a), esc(b), esc(f)) for (a, b, f) in fails) or "
  • none
  • " + nl = "".join('
  • %s — %s
  • ' % (esc(a), esc(b)) for (a, b) in notes_) or "
  • none
  • " + ml = "".join('%s%s%s' % (esc(a), esc(k), esc(v)) for (a, k, v) in metrics) or "none" + verdict = ("No failures: every subject requirement checked here passed." if not fails else + "%d failure(s) to fix, listed below with the suggested fix." % len(fails)) + return """ +

    Summary

    +
    %s %d note(s) are design choices or optional extras, not failures. +%s
    grouppassfailnoteskip
    +

    Failures

      %s
    +

    Notes (design choices / optional)

      %s
    +

    Measurements

    %s
    scenariometricvalue
    +
    Profile used: %s
    +""" % (esc(verdict), len(notes_), rows, fl, nl, ml, esc(profile_summary())) + + +def write_transcripts(outdir): + tdir = os.path.join(outdir, "transcripts"); os.makedirs(tdir, exist_ok=True) + for sc in REPORT.scenarios: + lines = ["# %s [%s]" % (sc.title, sc.group), "# %s" % sc.blurb, "", "## checks"] + for ch in sc.checks: + lines.append("[%s] %s" % (ch.status, ch.name)) + if ch.status in (FAIL, NOTE) and ch.got: + for l in ch.got.splitlines()[-4:]: + lines.append(" got: %s" % l) + if ch.status in (FAIL, NOTE) and ch.fix: + lines.append(" %s: %s" % ("fix" if ch.status == FAIL else "note", ch.fix)) + lines += ["", "## client screens"] + order = []; by = {} + for (t, label, d, line) in sc.events: + by.setdefault(label, []) + if label not in order: + order.append(label) + by[label].append("%s %s" % (d, line.replace("\x01", ""))) + for label in order: + lines.append("--- %s ---" % label); lines.extend(by[label]); lines.append("") + lines.append("## server log (this scenario)") + for (_, txt) in sc.server_lines: + lines.append(txt) + with open(os.path.join(tdir, "%s.txt" % sc.key), "w") as f: + f.write("\n".join(lines) + "\n") + + +# =========================================================================== +# LIVE mode: curses view of the run (scenario list + client windows + server log) +# =========================================================================== + +class LiveUI(object): + """Fixed-region live view of a test run. Nothing moves when the log grows. + + row 0 header: mode, port, pid, RSS, CPU, running totals + row 1 in / out messages-per-second sparklines, profile + row 2 poll set: sockets = listeners + clients, fds open, stalled badge + rows 3.. left: SCENARIOS (fixed 30 cols) | right: SERVER CLIENTS (irctop table: nick, in/out, SendQ, channels) + rows .. CLIENT WINDOWS of the current scenario (what each test client sent -> / received <-) + rows .. SERVER LOG (own scroll) + row h-1 keys + """ + + def __init__(self, mode, extra_files): + self.mode = mode; self.extra_files = extra_files + self.done = False; self.error = None + self.log_scroll = 0 + self.mon = MonState() if MonState else None + self.fed = 0; self.last_sample = 0 + self.socks = -1; self.fds = -1 + + def worker(self): + try: + run_everything(self.mode, self.extra_files) + except SystemExit: + self.error = "a server failed to start (see artifacts/server_*.log)" + except Exception as e: + self.error = "%s: %s" % (type(e).__name__, e) + finally: + self.done = True + + def _feed_monitor(self, srv): + """Push new server-log lines into the irctop State (nicks, channels, per-client counters).""" + if self.mon is None or srv is None: + return + with srv.lock: + new = srv.buffer[self.fed:] + self.fed = len(srv.buffer) + for (_, txt) in new: + self.mon.feed(txt) + + def run(self, stdscr): + curses.curs_set(0); stdscr.nodelay(True); stdscr.timeout(200); stdscr.keypad(True) + curses.start_color(); curses.use_default_colors() + many = curses.COLORS >= 256 + pal = [(1, 114 if many else curses.COLOR_GREEN), (2, 214 if many else curses.COLOR_YELLOW), + (3, 203 if many else curses.COLOR_RED), (4, 67 if many else curses.COLOR_BLUE), + (5, 176 if many else curses.COLOR_MAGENTA), (6, 81 if many else curses.COLOR_CYAN), + (7, 223 if many else curses.COLOR_WHITE), (8, 245 if many else curses.COLOR_WHITE)] + for i, col in pal: + curses.init_pair(i, col, -1) + C_G, C_Y, C_R, C_FRAME, C_M, C_FOCUS, C_TITLE, C_DIM = [curses.color_pair(i) for i in range(1, 9)] + GRAD = [curses.color_pair(i) for i in (4, 6, 5, 1, 2)] + BOLD = curses.A_BOLD + G = glyphs() + threading.Thread(target=self.worker, daemon=True).start() + last_srv = None + # panes: 0 scenarios, 1 server clients, 2.. client windows (up to 5), then server log + focus = 0 + scroll = {} # pane -> (vertical, horizontal) + + def sc_of(pane): + return scroll.get(pane, (0, 0)) + + def put(y, x, s, attr=0): + h, w = stdscr.getmaxyx() + if 0 <= y < h and 0 <= x < w: + lim = w - x - (1 if y == h - 1 else 0) + try: + stdscr.addstr(y, x, s[: max(0, lim)], attr) + except curses.error: + pass + + def box(y, x, hh, ww, title, attr, focused): + if hh < 2 or ww < 4: + return (y + 1, x + 1, max(0, hh - 2), max(0, ww - 2)) + a = (C_FOCUS | BOLD) if focused else attr + put(y, x, G["tl"] + G["h"] * (ww - 2) + G["tr"], a) + for i in range(1, hh - 1): + put(y + i, x, G["v"], a); put(y + i, x + ww - 1, G["v"], a) + put(y + hh - 1, x, G["bl"] + G["h"] * (ww - 2) + G["br"], a) + put(y, x + 2, (" %s " % title)[: ww - 4], (curses.A_REVERSE | BOLD) if focused else (a | BOLD)) + return (y + 1, x + 1, hh - 2, ww - 2) + + def rows_of(lines, pane, ih, iw): + """Slice for a pane: `v` lines up from the bottom, `hs` columns to the right.""" + v, hs = sc_of(pane) + v = max(0, min(v, max(0, len(lines) - ih))); scroll[pane] = (v, hs) + end = len(lines) - v + return [(l[0], l[1][hs: hs + iw].ljust(iw)) for l in lines[max(0, end - ih): max(0, end)]] + + while True: + h, w = stdscr.getmaxyx(); stdscr.erase() + srv = REPORT.server; t = REPORT.totals(); cur = REPORT.current + if srv is not last_srv: # new phase / new server: reset the monitor + last_srv = srv; self.fed = 0 + if MonState: + self.mon = MonState() + self._feed_monitor(srv) + now = time.time() + if now - self.last_sample >= 1.0 and srv and srv.proc: + self.last_sample = now + rss, cpu = ps_stats(srv.proc.pid); self.fds, self.socks = fd_counts(srv.proc.pid) + if self.mon: + self.mon.rss_mb, self.mon.cpu = rss, cpu; self.mon.pid = srv.proc.pid; self.mon.port = REPORT.args.port + q = kernel_sendq(REPORT.args.port) if REPORT.args.port else {} + with self.mon.lock: + for c in self.mon.clients.values(): + if not c.closed: + c.sendq = q.get(c.peer, 0) + self.mon.tick() + mon = self.mon + clients = mon.live_clients() if mon else [] + + # ---- header + traffic line ---- + hdr = " ft_irc tester mode=%s port %s pid %s RSS %d MB CPU %.1f%% %d pass %d fail %d note %d skip%s" % ( + self.mode, REPORT.args.port, (srv.proc.pid if (srv and srv.proc) else "-"), + mon.rss_mb if mon else 0, mon.cpu if mon else 0.0, t[PASS], t[FAIL], t[NOTE], t[SKIP], + " DONE - press q" if self.done else (" PAUSED - Enter: next scenario, c: continue" if REPORT.waiting else (" step mode: pausing after this scenario" if REPORT.step else ""))) + put(0, 0, hdr.ljust(w - 1), curses.A_REVERSE | BOLD) + listeners = max(0, self.socks - len(clients)) if self.socks >= 0 else -1 + stalled = sum(1 for c in clients if c.sendq >= 32768) + if srv: + sw = max(8, (w - 70) // 3) + put(1, 1, "in ", C_DIM); put(1, 5, _spark(srv.msgs_in, sw), C_G); put(1, 6 + sw, "%4d/s" % ((list(srv.msgs_in) or [0])[-1]), C_G) + x2 = 14 + sw; put(1, x2, "out ", C_DIM); put(1, x2 + 4, _spark(srv.msgs_out, sw), C_Y); put(1, x2 + 5 + sw, "%4d/s" % ((list(srv.msgs_out) or [0])[-1]), C_Y) + put(1, x2 + 13 + sw, ("poll set: %s sockets = %s listeners + %d clients fds %s profile: %s" % ( + self.socks if self.socks >= 0 else "?", listeners if listeners >= 0 else "?", len(clients), + self.fds if self.fds >= 0 else "?", profile_summary()))[: w - x2 - 14 - sw], C_DIM) + if stalled: + put(1, w - 22, " %d STALLED " % stalled, C_R | BOLD | curses.A_REVERSE) + + # ---- scenarios | server clients ---- + top = 2; lw = 34; T = max(6, min(12, (h - 12) // 3)) + with REPORT.lock: + scs = list(REPORT.scenarios) + iy, ix, ih, iw = box(top, 0, T + 2, lw, "SCENARIOS %d" % len(scs), C_FRAME, focus == 0) + sc_lines = [] + for sc in scs: + c = sc.counts() + if sc.state == "running": + mark, attr = ">", C_FOCUS | BOLD + elif c.get(FAIL): + mark, attr = "x", C_R | BOLD + elif c.get(NOTE): + mark, attr = "o", C_Y + else: + mark, attr = "v", C_G + sc_lines.append((attr, "%s %-24s %s" % (mark, sc.title[:24], "%d/%d" % (c[PASS], sum(c.values()))))) + for i, (attr, line) in enumerate(rows_of(sc_lines, 0, ih, iw)): + put(iy + i, ix, line, attr) + rx = lw; rw = w - rx + iy, ix, ih, iw = box(top, rx, T + 2, rw, "SERVER CLIENTS (from its log) unread-by-it = kernel bytes the client has not read", C_FRAME, focus == 1) + put(iy, ix, "%-4s %-12s %-11s %6s %6s %12s %6s %s" % ("fd", "nick", "state", "in", "out", "unread-by-it", "idle", "channels"), C_TITLE | BOLD) + cl_lines = [] + for c in clients: + attr = C_R | BOLD if c.sendq >= 32768 else (C_Y if c.sendq > 0 else 0) + cl_lines.append((attr, "%-4s %-12s %-11s %6d %6d %12s %5ds %s" % ( + c.fd, (c.nick or "-")[:12], "stalled" if c.sendq >= 32768 else ("registered" if c.registered else "connecting"), + c.msgs_in, c.msgs_out, human_bytes(c.sendq), int(now - c.last), ",".join(sorted(c.chans))))) + for i, (attr, line) in enumerate(rows_of(cl_lines, 1, ih - 1, iw)): + put(iy + 1 + i, ix, line, attr) + + # ---- client windows of the current scenario ---- + wy = top + T + 2 + sc = cur or (scs[-1] if scs else None) + log_rows = max(6, min(14, (h - wy) // 3)) + win_h = h - wy - log_rows - 3 + order = []; by = {} + if sc: + for (_, label, d, line) in sc.events: + if label not in by: + by[label] = []; order.append(label) + by[label].append((d, line)) + ports = {} + for sock, lab in list(LABELS.items()): + try: + ports[lab] = "%s:%d" % sock.getsockname() + except Exception: + pass + q = kernel_sendq(REPORT.args.port) if (REPORT.args.port and order) else {} + shown_labels = order[-5:] + ncols = max(1, len(shown_labels)); colw = w // ncols + if win_h > 2: + if not shown_labels: + box(wy, 0, win_h, w, "CLIENT WINDOWS %s" % (sc.title if sc else ""), C_FRAME, focus == 2) + for ci, label in enumerate(shown_labels): + x = ci * colw; ww = w - x if ci == ncols - 1 else colw + sq = q.get(ports.get(label, ""), 0) + frame = C_R if sq >= 32768 else GRAD[ci % len(GRAD)] + iy, ix, ih, iw = box(wy, x, win_h, ww, "%s unread-by-it %s" % (label, human_bytes(sq)), frame, focus == 2 + ci) + lines = [(C_Y if d == ">>" else C_G, "%s %s" % (G["out"] if d == ">>" else G["inn"], printable(line))) for (d, line) in by[label]] + for li, (attr, line) in enumerate(rows_of(lines, 2 + ci, ih, iw)): + put(iy + li, ix, line, attr) + npanes = 2 + len(shown_labels) + + # ---- server log ---- + ly = wy + win_h + iy, ix, ih, iw = box(ly, 0, h - ly - 1, w, "SERVER LOG", C_FRAME, focus == npanes) + if srv: + with srv.lock: + entries = list(srv.buffer) + log_lines = [] + for (tt, txt) in entries: + low = txt.lower() + attr = C_G if "from fd" in low else (C_Y if "sendto fd" in low else (C_R if ("error" in low or "closing" in low) else (C_M if "connect" in low else C_DIM))) + log_lines.append((attr, printable("%s %s" % (time.strftime("%H:%M:%S", time.localtime(tt)), re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d \S+\s+", "", txt))))) + for i, (attr, line) in enumerate(rows_of(log_lines, npanes, ih, iw)): + put(iy + i, ix, line, attr) + put(h - 1, 0, (" p pause after each scenario Enter/space next c continue q quit (after DONE) Tab pick a pane arrows/PgUp/PgDn scroll red frame = stalled %s" + % (self.error or "")).ljust(w - 1), curses.A_REVERSE | (C_R if self.error else 0)) + stdscr.refresh() + try: + k = stdscr.getch() + except curses.error: + k = -1 + if k in (ord("q"), ord("Q")) and self.done: + break + elif k in (ord("p"), ord("P")): # pause after every scenario from now on + REPORT.step = True + elif k in (10, 13, curses.KEY_ENTER, ord(" ")): # next scenario (stays in step mode) + if not REPORT.step: + REPORT.step = True + REPORT.go.set() + elif k in (ord("c"), ord("C")): # continue without pausing + REPORT.step = False; REPORT.go.set() + elif k == 9: + focus = (focus + 1) % (npanes + 1) + elif k == curses.KEY_BTAB: + focus = (focus - 1) % (npanes + 1) + elif k in (curses.KEY_UP, ord("k"), curses.KEY_DOWN, ord("j"), curses.KEY_PPAGE, curses.KEY_NPAGE, curses.KEY_LEFT, curses.KEY_RIGHT): + v, hs = sc_of(focus) + v += {curses.KEY_UP: 1, ord("k"): 1, curses.KEY_DOWN: -1, ord("j"): -1, curses.KEY_PPAGE: 10, curses.KEY_NPAGE: -10}.get(k, 0) + hs += {curses.KEY_LEFT: -8, curses.KEY_RIGHT: 8}.get(k, 0) + scroll[focus] = (max(0, v), max(0, hs)) + + +# =========================================================================== +# LAB: interactive multi-client console (server log + up to 5 client windows) +# =========================================================================== + +LAB_HELP = [ + ("Keys", None), + ("Tab / Shift-Tab", "next / previous pane: clients, server log, help, menu bar; a client pane is where typed lines go"), + ("menu bar", "Left/Right pick an option, Enter or Up/Down change it, b back: what windows show, order, per row, log rows, log filter, help, eol, walkthrough, new client, quit"), + ("Up/Down Left/Right", "scroll the focused pane (vertical / horizontal)"), + ("PgUp / PgDn", "scroll the focused pane by a page"), + ("Ctrl-L", "layout: help+log / windows only / help only"), + ("Enter", "send the input line to the active client (empty Enter = run the walkthrough step shown)"), + ("", None), + ("Lab commands", None), + ("/new [nick|N|a,b]", "1 client, N clients, or these nicks (auto PASS/NICK/USER)"), + ("/use N", "make client N active"), + ("/msg ", "= PRIVMSG : /join #chan = JOIN #chan"), + ("/eol crlf|lf|none", "line ending used when sending"), + ("/long N [target]", "PRIVMSG padded to exactly N chars"), + ("/noeol ", "send text with NO line ending (next send is glued on)"), + ("/raw ", "send exactly this (+ current eol)"), + ("/pause | /resume", "stop / restart reading (the ^Z test of the eval sheet)"), + ("/close", "close the active client abruptly (like kill -9 on nc)"), + ("/walk", "walkthrough: the next step is shown first, Enter runs it; type your own line to try something else; /skip /back /stop"), + ("/help /cols N /log N /helpw N", "hide help / windows per row / log rows / help width"), + ("/quit", "leave the lab (or Ctrl-D)"), + ("", None), + ("File transfer", None), + ("/send ", "WITHOUT the bot: this client offers to with a DCC SEND line and serves it"), + ("/dcc", "receive the pending offer in the active window (saves to artifacts/)"), + ("PRIVMSG DCCBot :!send ", "WITH the bot: offer files/; receiver: PRIVMSG DCCBot :!accept, then /dcc"), + ("", None), + ("IRC commands (typed as-is, sent to the active client)", None), + ("PASS NICK USER 0 * :", "registration (done for you on /new)"), + ("JOIN #chan [key] / PART #chan [:msg]", "first joiner becomes @op"), + ("PRIVMSG <#chan|nick> :txt / NOTICE ...", "message / message without error replies"), + ("TOPIC #chan [:txt]", "read / set topic"), + ("MODE #chan +i|+t|+k key|+o nick|+l n", "channel modes (-x removes); MODE #chan = query"), + ("KICK #chan nick [:why] / INVITE nick #chan", "operator commands"), + ("PING :x / QUIT :bye / CAP LS 302", "liveness / leave / IRCv3 handshake"), + ("PRIVMSG DCCBot :!help|!version|!whoami", "bonus bot commands"), +] + +# (who, line-or-action, what to say) +DEMO_STEPS = [ + ("alice", "JOIN #demo", "alice creates #demo and becomes operator (@alice in the 353 names list)"), + ("bob", "JOIN #demo", "bob joins: alice sees bob's JOIN, bob gets the names list"), + ("carol", "JOIN #demo", "carol joins too"), + ("alice", "PRIVMSG #demo :hello everyone", "channel message: bob and carol receive it, alice does not get an echo"), + ("bob", "PRIVMSG alice :just you", "private message to one nick"), + ("bob", "TOPIC #demo :first topic", "anyone can set the topic while +t is off"), + ("alice", "MODE #demo +t", "operator sets +t: topic now restricted to operators"), + ("bob", "TOPIC #demo :hijack", "bob is not op -> 482 ERR_CHANOPRIVSNEEDED"), + ("alice", "MODE #demo +o bob", "alice gives bob operator"), + ("bob", "KICK #demo carol :see you", "bob (now op) kicks carol; everyone sees the KICK"), + ("carol", "JOIN #demo", "carol comes back (no +i yet)"), + ("alice", "MODE #demo +i", "invite-only"), + ("carol", "PART #demo :brb", "carol leaves..."), + ("carol", "JOIN #demo", "...and is refused: 473 ERR_INVITEONLYCHAN"), + ("alice", "INVITE carol #demo", "alice invites carol (341 to alice, INVITE to carol)"), + ("carol", "JOIN #demo", "invited: carol gets in"), + ("alice", "MODE #demo +k secret", "channel key"), + ("alice", "MODE #demo +l 3", "user limit"), + ("alice", "MODE #demo", "query: 324 shows +itkl secret 3"), + ("alice", "MODE #demo -i-k-l", "clear the modes"), + ("bob", "PRIVMSG DCCBOT :!version", "BONUS: the bot answers (401 No such nick on the mandatory binary)"), + ("alice", "PRIVMSG DCCBOT :!send bob tester_sample.txt", "BONUS: alice offers files/tester_sample.txt to bob"), + ("bob", "PRIVMSG DCCBOT :!accept", "BONUS: bob accepts the pending offer"), + ("bob", "@dcc", "BONUS: bob's window connects to the DCC port and receives the bytes"), + ("carol", "QUIT :bye", "carol quits: alice and bob see the QUIT"), +] + + +def glyphs(): + """Box/arrow characters. Python's curses on macOS is often built against the old non-wide + ncurses, which corrupts multi-byte characters on redraw: use ASCII there.""" + wide = HAVE_CURSES and hasattr(curses, "unget_wch") + if wide: + return dict(tl="╭", tr="╮", bl="╰", br="╯", h="─", v="│", out="→", inn="←", dot="·", up="↑") + return dict(tl="+", tr="+", bl="+", br="+", h="-", v="|", out="->", inn="<-", dot="*", up="^") + + +def printable(text): + """Control characters as ^A style so they take one cell and cannot break the panes.""" + return "".join(ch if ch >= " " else "^" + chr(ord(ch) + 64) for ch in text) + + +class LabClient(object): + def __init__(self, idx, nick): + self.idx = idx; self.nick = nick; self.sock = None + self.lines = collections.deque(maxlen=400) # (dir, text) + self.buf = b""; self.last_len = 0; self.sendq = 0; self.closed = False; self.paused = False + self.dcc_offer = None + self.scroll = 0; self.hscroll = 0 # pane scrolling (lines from the bottom / columns) + self.dcc_listener = None; self.dcc_path = None; self.dcc_conn = None; self.dcc_sent = 0 + + def note(self, text): + self.lines.append(("--", printable(text))) + + +class Lab(object): + def __init__(self, args, nicks=None): + self.args = args; self.clients = []; self.active = 0; self.eol = "crlf" + self.start_nicks = list(nicks or ["alice"]) + self.input = ""; self.msg = ""; self.demo = None; self.demo_i = 0 + self.log_scroll = 0; self.help_scroll = 0; self.quit = False + self.bot = PROFILE["bot_nick"] + self.help_on = True; self.help_w = 36; self.log_rows = 8; self.grid_cols = 0 # 0 = automatic + + # ---- client plumbing ---- + def eol_bytes(self): + return {"crlf": b"\r\n", "lf": b"\n", "none": b""}[self.eol] + + def new_client(self, nick=None, register=True): + if len(self.clients) >= 5: + self.msg = "max 5 client windows"; return None + nick = nick or ("user%d" % (len(self.clients) + 1)) + c = LabClient(len(self.clients), nick) + try: + c.sock = socket.create_connection((self.args.host, self.args.port), timeout=3); c.sock.setblocking(False) + except OSError as e: + self.msg = "connect failed: %s" % e; return None + self.clients.append(c); self.active = c.idx; self.focus_request = c.idx + if register: + for line in ("PASS %s" % self.args.password, "NICK %s" % nick, "USER %s 0 * :%s" % (nick, nick)): + self.send_line(c, line) + return c + + def send_bytes(self, c, data, shown=None): + if c.sock is None or c.closed: + c.note("(closed)"); return + try: + c.sock.sendall(data) + except OSError as e: + c.note("send failed: %s" % e); c.closed = True; return + c.last_len = len(data) + c.lines.append((">>", printable(shown if shown is not None else data.decode(errors="replace").replace("\r", "\\r").replace("\n", "\\n")))) + + def send_line(self, c, line): + self.send_bytes(c, line.encode() + self.eol_bytes(), line + {"crlf": "", "lf": " [LF]", "none": " [no eol]"}[self.eol]) + + def pump(self): + """Read anything pending on every client socket (non-blocking).""" + self.dcc_serve() + for c in self.clients: + if c.sock is None or c.closed or c.paused: + continue + try: + r, _, _ = select.select([c.sock], [], [], 0) + except (OSError, ValueError): + continue + if not r: + continue + try: + d = c.sock.recv(65536) + except (BlockingIOError, InterruptedError): + continue + except OSError as e: + c.note("recv error: %s" % e); c.closed = True; continue + if not d: + c.note("<< server closed the connection (EOF)"); c.closed = True; continue + c.buf += d + while b"\n" in c.buf: + line, c.buf = c.buf.split(b"\n", 1) + text = line.decode(errors="replace").replace("\r", "\\r") + c.lines.append(("<<", printable(text))) + m = re.search(r"DCC SEND (\S+) (\S+) (\d+) (\d+)", text) + if m: + c.dcc_offer = (m.group(1), m.group(2), int(m.group(3)), int(m.group(4))) + c.note("DCC offer noted: %s from %s:%s (%s bytes) -- /dcc to receive" % (m.group(1), m.group(2), m.group(3), m.group(4))) + + def dcc_send(self, c, nick, path): + """Bot-less DCC SEND: this client opens a listener, tells where, and serves the file + when the receiver connects (what irssi's /dcc send does).""" + if not os.path.isfile(path): + self.msg = "no such file: %s" % path; return + ls = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + ls.bind(("127.0.0.1", 0)); ls.listen(1); ls.setblocking(False) + port = ls.getsockname()[1]; size = os.path.getsize(path) + c.dcc_listener = ls; c.dcc_path = path; c.dcc_conn = None; c.dcc_sent = 0 + self.send_line(c, "PRIVMSG %s :\x01DCC SEND %s 127.0.0.1 %d %d\x01" % (nick, os.path.basename(path), port, size)) + c.note("DCC SEND offered to %s: %s (%d bytes) on port %d; waiting for it to connect (the receiver types /dcc)" % (nick, os.path.basename(path), size, port)) + + def dcc_serve(self): + """Called from pump(): accept the receiver, stream the file, close.""" + for c in self.clients: + if c.dcc_listener is None: + continue + try: + if c.dcc_conn is None: + r, _, _ = select.select([c.dcc_listener], [], [], 0) + if not r: + continue + c.dcc_conn, _ = c.dcc_listener.accept(); c.dcc_conn.setblocking(True) + c.note("DCC: receiver connected, sending %s" % os.path.basename(c.dcc_path)) + with open(c.dcc_path, "rb") as f: + f.seek(c.dcc_sent) + chunk = f.read(65536) + if chunk: + c.dcc_conn.sendall(chunk); c.dcc_sent += len(chunk) + else: + c.note("DCC: sent %d bytes, done" % c.dcc_sent) + c.dcc_conn.close(); c.dcc_listener.close(); c.dcc_conn = c.dcc_listener = None + except OSError as e: + c.note("DCC send failed: %s" % e) + for so in (c.dcc_conn, c.dcc_listener): + try: + if so: so.close() + except OSError: + pass + c.dcc_conn = c.dcc_listener = None + + def dcc_receive(self, c): + if not c.dcc_offer: + self.msg = "no DCC offer seen in this window"; return + fname, ip, port, size = c.dcc_offer + if not re.match(r"^\d+\.\d+\.\d+\.\d+$", ip): + ip = self.args.host + def worker(): + data = b"" + try: + ds = socket.create_connection((ip, port), timeout=5); ds.setblocking(False) + end = time.time() + 8 + while time.time() < end and len(data) < size: + r, _, _ = select.select([ds], [], [], 0.25) + if r: + chunk = ds.recv(8192) + if not chunk: + break + data += chunk + ds.close() + try: + os.makedirs(self.args.outdir, exist_ok=True) + dest = os.path.join(self.args.outdir, "dcc_%s_%s" % (c.nick, os.path.basename(fname))) + with open(dest, "wb") as f: + f.write(data) + c.note("DCC: received %d / %d bytes of %s -> %s" % (len(data), size, fname, dest)) + except OSError as e: + c.note("DCC: received %d / %d bytes of %s (not saved: %s)" % (len(data), size, fname, e)) + except OSError as e: + c.note("DCC connect failed: %s" % e) + threading.Thread(target=worker, daemon=True).start() + c.note("DCC: connecting to %s:%d ..." % (ip, port)) + + # ---- commands ---- + def handle(self, text): + text = text.rstrip("\n") + if not text: + if self.demo is not None: + self.demo_step() + return + c = self.clients[self.active] if self.clients else None + if text.startswith("/"): + parts = text.split(" ", 2); cmd = parts[0].lower() + if cmd == "/quit": + self.quit = True + elif cmd == "/new": + arg = parts[1] if len(parts) > 1 else "" + if arg.isdigit(): # /new 3 -> three more clients + for _ in range(int(arg)): + self.new_client(None) + elif "," in arg: # /new dave,eve + for nick in arg.split(","): + if nick.strip(): + self.new_client(nick.strip()) + else: + self.new_client(arg or None) + elif cmd == "/msg" and c is not None and len(parts) > 2: + self.send_line(c, "PRIVMSG %s :%s" % (parts[1], parts[2])) + elif cmd == "/join" and c is not None and len(parts) > 1: + self.send_line(c, "JOIN " + parts[1]) + elif cmd == "/use" and len(parts) > 1 and parts[1].isdigit(): + n = int(parts[1]) - 1 + if 0 <= n < len(self.clients): + self.active = n; self.focus_request = n + elif cmd == "/eol" and len(parts) > 1 and parts[1] in ("crlf", "lf", "none"): + self.eol = parts[1]; self.msg = "line ending: %s" % self.eol + elif cmd == "/long" and c is not None and len(parts) > 1 and parts[1].isdigit(): + n = int(parts[1]); target = parts[2] if len(parts) > 2 else c.nick + head = "PRIVMSG %s :" % target + body = head + ("x" * max(0, n - len(head))) + self.send_line(c, body); c.note("sent %d chars + %d eol bytes = %d" % (len(body), len(self.eol_bytes()), len(body) + len(self.eol_bytes()))) + elif cmd == "/noeol" and c is not None: + payload = text[len("/noeol "):] + self.send_bytes(c, payload.encode(), payload + " [no eol]"); c.note("sent %d bytes, no line ending" % len(payload)) + elif cmd == "/raw" and c is not None: + self.send_line(c, text[len("/raw "):]) + elif cmd == "/dcc" and c is not None: + self.dcc_receive(c) + elif cmd == "/send" and c is not None and len(parts) > 2: + self.dcc_send(c, parts[1], parts[2].strip()) + elif cmd == "/pause" and c is not None: + c.paused = True; c.note("PAUSED: this window stops reading (like Ctrl-Z on nc). Flood it from another window, watch its sendq, then /resume") + elif cmd == "/resume" and c is not None: + c.paused = False; c.note("resumed: everything the server queued is read now") + elif cmd == "/close" and c is not None: + try: + c.sock.close() + except OSError: + pass + c.closed = True; c.note("closed abruptly (no QUIT)") + elif cmd in ("/walk", "/demo"): + self.start_demo() + elif cmd == "/skip" and self.demo is not None: + self.demo_i = min(len(self.demo), self.demo_i + 1); self.msg = "skipped" + elif cmd == "/back" and self.demo is not None: + self.demo_i = max(0, self.demo_i - 1); self.msg = "back one step" + elif cmd == "/stop" and self.demo is not None: + self.demo = None; self.msg = "walkthrough stopped" + elif cmd == "/irssi" and len(parts) > 1 and parts[1].isdigit(): + k = max(1, min(5, int(parts[1]))) + self.msg = open_client_windows("irssi", list(NICKS[:k]), self.args.host, self.args.port, self.args.password, "windows") + elif cmd == "/help": + self.help_on = not self.help_on; self.msg = "help pane %s" % ("shown (PgUp/PgDn scroll it)" if self.help_on else "hidden") + elif cmd == "/log" and len(parts) > 1 and parts[1].isdigit(): + self.log_rows = max(0, min(30, int(parts[1]))); self.msg = "server log: %d rows" % self.log_rows + elif cmd == "/cols" and len(parts) > 1 and parts[1].isdigit(): + self.grid_cols = max(0, min(5, int(parts[1]))); self.msg = "client windows per row: %s" % (self.grid_cols or "auto") + elif cmd == "/helpw" and len(parts) > 1 and parts[1].isdigit(): + self.help_w = max(20, min(60, int(parts[1]))); self.msg = "help pane width: %d" % self.help_w + else: + self.msg = "unknown lab command: %s" % cmd + return + if c is None: + self.msg = "no client: /new first"; return + self.send_line(c, text) + + # ---- walkthrough: the next step is shown first; Enter runs it ---- + def start_demo(self): + want = ["alice", "bob", "carol"] + have = dict((cl.nick, cl) for cl in self.clients) + for n in want: + if n not in have: + self.new_client(n) + self.demo = [(w, l.replace("DCCBOT", self.bot), n) for (w, l, n) in DEMO_STEPS]; self.demo_i = 0 + self.msg = "walkthrough: the next step is shown above this line. Enter runs it; type a line to send something else first; /skip /back /stop" + + def walk_bar(self): + """One line describing the pending step, or '' when no walkthrough is running.""" + if self.demo is None: + return "" + if self.demo_i >= len(self.demo): + return "WALK done (%d steps). /stop to close, /back to repeat the last one" % len(self.demo) + who, line, what = self.demo[self.demo_i] + return "WALK %d/%d next: %s > %s %s" % (self.demo_i + 1, len(self.demo), who, line, what) + + def demo_step(self): + if self.demo_i >= len(self.demo): + self.msg = "walkthrough finished (/stop to close it)"; return + who, line, what = self.demo[self.demo_i]; self.demo_i += 1 + c = next((cl for cl in self.clients if cl.nick == who), None) + if c is None: + self.msg = "walkthrough: no client %s" % who; return + self.active = c.idx; self.focus_request = c.idx + if line == "@dcc": + time.sleep(0.3); self.pump(); self.dcc_receive(c) + else: + self.send_line(c, line) + self.msg = "ran %d/%d %s > %s" % (self.demo_i, len(self.demo), who, line) + + # ---- UI ---- + def run(self, stdscr): + curses.curs_set(1); stdscr.nodelay(True); stdscr.timeout(120); stdscr.keypad(True) + curses.start_color(); curses.use_default_colors() + many = curses.COLORS >= 256 + # colour pairs: 1 sent, 2 received, 3 error/red, 4 frame, 5 note, 6 focused frame, 7 title, 8 dim + pal = [(1, 214 if many else curses.COLOR_YELLOW), (2, 114 if many else curses.COLOR_GREEN), + (3, 203 if many else curses.COLOR_RED), (4, 67 if many else curses.COLOR_BLUE), + (5, 176 if many else curses.COLOR_MAGENTA), (6, 81 if many else curses.COLOR_CYAN), + (7, 223 if many else curses.COLOR_WHITE), (8, 245 if many else curses.COLOR_WHITE)] + for i, col in pal: + curses.init_pair(i, col, -1) + C_SENT, C_RECV, C_R, C_FRAME, C_NOTE, C_FOCUS, C_TITLE, C_DIM = [curses.color_pair(i) for i in range(1, 9)] + GRAD = [curses.color_pair(i) for i in (4, 6, 5, 2, 1)] # one frame colour per client pane + BOLD = curses.A_BOLD + G = glyphs() + last_q = 0 + if not self.clients: + for n in self.start_nicks: + self.new_client(n) + self.active = 0 + self.msg = ("%d clients registered (%s). Tab moves between panes and picks the client you type to; " + "the last Tab stop is the menu bar; /walk for the walkthrough" + % (len(self.clients), ", ".join(c.nick for c in self.clients))) + # focus: 0..n-1 client panes, LOG, HELP, MENU (the last three are constants below n is added) + LOG, HELP, MENU = "log", "help", "menu" + focus = 0 + self.hscroll_log = 0; self.help_h = 0 + # ---- view options, edited from the menu bar (same keys as the monitor) ---- + SHOWS = ("all", "sent", "received", "notes") + SORTS = ("number", "nick", "unread") + LOGFS = ("all", "in", "out", "errors") + opts = {"show": 0, "sort": 0, "logf": 0} + MENU_ITEMS = [ # (key, label, kind) + ("show", "windows show", "cycle"), ("sort", "order", "cycle"), ("cols", "per row", "int"), + ("log", "log rows", "int"), ("logf", "log shows", "cycle"), ("help", "help", "toggle"), + ("eol", "eol", "cycle"), ("walk", "walkthrough", "action"), ("new", "new client", "action"), + ("quit", "quit", "action"), + ] + menu_i = 0 + + def menu_value(key): + if key == "show": + return SHOWS[opts["show"]] + if key == "sort": + return SORTS[opts["sort"]] + if key == "cols": + return str(self.grid_cols) if self.grid_cols else "auto" + if key == "log": + return str(self.log_rows) if self.log_rows else "off" + if key == "logf": + return LOGFS[opts["logf"]] + if key == "help": + return "on" if self.help_on else "off" + if key == "eol": + return self.eol + if key == "walk": + return "next" if self.demo is not None else "start" + return "" + + def visible_panes(): + v = list(range(len(self.clients))) + if self.log_rows: + v.append(LOG) + if self.help_on: + v.append(HELP) + v.append(MENU) + return v + + def put(y, x, s, attr=0): + h, w = stdscr.getmaxyx() + if 0 <= y < h and 0 <= x < w: + lim = w - x - (1 if y == h - 1 else 0) + try: + stdscr.addstr(y, x, s[: max(0, lim)], attr) + except curses.error: + pass + + def box(y, x, hh, ww, title, attr, focused): + if hh < 2 or ww < 4: + return (y + 1, x + 1, max(0, hh - 2), max(0, ww - 2)) + a = (C_FOCUS | BOLD) if focused else attr + put(y, x, G["tl"] + G["h"] * (ww - 2) + G["tr"], a) + for i in range(1, hh - 1): + put(y + i, x, G["v"], a); put(y + i, x + ww - 1, G["v"], a) + put(y + hh - 1, x, G["bl"] + G["h"] * (ww - 2) + G["br"], a) + put(y, x + 2, (" %s " % title)[: ww - 4], (curses.A_REVERSE | BOLD) if focused else (a | BOLD)) + return (y + 1, x + 1, hh - 2, ww - 2) + + while not self.quit: + self.pump() + if time.time() - last_q > 1.0: + last_q = time.time() + q = kernel_sendq(self.args.port) + for c in self.clients: + try: + c.sendq = q.get("%s:%d" % c.sock.getsockname(), 0) if c.sock else 0 + except OSError: + pass + h, w = stdscr.getmaxyx(); stdscr.erase() + srv = REPORT.server + rss = ps_stats(srv.proc.pid)[0] if (srv and srv.proc) else 0 + n = len(self.clients) + fr = getattr(self, "focus_request", None) + if fr is not None: + focus = fr; self.focus_request = None + vis = visible_panes() + if focus not in vis: + focus = vis[0] + if isinstance(focus, int): + self.active = focus + act = self.clients[self.active] if self.clients else None + fname = {LOG: "server log", HELP: "help", MENU: "menu"}.get(focus, self.clients[focus].nick if isinstance(focus, int) and focus < n else "-") + put(0, 0, (" LAB server 127.0.0.1:%s pid %s RSS %d MB clients %d typing goes to: %s focus: %s" % ( + self.args.port, srv.proc.pid if (srv and srv.proc) else "-", rss, n, + act.nick if act else "-", fname)).ljust(w - 1), curses.A_REVERSE | BOLD) + # ---- menu bar (row 1) ---- + x = 0 + put(1, 0, " " * (w - 1), C_DIM) + for i, (key, label, kind) in enumerate(MENU_ITEMS): + val = menu_value(key) + txt = " %s%s " % (label, (": " + val) if val else "") + sel = (focus == MENU and i == menu_i) + put(1, x, txt, (curses.A_REVERSE | BOLD) if sel else (C_FOCUS if focus == MENU else C_DIM)) + x += len(txt) + 1 + tail = (" %s/%s pick Enter or Up/Down change b back" % (G["inn"], G["out"])) if focus == MENU else " Tab: panes, log, help, menu" + put(1, min(x, max(0, w - len(tail) - 1)), tail, C_SENT if focus == MENU else C_DIM) + + top = 2 + walk = self.walk_bar() + bottom = 2 + (1 if walk else 0) # msg + input (+ walk bar) + # ---- help pane (left) ---- + hw = self.help_w if self.help_on else 0 + log_rows = self.log_rows + if self.help_on: + hh_ = h - top - bottom - (log_rows + 2 if log_rows else 0) + iy, ix, ih, iw = box(top, 0, hh_, hw, "HELP", C_FRAME, focus == HELP) + self.help_scroll = max(0, min(self.help_scroll, max(0, len(LAB_HELP) - ih))) + for i, (k, d) in enumerate(LAB_HELP[self.help_scroll: self.help_scroll + ih]): + if d is None: + put(iy + i, ix, k[hw and self.help_h:][: iw], C_TITLE | BOLD if k else 0) + else: + line = ("%-22s %s" % (k, d))[self.help_h:] + put(iy + i, ix, line[:iw], C_DIM) + put(iy + i, ix, line[: min(iw, max(0, 22 - self.help_h))], C_RECV) + # ---- client panes (grid, right), in the chosen order ---- + gx = hw; gw = w - gx; nn = max(1, n) + cols = self.grid_cols or (3 if gw >= 105 else (2 if gw >= 70 else 1)) + cols = max(1, min(nn, cols)); rws = (nn + cols - 1) // cols + area_h = h - top - bottom - (log_rows + 2 if log_rows else 0); win_h = max(4, area_h // rws); win_w = gw // cols + order = list(self.clients) + if SORTS[opts["sort"]] == "nick": + order.sort(key=lambda cl: cl.nick.lower()) + elif SORTS[opts["sort"]] == "unread": + order.sort(key=lambda cl: -cl.sendq) + show = SHOWS[opts["show"]] + for pos, c in enumerate(order): + r, col = divmod(pos, cols) + x = gx + col * win_w; y = top + r * win_h + ww = (w - x) if col == cols - 1 else win_w + title = "%d %s sent %dB unread-by-it %s%s%s%s" % ( + c.idx + 1, c.nick, c.last_len, human_bytes(c.sendq), + " PAUSED" if c.paused else "", " CLOSED" if c.closed else "", + " <- typing here" if c.idx == self.active else "") + frame = C_R if (c.sendq >= 32768 or c.closed) else GRAD[c.idx % len(GRAD)] + iy, ix, ih, iw = box(y, x, win_h, ww, title, frame, focus == c.idx) + lines = [(d, t) for (d, t) in c.lines + if show == "all" or (show == "sent" and d == ">>") or (show == "received" and d == "<<") + or (show == "notes" and d not in (">>", "<<"))] + rows = [(d, ("%s %s" % ({">>": G["out"], "<<": G["inn"]}.get(d, G["dot"]), t))) for (d, t) in lines] + c.scroll = max(0, min(c.scroll, max(0, len(rows) - ih))) + shown = rows[max(0, len(rows) - c.scroll - ih): max(0, len(rows) - c.scroll)] + for i, (d, text) in enumerate(shown): + attr = C_SENT if d == ">>" else (C_RECV if d == "<<" else C_NOTE) + put(iy + i, ix, text[c.hscroll: c.hscroll + iw].ljust(iw), attr) + if c.scroll: + put(y + win_h - 1, x + ww - 14, " %s%d more " % (G["up"], c.scroll), C_DIM) + # ---- server log (bottom) ---- + if log_rows: + ly = h - log_rows - 2 - bottom + lf = LOGFS[opts["logf"]] + iy, ix, ih, iw = box(ly, 0, log_rows + 2, w, "SERVER LOG%s" % ("" if lf == "all" else " (%s only)" % lf), C_FRAME, focus == LOG) + if srv: + with srv.lock: + entries = list(srv.buffer) + if lf != "all": + entries = [(tt, txt) for (tt, txt) in entries + if (lf == "in" and "from fd" in txt.lower()) or (lf == "out" and "sendto fd" in txt.lower()) + or (lf == "errors" and ("error" in txt.lower() or "closing" in txt.lower()))] + self.log_scroll = max(0, min(self.log_scroll, max(0, len(entries) - ih))) + end = len(entries) - self.log_scroll + for i, (tt, txt) in enumerate(entries[max(0, end - ih): max(0, end)]): + low = txt.lower() + attr = C_RECV if "from fd" in low else (C_SENT if "sendto fd" in low else (C_R if ("error" in low or "closing" in low) else C_DIM)) + line = printable("%s %s" % (time.strftime("%H:%M:%S", time.localtime(tt)), re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d \S+\s+", "", txt))) + put(iy + i, ix, line[self.hscroll_log: self.hscroll_log + iw].ljust(iw), attr) + # ---- walk bar + message + input ---- + if walk: + put(h - 3, 0, walk[: w - 1].ljust(w - 1), C_FOCUS | BOLD) + put(h - 2, 0, (self.msg or "")[: w - 1].ljust(w - 1), C_NOTE | BOLD) + prompt = "[%s]> " % (act.nick if act else "-") + counter = " len=%d (+%d eol) " % (len(self.input), len(self.eol_bytes())) + put(h - 1, 0, (prompt + self.input).ljust(w - 1), curses.A_REVERSE) + put(h - 1, max(0, w - len(counter) - 1), counter, curses.A_REVERSE | C_SENT) + try: + stdscr.move(h - 1, min(w - 2, len(prompt) + len(self.input))) + except curses.error: + pass + stdscr.refresh() + try: + k = stdscr.getch() + except curses.error: + k = -1 + if k == -1: + continue + if k == 27: # raw ESC sequence (terminal not in keypad mode) + stdscr.nodelay(True) + seq = "" + for _ in range(3): + try: + ch = stdscr.getch() + except curses.error: + ch = -1 + if ch == -1: + break + seq += chr(ch) if 0 <= ch < 256 else "" + stdscr.timeout(120) + k = {"[D": curses.KEY_LEFT, "OD": curses.KEY_LEFT, "[C": curses.KEY_RIGHT, "OC": curses.KEY_RIGHT, + "[A": curses.KEY_UP, "OA": curses.KEY_UP, "[B": curses.KEY_DOWN, "OB": curses.KEY_DOWN, + "[Z": curses.KEY_BTAB, "[5~": curses.KEY_PPAGE, "[6~": curses.KEY_NPAGE}.get(seq, 27 if not seq else -1) + if k == -1: + continue + if k == 4: # Ctrl-D + self.quit = True; continue + vis = visible_panes() + if k == 9: # Tab: next visible pane (clients, log, help, menu) + focus = vis[(vis.index(focus) + 1) % len(vis)]; continue + if k == curses.KEY_BTAB: # Shift-Tab: previous + focus = vis[(vis.index(focus) - 1) % len(vis)]; continue + if focus == MENU: # menu bar: Left/Right pick, Enter/Up/Down change, b/Esc back + key, label, kind = MENU_ITEMS[menu_i] + if k == curses.KEY_LEFT: + menu_i = (menu_i - 1) % len(MENU_ITEMS) + elif k == curses.KEY_RIGHT: + menu_i = (menu_i + 1) % len(MENU_ITEMS) + elif k in (ord("b"), ord("B"), 27): + focus = vis[0] + elif k in (10, 13, curses.KEY_ENTER, curses.KEY_UP, curses.KEY_DOWN, ord(" ")): + step = -1 if k == curses.KEY_DOWN else 1 + if key in ("show", "sort", "logf"): + size = {"show": len(SHOWS), "sort": len(SORTS), "logf": len(LOGFS)}[key] + opts[key] = (opts[key] + step) % size + elif key == "cols": + self.grid_cols = (self.grid_cols + step) % 6 + elif key == "log": + self.log_rows = max(0, min(20, self.log_rows + 4 * step)) + elif key == "help": + self.help_on = not self.help_on + elif key == "eol": + e = ("crlf", "lf", "none"); self.eol = e[(e.index(self.eol) + step) % 3] + elif key == "walk": + if self.demo is None: + self.start_demo() + else: + self.demo_step() + self.focus_request = None; focus = MENU + elif key == "new": + used = set(cl.nick for cl in self.clients) + self.new_client(next((nk for nk in NICKS if nk not in used), None)); focus = MENU; self.focus_request = None + elif key == "quit": + self.quit = True + continue + if k in (10, 13, curses.KEY_ENTER): + text = self.input; self.input = ""; self.msg = "" + self.handle(text) + elif k in (curses.KEY_BACKSPACE, 127, 8): + self.input = self.input[:-1] + elif k == 12: # Ctrl-L: cycle layouts + if self.help_on and self.log_rows: + self.help_on = False; self.log_rows = 0; self.msg = "layout: client windows only (Ctrl-L again to cycle)" + elif not self.help_on and not self.log_rows: + self.help_on = True; self.msg = "layout: help + windows" + else: + self.help_on = True; self.log_rows = 8; self.msg = "layout: help + windows + server log" + elif k in (curses.KEY_UP, curses.KEY_DOWN, curses.KEY_PPAGE, curses.KEY_NPAGE, curses.KEY_LEFT, curses.KEY_RIGHT): + step = {curses.KEY_UP: 1, curses.KEY_DOWN: -1, curses.KEY_PPAGE: 10, curses.KEY_NPAGE: -10}.get(k, 0) + hstep = {curses.KEY_LEFT: -8, curses.KEY_RIGHT: 8}.get(k, 0) + if isinstance(focus, int): + c = self.clients[focus] + c.scroll = max(0, c.scroll + step); c.hscroll = max(0, c.hscroll + hstep) + elif focus == LOG: + self.log_scroll = max(0, self.log_scroll + step); self.hscroll_log = max(0, self.hscroll_log + hstep) + else: + self.help_scroll = max(0, self.help_scroll - step); self.help_h = max(0, self.help_h + hstep) + elif k == curses.KEY_RESIZE: + pass + elif 32 <= k <= 126: + self.input += chr(k) + for c in self.clients: + try: + if c.sock: + c.sock.close() + except OSError: + pass + + +NICKS = ("alice", "bob", "carol", "dave", "eve") + + +def nc_crlf_flag(): + """The flag that makes this nc send CRLF line endings: -c on macOS/BSD, -C on Linux (OpenBSD nc).""" + try: + h = subprocess.run(["nc", "-h"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT).stdout.decode(errors="ignore") + except OSError: + return "" + for line in h.splitlines(): + if "CRLF" in line: + m = re.search(r"-(c|C)\b", line) + if m: + return "-" + m.group(1) + return "-c" if sys.platform == "darwin" else "-C" + + +def client_command(kind, nick, host, port, password): + """Shell command for one client window: irssi already connected, or nc already registered.""" + if kind == "irssi": + return "irssi -c %s -p %d -w %s -n %s" % (host, port, password, nick) + flag = nc_crlf_flag() + reg = "PASS %s\\r\\nNICK %s\\r\\nUSER %s 0 * :%s\\r\\n" % (password, nick, nick, nick) + # registration is sent first, then whatever you type goes to the server as-is + return ("printf '== %s: registered as %s, type raw IRC lines (JOIN #x, PRIVMSG bob :hi) ==\\n'; " + "(printf '%s'; cat) | nc %s %s %d" % (nick, nick, reg, flag, host, port)) + + +def open_client_windows(kind, nicks, host, port, password, layout): + """Open one client per nick. layout: 'tmux' = panes in one tmux session in this terminal + (blocks until you detach with Ctrl-b d or close the panes); 'windows' = separate terminal + windows, tiled on macOS. Returns a status line.""" + cmds = [client_command(kind, n, host, port, password) for n in nicks] + if layout == "tmux": + sess = "ftirc" + subprocess.call(["tmux", "kill-session", "-t", sess], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.check_call(["tmux", "new-session", "-d", "-s", sess, "-n", kind, "bash -c %s" % shlex.quote(cmds[0])]) + for c in cmds[1:]: + subprocess.check_call(["tmux", "split-window", "-t", sess, "bash -c %s" % shlex.quote(c)]) + subprocess.call(["tmux", "select-layout", "-t", sess, "tiled"]) + subprocess.call(["tmux", "set-option", "-t", sess, "pane-border-status", "top"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for i, n in enumerate(nicks): + subprocess.call(["tmux", "select-pane", "-t", "%s:0.%d" % (sess, i), "-T", n], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.call(["tmux", "select-pane", "-t", "%s:0.0" % sess]) + print("%s%d %s pane(s) in one tmux window: Ctrl-b + arrow = move between panes, Ctrl-b z = zoom one, Ctrl-b d = detach (comes back here).%s" + % (DIM, len(nicks), kind, RESET)) + subprocess.call(["tmux", "attach", "-t", sess]) + return "tmux session ended (%s)" % kind + opened = 0 + for i, c in enumerate(cmds): + try: + if sys.platform == "darwin": + # tile: two columns, rows of 380 px, from the top-left of the main screen + col, row = i % 2, i // 2 + x1, y1 = 20 + col * 700, 40 + row * 380 + script = ('tell application "Terminal"\n activate\n set w to do script "%s"\n' + ' set bounds of front window to {%d, %d, %d, %d}\n end tell' % (c.replace('"', '\\"'), x1, y1, x1 + 680, y1 + 360)) + subprocess.Popen(["osascript", "-e", script], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + opened += 1 + else: + for term in (["gnome-terminal", "--"], ["konsole", "-e"], ["xfce4-terminal", "-e"], ["x-terminal-emulator", "-e"], ["xterm", "-e"]): + try: + subprocess.Popen(term + ["bash", "-c", c], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + opened += 1; break + except OSError: + continue + except OSError: + pass + return "opened %d/%d %s window(s): %s" % (opened, len(cmds), kind, ", ".join(nicks)) + + +def have(prog): + return any(os.access(os.path.join(d, prog), os.X_OK) for d in os.getenv("PATH", "").split(os.pathsep)) + + +CHEATSHEET = """ +Cheat-sheet commands + irssi (Alt-1 = status window, Alt-2.. = channel/query windows) + /connect or start it: irssi -c -p -w -n + /join #chan /part #chan [msg] /topic #chan + /msg /notice /me waves + /mode #chan +i /mode #chan +t /mode #chan +k /mode #chan +l + /mode #chan +o /kick #chan [why] /invite #chan + /msg DCCBot !help /msg DCCBot !send /msg DCCBot !accept + /rawlog open x.log (writes every wire line) /quit [msg] + nc (raw IRC, one command per line; the window is already registered) + JOIN #chan PART #chan :msg TOPIC #chan :text + PRIVMSG :text PRIVMSG #chan :text NOTICE :text + MODE #chan +i MODE #chan +t MODE #chan +k key MODE #chan +l 3 + MODE #chan +o KICK #chan :why INVITE #chan + PRIVMSG DCCBot :!help PRIVMSG DCCBot :!send PRIVMSG DCCBot :!accept + PING :x QUIT :bye +""" + +# =========================================================================== +# Terminal chrome, menu, main +# =========================================================================== + +def intro(args): + if args.no_intro: + return + if sys.stdout.isatty(): + os.system("clear") + print("%s%s%s%s" % (BOLD, BLUE, FT_BANNER, RESET)) + print("%s%sInspectable ft_irc tester -- mandatory, robustness, load, bonus, lab.%s %s(inspired by the black-wolf style testers)%s" % (BOLD, CYAN, RESET, DIM, RESET)) + + +def banner(args): + print("%s%s%s" % (MAGENTA, "-" * 62, RESET)); print("%sft_irc tester%s" % (BOLD, RESET)) + print("%smandatory%s %s" % (DIM, RESET, args.binary)); print("%sbonus %s %s" % (DIM, RESET, args.bonus_binary)) + print("%shost %s %s %spassword%s %s" % (DIM, RESET, args.host, DIM, RESET, args.password)) + print("%sprofile %s %s %s(--show-config to see all)%s" % (DIM, RESET, profile_summary(), DIM, RESET)) + print("%soutput %s %s" % (DIM, RESET, args.outdir)); print("%s%s%s" % (MAGENTA, "-" * 62, RESET)) + + +def print_summary(): + t = REPORT.totals(); tot = sum(t.values()); print() + print("%s%s%s" % (MAGENTA, "-" * 62, RESET)) + print(" Results: %s%d pass%s / %s%d fail%s / %s%d notes%s / %s%d info%s / %s%d skip%s / %d total" + % (GREEN, t[PASS], RESET, RED, t[FAIL], RESET, YELLOW, t[NOTE], RESET, DIM, t[INFO], RESET, DIM, t[SKIP], RESET, tot)) + if t[FAIL] == 0: + print(" %s%sNo failures.%s %sNOTE = behaviour the subject leaves open, INFO = a measurement.%s" % (GREEN, BOLD, RESET, DIM, RESET)) + else: + print(" %s%s%d real failure(s) -- see the report.%s" % (RED, BOLD, t[FAIL], RESET)) + print("%s%s%s" % (MAGENTA, "-" * 62, RESET)) + + +def raw_terminal(): + print("\n%s%s[ Raw IRC terminal ]%s type commands; /quit to leave\n" % (BOLD, CYAN, RESET)) + nick = input("Nick [blackwolf]: ").strip() or "blackwolf" + c = connect(); ok, buf = register(c, nick) + if buf: + print("%s%s%s" % (DIM, buf.rstrip(), RESET)) + try: + while True: + ready, _, _ = select.select([c, sys.stdin], [], [], 0.2) + if PENDING.get(c): + print("%s%s%s" % (CYAN, PENDING.pop(c).rstrip(), RESET)) + if c in ready: + data = c.recv(4096) + if not data: + print("\n%sServer closed the connection.%s" % (RED, RESET)); break + print("%s%s%s" % (CYAN, data.decode(errors="ignore").rstrip(), RESET)) + if sys.stdin in ready: + line = sys.stdin.readline() + if not line: + break + line = line.rstrip("\n") + if line == "/quit": + break + send(c, line) + finally: + safe_close(c) + + +def choose_mode(): + print("\n%s%sChoose mode%s" % (BOLD, CYAN, RESET)) + for k, lbl, desc in (("1", "mandatory", "subject command set + numerics"), + ("2", "robustness", "framing, limits, slow readers, churn, many clients"), + ("3", "load", "slow-reader load: N clients reading 1 byte at a time"), + ("4", "bonus", "DCC bot + file transfer (needs the bonus binary)"), + ("5", "all", "everything + with/no-bot capture"), + ("6", "compare", "with-bot vs no-bot capture only"), + ("7", "lab", "interactive: up to 5 client windows + server log + help + guided demo"), + ("8", "clients", "N irssi or nc client windows on a launched server (tmux panes or separate windows)"), + ("9", "profile", "show the implementation profile, then edit it if you want"), + ("0", "quit", "")): + print(" %s%s%s %-12s %s" % (GREEN, k, RESET, lbl, desc)) + print(" %sadd 'live' to a mode number to watch it run, e.g. '5 live'%s" % (DIM, RESET)) + ans = ask("\n%sSelect [default: 5 = all]: %s" % (BOLD, RESET), "0").strip().lower() or "5" + live = ans.endswith("live"); ans = ans.replace("live", "").strip() or "5" + m = {"1": "mandatory", "2": "robustness", "3": "load", "4": "bonus", "5": "all", "6": "compare", + "7": "lab", "8": "clients", "9": "profile", "0": "quit"}.get(ans, ans) + return (m, live) + + +def parse_args(): + p = argparse.ArgumentParser(description="Inspectable ft_irc tester.", formatter_class=argparse.ArgumentDefaultsHelpFormatter) + p.add_argument("--host", default=os.getenv("IRC_TEST_HOST", "127.0.0.1")) + p.add_argument("--port", type=int, default=int(os.getenv("IRC_TEST_PORT", "6667")), help="the server is always started on this port") + p.add_argument("--password", default=os.getenv("IRC_TEST_PASSWORD", "pass")) + p.add_argument("--binary", default=os.getenv("IRC_TEST_BINARY", "../../ircserv"), help="mandatory server binary") + p.add_argument("--bonus-binary", dest="bonus_binary", default=os.getenv("IRC_TEST_BONUS_BINARY", "../../ircserv_bonus")) + p.add_argument("--mode", choices=("menu", "mandatory", "robustness", "load", "bonus", "all", "compare", "lab", "irssi", "clients", "nc", "terminal"), default="menu") + p.add_argument("--live", action="store_true", help="watch the server window and client windows while the suite runs") + p.add_argument("--step", action="store_true", help="with --live: pause after every scenario (Enter = next, c = continue)") + p.add_argument("--no-start", action="store_true", help="use an already-running server on --host/--port") + p.add_argument("--no-intro", action="store_true"); p.add_argument("--fast", action="store_true") + p.add_argument("--debug", action="store_true", help="print every line sent/received live") + p.add_argument("--outdir", default="artifacts"); p.add_argument("--report", default=None) + p.add_argument("--no-html", action="store_true") + p.add_argument("--config", default="tester.conf", help="profile file (key=value lines)") + p.add_argument("--set", action="append", default=[], metavar="KEY=VALUE", help="override a profile option (repeatable)") + p.add_argument("--show-config", action="store_true", help="print the implementation profile and exit") + # legacy flags accepted silently + p.add_argument("--clients", type=int, default=None, help=argparse.SUPPRESS) + p.add_argument("--bot-nick", dest="bot_nick", default=None, help=argparse.SUPPRESS) + p.add_argument("--timeout", type=float, default=None, help=argparse.SUPPRESS) + p.add_argument("--server-log", default=None, help=argparse.SUPPRESS) + return p.parse_args() + + +def run_mode(mode, args): + """Run one mode to completion; returns True if a report was produced.""" + global TIMEOUT + extra_files = [] + real_stdout = sys.stdout + if mode == "lab": + if not (HAVE_CURSES and sys.stdout.isatty()): + print("the lab needs a terminal (curses)."); return False + n = ask("How many clients to open (1-5) [3]: ", "3").strip() or "3" + n = max(1, min(5, int(n))) if n.isdigit() else 3 + names = ask("Nicks, comma separated [%s]: " % ",".join(NICKS[:n]), "").strip() + nicks = [x.strip() for x in names.split(",") if x.strip()][:n] if names else list(NICKS[:n]) + while len(nicks) < n: + nicks.append(NICKS[len(nicks)]) + srv = None if args.no_start else start_phase_server(args.binary) + lab = Lab(args, nicks) + try: + curses.wrapper(lab.run) + finally: + stop_phase_server(srv) + return False + if mode in ("irssi", "nc", "clients"): + kind = mode if mode != "clients" else "" + if not kind: + kind = ask("Client program: 1) irssi 2) nc (raw IRC, already registered) [1]: ", "1").strip() + kind = "nc" if kind in ("2", "nc") else "irssi" + if not have(kind): + print("%s%s is not installed (brew install %s / apt install %s)%s" % (RED, kind, kind, kind, RESET)); return False + n = ask("How many %s clients (1-5) [3]: " % kind, "3").strip() or "3" + n = max(1, min(5, int(n))) if n.isdigit() else 3 + names = ask("Nicks, comma separated [%s]: " % ",".join(NICKS[:n]), "").strip() + nicks = [x.strip() for x in names.split(",") if x.strip()][:n] if names else list(NICKS[:n]) + while len(nicks) < n: + nicks.append(NICKS[len(nicks)]) + if have("tmux"): + lay = ask("Layout: 1) panes in this terminal (tmux) 2) separate windows [1]: ", "1").strip() + layout = "windows" if lay == "2" else "tmux" + else: + layout = "windows"; print("%s(tmux not installed: opening separate windows; brew install tmux gives panes in one terminal)%s" % (DIM, RESET)) + print(CHEATSHEET) + srv = None if args.no_start else start_phase_server(args.binary) + print(open_client_windows(kind, nicks, args.host, args.port, args.password, layout)) + if srv is not None: + if layout == "windows": + print("Server running on port %d for the %s windows. Press Enter here to stop it." % (args.port, kind)) + ask("") + stop_phase_server(srv) + return False + try: + if args.live and HAVE_CURSES and sys.stdout.isatty(): + sys.stdout = io.StringIO() + ui = LiveUI(mode, extra_files) + REPORT.step = bool(args.step) + try: + curses.wrapper(ui.run) + finally: + sys.stdout = real_stdout + if ui.error: + print("%s[live] %s%s" % (RED, ui.error, RESET)) + else: + run_everything(mode, extra_files) + except KeyboardInterrupt: + sys.stdout = real_stdout; print("\nInterrupted.") + finally: + sys.stdout = real_stdout + if REPORT.server is not None: + stop_phase_server(REPORT.server) + cleanup_sample_file() + print_summary() + write_transcripts(args.outdir) + extra_files.append(("artifacts/transcripts/", "raw per-scenario screens (client + server)")) + if not args.no_html: + write_html(args.report, extra_files) + try: + import pathlib + uri = pathlib.Path(os.path.abspath(args.report)).as_uri() + except Exception: + uri = os.path.abspath(args.report) + print("\n%sHTML report:%s %s %s(Cmd-click / Ctrl-click, or: make open)%s" % (BOLD, RESET, uri, DIM, RESET)) + print("%sTranscripts:%s %s" % (BOLD, RESET, os.path.join(args.outdir, "transcripts"))) + return True + + +def main(): + global REPORT, TIMEOUT, PASSWORD + args = parse_args() + atexit.register(_kill_servers) + for sig in (signal.SIGTERM, signal.SIGHUP): + try: + signal.signal(sig, _on_signal) + except (OSError, ValueError, AttributeError): + pass + try: + signal.signal(signal.SIGPIPE, signal.SIG_DFL) + except (OSError, ValueError, AttributeError): + pass + try: + load_profile(args.config, args.set) + if args.clients is not None: + profile_set("clients", args.clients, "--clients") + if args.bot_nick: + profile_set("bot_nick", args.bot_nick, "--bot-nick") + if args.timeout is not None: + profile_set("timeout", args.timeout, "--timeout") + except ValueError as e: + print("profile error: %s" % e); sys.exit(2) + if args.show_config: + show_config(); return + TIMEOUT = PROFILE["timeout"]; PASSWORD = args.password + args.outdir = os.path.abspath(args.outdir); os.makedirs(args.outdir, exist_ok=True) + if args.report is None: + args.report = os.path.join(args.outdir, "report.html") + + intro(args); banner(args) + if args.mode != "menu": + REPORT = Report(args) + try: + run_mode("lab" if args.mode == "terminal" else args.mode, args) + except KeyboardInterrupt: + _kill_servers(); print("\n%sInterrupted; server stopped.%s" % (YELLOW, RESET)) + sys.exit(1 if REPORT.totals()[FAIL] else 0) + + # menu loop: come back here after every mode instead of quitting + worst = 0 + while True: + mode, live = choose_mode() + if mode == "quit": + break + if mode == "profile": + configure_interactive(); TIMEOUT = PROFILE["timeout"]; continue + if mode not in ("mandatory", "robustness", "load", "bonus", "all", "compare", "lab", "irssi", "nc", "clients"): + print("unknown choice"); continue + args.live = live + REPORT = Report(args) + try: + run_mode(mode, args) + except KeyboardInterrupt: + _kill_servers(); print("\n%sInterrupted; server stopped.%s" % (YELLOW, RESET)) + worst = max(worst, REPORT.totals()[FAIL]) + print("\n%s(back to the menu -- 0 to quit)%s" % (DIM, RESET)) + sys.exit(1 if worst else 0) + + +if __name__ == "__main__": + main() diff --git a/tests/dynamic_tester/tester.conf b/tests/dynamic_tester/tester.conf new file mode 100644 index 0000000..3ed3246 --- /dev/null +++ b/tests/dynamic_tester/tester.conf @@ -0,0 +1,21 @@ +# tester.conf -- what the tester expects where the subject leaves a choice. +# Every line commented out = the default shown. Uncomment and edit to match the +# server; a wrong entry shows as NOTE or FAIL in the affected checks. +# python3 test_irc.py --show-config prints the current values. +# +# line_endings = lf_ok # lf_ok | crlf_only accept a bare \n (nc sends it) or require \r\n +# max_line = 510 # message bytes without CRLF (RFC 2812: 510) +# overlong = reject # reject | truncate | ignore what an over-long line gets +# overlong_strikes = 3 # close after N over-long lines in a row; 0 = never +# slow_client = keep # keep | drop a client that never reads +# missing_mode_arg = any # 461 | specific | any reply to MODE +k/+l/+o with no argument +# user_modes = none # none | supported +# welcome_burst = yes # yes | no 002 003 004 005 after 001 +# casemapping = rfc1459 # rfc1459 | ascii | none +# nonmember_send = reject # reject | deliver PRIVMSG to a channel you are not in +# bot_nick = DCCBot # bonus bot nickname +# clients = 20 # many-clients scenario +# slow_clients = 30 # slow-reader load scenario +# rcvbuf = 2048 # receive buffer of the stalled/slow test clients +# flood_mb = 4 # MB queued toward the stalled client in the flood scenario +# timeout = 4.0 # seconds to wait for a reply per check diff --git a/tests/genprint.py b/tests/genprint.py deleted file mode 100644 index fe6c499..0000000 --- a/tests/genprint.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -"""Print a random printable-ASCII string with random-length character runs. - -Usage: python3 genprint.py [max_len] -""" -import random -import sys - -CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .!?," - - -def rand_print(chars=CHARS, char_rand_max=5, str_rand_max=80): - return "".join( - c * random.randint(1, char_rand_max) - for _ in range(random.randint(1, str_rand_max)) - for c in random.choice(chars) - ) - - -if __name__ == "__main__": - print(rand_print() if len(sys.argv) < 2 else rand_print(str_rand_max=int(sys.argv[1]))) \ No newline at end of file diff --git a/tests/reap_test.py b/tests/reap_test.py deleted file mode 100644 index 7b7c0a8..0000000 --- a/tests/reap_test.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python3 -# Proves the loop reaps a send-failing socket. The stalled member's receive -# buffer is shrunk BEFORE connect and the flood is large enough (>= 30 MB) to -# close its window even with macOS loopback autotuning, forcing the server's -# sends to fail -> closeFd -> marker sweep reaps it. -# python3 reap_test.py [port] [password] -import socket -import sys -import time - -HOST = "127.0.0.1" -PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 6667 -PW = sys.argv[2] if len(sys.argv) > 2 else "pass" -NMSG = int(sys.argv[3]) if len(sys.argv) > 3 else 60000 # ~30 MB - - -def make_client(buf=0): - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - if buf: - s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, buf) # before connect - s.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, buf) - s.connect((HOST, PORT)) - return s - - -def reg(s, nick): - s.sendall(("PASS %s\r\nNICK %s\r\nUSER u 0 * :x\r\n" % (PW, nick)).encode()) - time.sleep(0.25) - s.setblocking(False) - try: - while s.recv(4096): - pass - except BlockingIOError: - pass - s.setblocking(True) - - -st = make_client(buf=1024) -reg(st, "stall") -sndr = make_client(0) -reg(sndr, "sndr") -st.sendall(b"JOIN #c\r\n") -sndr.sendall(b"JOIN #c\r\n") -time.sleep(0.4) -for s in (sndr, st): - s.setblocking(False) - try: - while s.recv(4096): - pass - except BlockingIOError: - pass - s.setblocking(True) - -st.setblocking(False) -print("flooding #c with %d messages (~%.0f MB)..." % (NMSG, NMSG * 0.00051)) -t0 = time.time() -for k in range(NMSG): - sndr.sendall(("PRIVMSG #c :%08d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\r\n" % k).encode()) - if k % 500 == 0: - time.sleep(0.002) -print("flood done in %.1fs; watching up to 10s for the close..." % (time.time() - t0)) - -closed = False -end = time.time() + 10 -while time.time() < end: - try: - c = st.recv(4096) - except BlockingIOError: - time.sleep(0.05) - continue - if not c: - closed = True - break -st.close() -sndr.close() -if closed: - print(" stalled connection reaped -> PASS (loop sweep works)") -else: - print(" still open after 10s.") - print(" NEXT: while it runs, watch the server console for 'Sendto FD' lines") - print(" naming the stalled client's fd - if those keep coming and never stop,") - print(" the window never closed (autotune ate the flood).") \ No newline at end of file diff --git a/tests/src/test_client.cpp b/tests/src/test_client.cpp deleted file mode 100644 index 49f2b3a..0000000 --- a/tests/src/test_client.cpp +++ /dev/null @@ -1,186 +0,0 @@ -#include "Client.hpp" - -#include -#include -#include - -/* Tests for Client buffering and connection state. */ - -static int failures = 0; - -static void report(bool ok, const std::string &what) { - std::cout << (ok ? " ok " : " FAIL ") << what << "\n"; - if (!ok) - failures++; -} - -static void expectLine(Client &client, const std::string &want) { - std::string line; - if (!client.extractLine(line)) { - report(false, "expected a line \"" + want + "\", got none"); - return; - } - report(line == want, "line is \"" + want + "\"" + - (line == want ? "" : ", got \"" + line + "\"")); -} - -static void expectNoLine(Client &client, const std::string &why) { - std::string line; - report(!client.extractLine(line), why); -} - -int main() { - std::cout << "=== Client ===\n\n"; - - { - std::cout << "[one complete line]\n"; - Client c; - c.appendToRecvBuffer("NICK bob\r\n"); - expectLine(c, "NICK bob"); - expectNoLine(c, "buffer is empty afterwards"); - } - - // TCP may split one command across multiple recv() calls. - { - std::cout << "[command split across three recv() calls]\n"; - Client c; - c.appendToRecvBuffer("NI"); - expectNoLine(c, "nothing yet after \"NI\""); - c.appendToRecvBuffer("CK bo"); - expectNoLine(c, "still nothing after \"CK bo\""); - c.appendToRecvBuffer("b\r\n"); - expectLine(c, "NICK bob"); - } - - // One recv() may contain several complete commands. - { - std::cout << "[three commands in one chunk]\n"; - Client c; - c.appendToRecvBuffer("PASS x\r\nNICK bob\r\nUSER b 0 * :B\r\n"); - expectLine(c, "PASS x"); - expectLine(c, "NICK bob"); - expectLine(c, "USER b 0 * :B"); - expectNoLine(c, "buffer is empty afterwards"); - } - - { - std::cout << "[combined chunk may exceed one message limit]\n"; - Client c; - const std::string first(300, 'A'); - const std::string second(300, 'B'); - c.appendToRecvBuffer(first + "\r\n" + second + "\r\n"); - expectLine(c, first); - expectLine(c, second); - expectNoLine(c, "both individually valid lines were extracted"); - } - - { - std::cout << "[complete line plus a partial one]\n"; - Client c; - c.appendToRecvBuffer("NICK bob\r\nUSE"); - expectLine(c, "NICK bob"); - expectNoLine(c, "\"USE\" is held until the rest arrives"); - c.appendToRecvBuffer("R b 0 * :B\r\n"); - expectLine(c, "USER b 0 * :B"); - } - - // Accept bare '\n' for nc testing. - { - std::cout << "[bare newline from nc]\n"; - Client c; - c.appendToRecvBuffer("NICK bob\n"); - expectLine(c, "NICK bob"); - } - - { - std::cout << "[empty line]\n"; - Client c; - c.appendToRecvBuffer("\r\n"); - expectLine(c, ""); - } - - { - std::cout << "[nick is stored casefolded as well]\n"; - Client c; - c.setNick("BoB[\\~"); - report(c.getNick() == "BoB[\\~", "getNick keeps the chosen spelling"); - report(c.getNickCaseFolded() == "bob{|^", - "getNickCaseFolded uses IRC case mapping"); - } - - { - std::cout << "[invites are casefolded and unique]\n"; - Client c; - c.addInvite("#TeSt[Room]"); - c.addInvite("#test{room}"); - report(c.isInvitedTo("#TEST[ROOM]"), - "equivalent channel spellings match"); - c.removeInvite("#test{room}"); - report(!c.isInvitedTo("#TeSt[Room]"), - "one removal clears the unique invite"); - } - - { - std::cout << "[registration state starts empty]\n"; - Client c; - report(!c.isRegistered(), "a new client is not registered"); - report(!c.hasPassAccepted(), "a new client has not sent PASS"); - report(!c.hasIdentity(), "a new client has neither NICK nor USER"); - } - - // NICK and USER may arrive in either order. - { - std::cout << "[NICK then USER]\n"; - Client c; - c.setNick("bob"); - report(!c.hasIdentity(), "NICK alone is not enough"); - c.setUser("b", "Bob Example"); - report(c.hasIdentity(), "NICK and USER together are enough"); - } - - { - std::cout << "[USER then NICK]\n"; - Client c; - c.setUser("b", "Bob Example"); - report(!c.hasIdentity(), "USER alone is not enough"); - c.setNick("bob"); - report(c.hasIdentity(), "the other order works the same"); - } - - { - std::cout << "[prefix]\n"; - Client c; - report(c.getPrefix() == "*", "prefix is * without an identity"); - c.setNick("bob"); - report(c.getPrefix() == "*", "prefix is * with only a nickname"); - c.setUser("b", "Bob Example"); - report(c.getPrefix() == "bob!b@localhost", - "prefix falls back to localhost without a hostname"); - c.setHostname("127.0.0.1"); - report(c.getPrefix() == "bob!b@127.0.0.1", "prefix is nick!user@host"); - } - - { - std::cout << "[send queues output]\n"; - Client c; - report(!c.hasPendingOutput(), "nothing queued at first"); - c.send("PING :x\r\n"); - report(c.hasPendingOutput(), "output is queued"); - report(c.getSendBuffer() == "PING :x\r\n", "buffer holds the message"); - c.consumeSendBuffer(5); - report(c.getSendBuffer() == ":x\r\n", - "a partial write leaves the tail"); - c.consumeSendBuffer(100); - report(!c.hasPendingOutput(), "consuming past the end empties it"); - c.send("NOTICE bob :queued\r\n"); - c.clearSendBuffer(); - report(!c.hasPendingOutput(), "clearSendBuffer discards all output"); - } - - std::cout << "\n"; - if (failures == 0) - std::cout << "all checks passed\n"; - else - std::cout << failures << " check(s) failed\n"; - return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; -} diff --git a/tests/src/test_cmds2.cpp b/tests/src/test_cmds2.cpp deleted file mode 100644 index 00db389..0000000 --- a/tests/src/test_cmds2.cpp +++ /dev/null @@ -1,211 +0,0 @@ -#include "Client.hpp" -#include "CommandProcessor.hpp" -#include "Errors.hpp" -#include "Server.hpp" - -#include -#include -#include - -/* - * Tests for QUIT, PING, PONG (Connection.cpp) and NOTICE (Messaging.cpp), - * driven through CommandProcessor::run exactly as the event loop does, without - * any socket. Clients are put into the registered state directly so this test - * does not depend on the registration commands or on ClientManager's shape. - */ - -static int failures = 0; - -static void report(bool ok, const std::string &what) { - std::cout << (ok ? " ok " : " FAIL ") << what << "\n"; - if (!ok) - failures++; -} - -static bool contains(const std::string &hay, const std::string &needle) { - return hay.find(needle) != std::string::npos; -} - -// A registered client with a nick, user and host, plus a way to feed it lines. -// The Client is owned by the server's ClientManager, like in the real loop. -struct Conn { - Server &server; - CommandProcessor cmds; - Client &client; - bool closed; - std::string error; - - static int nextFd() { - static int fd = 4; - return fd++; - } - - Conn(Server &s, const std::string &nick) - : server(s), client(*new Client(nextFd())), closed(false) { - cmds.registerCommands(); - s.getClientManager().add(client.getFd(), &client); - s.getClientManager().setNick(client, nick); - client.setUser(nick.substr(0, 1), nick); - client.setHostname("h"); - client.setRegistered(true); - } - - bool send(const std::string &line) { - error.clear(); - try { - closed = cmds.run(server, client, line) || closed; - } catch (const Error &e) { - error = e.hasReply() ? Reply::build(server.getServerName(), - client.getNick(), e.reply()) - : e.what(); - return false; - } - return true; - } - - std::string drain() { - std::string s = client.getSendBuffer(); - client.clearSendBuffer(); - return s; - } -}; - -int main() { - std::cout << "=== cmds2: QUIT PING PONG NOTICE ===\n\n"; - - { - std::cout << "[PING / PONG]\n"; - Server server(6667, "secret"); - Conn c(server, "bob"); - report(c.send("PING abc") && - c.drain() == ":irc.local PONG irc.local :abc\r\n", - "PING -> PONG :"); - report(!c.send("PING") && - contains(c.error, " 409 bob :No origin specified"), - "PING without token -> 409 (not 461)"); - report(c.send("PONG abc") && c.drain().empty(), - "PONG accepted silently"); - - Client fresh(9); // not registered: PING must still work - CommandProcessor cp; - cp.registerCommands(); - bool threw = false; - try { - cp.run(server, fresh, "PING x"); - } catch (const Error &) { - threw = true; - } - report(!threw && contains(fresh.getSendBuffer(), "PONG irc.local :x"), - "PING is usable before registration"); - } - - { - std::cout << "[QUIT reaches channel peers exactly once]\n"; - Server server(6667, "secret"); - Conn a(server, "alice"); - Conn b(server, "bob"); - Conn c(server, "carol"); - ChannelManager &cm = server.getChannelManager(); - cm.join(a.client, "#x"); - cm.join(b.client, "#x"); - cm.join(a.client, "#y"); - cm.join(b.client, "#y"); // alice and bob share two channels - a.drain(); - b.drain(); - c.drain(); - - report(a.send("QUIT :bye") && a.closed, - "QUIT returns true: the loop must close the link"); - report(a.drain() == "ERROR :Closing link: (alice!a@h) [Quit: bye]\r\n", - "quitter gets ERROR :Closing link (no prefix)"); - report(b.drain() == ":alice!a@h QUIT :bye\r\n", - "peer in two shared channels sees the QUIT once"); - report(c.drain().empty(), "someone sharing no channel sees nothing"); - - Conn d(server, "dave"); - report(d.send("QUIT") && d.closed && - contains(d.drain(), "[Quit: Client Quit]"), - "QUIT without a message uses the default reason"); - - Client fresh; // unregistered client may QUIT too - CommandProcessor cp; - cp.registerCommands(); - report(cp.run(server, fresh, "QUIT") && - contains(fresh.getSendBuffer(), "ERROR :Closing link: (*)"), - "QUIT before registration closes with prefix *"); - } - - { - std::cout << "[NOTICE delivers like PRIVMSG and never replies]\n"; - Server server(6667, "secret"); - Conn a(server, "alice"); - Conn b(server, "bob"); - Conn c(server, "carol"); - ChannelManager &cm = server.getChannelManager(); - cm.join(a.client, "#room"); - cm.join(b.client, "#room"); - a.drain(); - b.drain(); - - report(a.send("NOTICE #room :psst") && a.drain().empty(), - "channel NOTICE: sender is not echoed"); - report(b.drain() == ":alice!a@h NOTICE #room :psst\r\n", - "channel NOTICE: other member receives it"); - report(c.send("NOTICE #room :hi") && c.drain().empty(), - "NOTICE to a channel you are not in -> silence (no 404)"); - report(c.send("NOTICE #nochan :hi") && c.drain().empty(), - "NOTICE to an unknown channel -> silence (no 403)"); - report(c.send("NOTICE nobody :hi") && c.drain().empty(), - "NOTICE to an unknown nick -> silence (no 401)"); - report(c.send("NOTICE") && c.drain().empty(), - "NOTICE without target -> silence (no 411)"); - report(c.send("NOTICE #room") && c.drain().empty(), - "NOTICE without text -> silence (no 412)"); - report(a.send("NOTICE bob :hey") && a.drain().empty() && - b.drain() == ":alice!a@h NOTICE bob :hey\r\n", - "NOTICE to a nick: only that nick gets it"); - } - - { - std::cout << "[PRIVMSG delivers and replies on errors]\n"; - Server server(6667, "secret"); - Conn a(server, "alice"); - Conn b(server, "bob"); - Conn c(server, "carol"); - ChannelManager &cm = server.getChannelManager(); - cm.join(a.client, "#room"); - cm.join(b.client, "#room"); - a.drain(); - b.drain(); - - report(a.send("PRIVMSG #room :hello") && a.drain().empty(), - "channel PRIVMSG: sender is not echoed"); - report(b.drain() == ":alice!a@h PRIVMSG #room :hello\r\n", - "channel PRIVMSG: other member receives it"); - report(a.send("PRIVMSG bob :hi bob") && a.drain().empty() && - b.drain() == ":alice!a@h PRIVMSG bob :hi bob\r\n", - "PRIVMSG to a nick"); - report(c.send("PRIVMSG #room :hi") && - contains(c.drain(), " 404 carol #room "), - "PRIVMSG to a channel you are not in -> 404"); - report(c.send("PRIVMSG #nochan :hi") && - contains(c.drain(), " 403 carol #nochan "), - "PRIVMSG to an unknown channel -> 403"); - report(c.send("PRIVMSG nobody :hi") && - contains(c.drain(), " 401 carol nobody "), - "PRIVMSG to an unknown nick -> 401"); - report(c.send("PRIVMSG") && contains(c.drain(), " 411 carol "), - "PRIVMSG without target -> 411"); - report(c.send("PRIVMSG #room") && contains(c.drain(), " 412 carol "), - "PRIVMSG without text -> 412"); - report(a.send("PRIVMSG bob,#room :both") && a.drain().empty() && - b.drain() == ":alice!a@h PRIVMSG bob :both\r\n" - ":alice!a@h PRIVMSG #room :both\r\n", - "PRIVMSG with two targets"); - } - - std::cout << "\n" - << (failures ? "FAILED" : "ALL OK") << " (" << failures - << " failures)\n"; - return failures ? EXIT_FAILURE : EXIT_SUCCESS; -} diff --git a/tests/src/test_reply.cpp b/tests/src/test_reply.cpp deleted file mode 100644 index 396a643..0000000 --- a/tests/src/test_reply.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "Errors.hpp" -#include "Reply.hpp" - -#include - -int main() { - std::cout << Reply::build("irc.local", "alice", - Reply::reply(Reply::ERR_NOSUCHNICK, "bob")); - - std::cout << Reply::build("irc.local", "*", - Reply::reply(Reply::ERR_NICKNAMEINUSE, "alice")); - - std::cout << Reply::build( - "irc.local", "alice", - Reply::reply(Reply::ERR_NEEDMOREPARAMS, "PRIVMSG")); - - std::cout << Reply::build( - "irc.local", "alice", - Reply::reply(Reply::RPL_WELCOME, "", - "Welcome to the Internet Relay Network alice!user@host")); - - std::cout << Reply::build( - "irc.local", "alice", - Reply::reply(Reply::RPL_TOPIC, "#chan", "a topic")); - - std::cout << Reply::build( - "irc.local", "alice", - Reply::reply(Reply::RPL_NAMREPLY, "= #chan", "@alice bob")); - - std::cout << Reply::build("irc.local", "alice", - Reply::reply(Reply::RPL_ENDOFNAMES, "#chan")); - - std::cout << Reply::build("irc.local", "alice", - Reply::reply(Reply::RPL_TOPIC, "#chan", "")); - - std::cout << Reply::build("irc.local", "*", - Reply::reply(Reply::ERR_NOTREGISTERED, "")); - - std::cout << Reply::build("irc.local", "*", - Reply::reply(Reply::ERR_INPUTTOOLONG, "")); - - try { - throw Error::reply(Reply::ERR_NOTONCHANNEL, "#chan"); - } catch (const Error &e) { - std::cout << "caught, hasReply=" << e.hasReply() << "\n"; - if (e.hasReply()) - std::cout << Reply::build("irc.local", "alice", e.reply()); - } - - try { - throw Error("internal: channel name invalid"); - } catch (const Error &e) { - std::cout << "internal, hasReply=" << e.hasReply() - << " what=" << e.what() << "\n"; - } - - try { - throw Error::reply(Reply::ERR_USERNOTINCHANNEL, "bob #chan"); - } catch (const Error &e) { - std::cout << "caught, hasReply=" << e.hasReply() << "\n"; - if (e.hasReply()) - std::cout << Reply::build("irc.local", "alice", e.reply()); - } - - // or directly send an error - // std::cout << Reply::build("irc.local", "alice", - // Reply::reply(Reply::ERR_USERNOTINCHANNEL, "bob #chan")); - return 0; -} diff --git a/tests/stall_test.py b/tests/stall_test.py deleted file mode 100644 index 3e088ec..0000000 --- a/tests/stall_test.py +++ /dev/null @@ -1,38 +0,0 @@ -import socket -import sys -import time - -host = "localhost" -port = int(sys.argv[1]) if len(sys.argv) > 1 else 6667 -password = sys.argv[2] if len(sys.argv) > 2 else "pass" - -def connect(nick, user): - s = socket.create_connection((host, port)) - s.sendall(("PASS %s\r\nNICK %s\r\nUSER %s 0 * :x\r\n" - % (password, nick, user)).encode()) - # read until registration completes (welcome numerics) so delivery works - time.sleep(0.3) - s.setblocking(False) - try: - while s.recv(4096): - pass - except BlockingIOError: - pass - s.setblocking(True) - return s - -# A: registers, then NEVER reads again (stalls its TCP receive window) -a = connect("stalled", "u1") - -# B: floods A with enough max-length PRIVMSGs to fill A's kernel buffer -b = connect("flooder", "u2") -body = "x" * 480 -for i in range(400): - b.sendall(("PRIVMSG stalled :%s\r\n" % body).encode()) - time.sleep(0.01) - -print("flood sent; A is silently stalled. sleeping 3s...") -time.sleep(3) -print("done. check lsof now: A should be gone with the sweep, present without it") -# leave sockets open so A lingers if leaked -time.sleep(60) \ No newline at end of file diff --git a/tests/test_client.sh b/tests/test_client.sh deleted file mode 100755 index 61333b5..0000000 --- a/tests/test_client.sh +++ /dev/null @@ -1,14 +0,0 @@ -#! /bin/bash - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(dirname "$SCRIPT_DIR")" -TEST_BIN="$SCRIPT_DIR/test_client" - -trap 'rm -f "$TEST_BIN"' EXIT - -c++ -Wall -Wextra -Werror -pedantic -std=c++98 -I "$ROOT_DIR/includes" \ - -o "$TEST_BIN" \ - "$SCRIPT_DIR/src/test_client.cpp" \ - "$ROOT_DIR/srcs/server/Client.cpp" || exit 1 - -"$TEST_BIN" diff --git a/tests/test_cmds2.sh b/tests/test_cmds2.sh deleted file mode 100755 index 5565ce8..0000000 --- a/tests/test_cmds2.sh +++ /dev/null @@ -1,16 +0,0 @@ -#! /bin/bash - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -TEST_BIN="$SCRIPT_DIR/test_cmds2" - -trap 'rm -f "$TEST_BIN"' EXIT - -c++ -Wall -Wextra -Werror -pedantic -std=c++98 -I "$ROOT_DIR/includes" \ - -o "$TEST_BIN" \ - "$SCRIPT_DIR/src/test_cmds2.cpp" \ - "$ROOT_DIR"/srcs/commands/*.cpp \ - "$ROOT_DIR"/srcs/server/*.cpp \ - "$ROOT_DIR"/srcs/utils/*.cpp || exit 1 - -"$TEST_BIN" diff --git a/tests/test_parser.sh b/tests/test_parser.sh deleted file mode 100755 index 9708e9a..0000000 --- a/tests/test_parser.sh +++ /dev/null @@ -1,13 +0,0 @@ -#! /bin/bash - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -TEST_BIN="$SCRIPT_DIR/test_parser" - -trap 'rm -f "$TEST_BIN"' EXIT - -c++ -Wall -Wextra -Werror -pedantic -std=c++98 -I "$ROOT_DIR/includes" \ - -o "$TEST_BIN" \ - "$SCRIPT_DIR/src/test_parser.cpp" || exit 1 - -"$TEST_BIN" diff --git a/tests/test_registration.sh b/tests/test_registration.sh deleted file mode 100644 index 3f8a11c..0000000 --- a/tests/test_registration.sh +++ /dev/null @@ -1,16 +0,0 @@ -#! /bin/bash - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -TEST_BIN="$SCRIPT_DIR/test_registration" - -trap 'rm -f "$TEST_BIN"' EXIT - -c++ -Wall -Wextra -Werror -pedantic -std=c++98 -I "$ROOT_DIR/includes" \ - -o "$TEST_BIN" \ - "$SCRIPT_DIR/src/test_registration.cpp" \ - "$ROOT_DIR"/srcs/commands/*.cpp \ - "$ROOT_DIR"/srcs/server/*.cpp \ - "$ROOT_DIR"/srcs/utils/*.cpp || exit 1 - -"$TEST_BIN" diff --git a/tests/test_reply.sh b/tests/test_reply.sh deleted file mode 100755 index cd2ced2..0000000 --- a/tests/test_reply.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(dirname "$SCRIPT_DIR")" -TEST_BIN="$SCRIPT_DIR/test_reply" - -trap 'rm -f "$TEST_BIN"' EXIT - -c++ -Wall -Wextra -Werror -pedantic -std=c++98 -I "$ROOT_DIR/includes" \ - -o "$TEST_BIN" \ - "$SCRIPT_DIR/src/test_reply.cpp" || exit 1 - -"$TEST_BIN" diff --git a/tests/test_transfer.sh b/tests/test_transfer.sh deleted file mode 100755 index 2d893a3..0000000 --- a/tests/test_transfer.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/bash - -# test_transfer.sh — full DCC sender-side end-to-end test. No nc. -# Registers a nick, offers a file via the bot, parses the DCC port from the -# offer line, accepts, connects with embedded python3 (holds the socket open -# until EOF), prints the received bytes and byte count. - -# Usage: ./test_transfer.sh [irc-host] [irc-port] [password] [nick] [filename] - -set -u - -HOST=${1:-127.0.0.1} -IRCPORT=${2:-6667} -PASS=${3:-pass} -WHO=${4:-alice} -FILE=${5:-file} -OUT=received.bin - -tmp=$(mktemp) - -exec 3<>"/dev/tcp/$HOST/$IRCPORT" || { echo "irc connect failed"; exit 1; } - -cat <&3 > "$tmp" & -catpid=$! -cleanup() { - kill "$catpid" 2>/dev/null - exec 3<&- 2>/dev/null - rm -f "$tmp" -} -trap cleanup EXIT INT TERM - -sleep 0.3 -printf 'PASS %s\r\nNICK %s\r\nUSER %s 0 * :%s\r\n' "$PASS" "$WHO" "$WHO" "$WHO" >&3 -sleep 1 -printf 'PRIVMSG bot :!send %s %s\r\n' "$WHO" "$FILE" >&3 -sleep 1.5 - -dcc=$(grep -oa 'DCC SEND [^[:space:]]* [^[:space:]]* [0-9]* [0-9]*' "$tmp" | tail -1) -echo "offer line : $dcc" - -# fields: DCC SEND -ip=$(echo "$dcc" | awk '{print $4}') -port=$(echo "$dcc" | awk '{print $5}') -if [ -z "$port" ]; then - echo "NO OFFER (bot busy / bad filename?)" - sed 's/^/ irc /' "$tmp" - exit 1 -fi - -printf 'PRIVMSG bot :!accept\r\n' >&3 -sleep 1 -echo "accept reply:" -sed 's/^/ irc /' "$tmp" | tail -3 - -echo "connecting to $ip:$port ..." -python3 - "$ip" "$port" <<'PYEOF' | tee "$OUT" -import socket -import sys - -ip, port = sys.argv[1], int(sys.argv[2]) -s = socket.create_connection((ip, port)) -print("python local end:", s.getsockname(), file=sys.stderr) -s.settimeout(10) -data = b"" -try: - while True: - chunk = s.recv(4096) - if not chunk: - break - data += chunk -except socket.timeout: - print("TIMEOUT: no data / no close within 10s", file=sys.stderr) -s.close() -print("RECEIVED %d bytes: %r" % (len(data), data)) -PYEOF - -printf '\n^--- received bytes (saved to %s, %s bytes)\n' "$OUT" "$(wc -c <"$OUT" 2>/dev/null || echo 0)"