]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Port the long TCP stream test to Python
authorŠtěpán Balážik <stepan@isc.org>
Sat, 25 Apr 2026 14:13:59 +0000 (16:13 +0200)
committerŠtěpán Balážik <stepan@isc.org>
Mon, 3 Aug 2026 09:13:45 +0000 (11:13 +0200)
Previously, the packet.pl script was used to send the a series of frames
to named; this worked by accident as most of these were refused by the
kernel with EAGAIN. packet.pl prints a dot every 1000 packets, so this
slowed the script down and allowed some frames to get through.

Reimplement the test in Python: build the packet with dnspython, send
~6 MiB of data over TCP discarding all replies and then check if the
server is still alive.

The server may close the connection before we have written the whole
stream. This shows up notably on Alma Linux 8: under concurrent load
its small default TCP buffers let the flow control between us and named
wedge, named then hits its idle timeout and resets the connection
mid-write, and drain() raises ConnectionError.

That is fine -- the connection being torn down is acceptable and the
only thing this test asserts is that named survives the flood, which
the final query checks -- so tolerate the server closing the connection
early.

bin/tests/system/tcp/1996-alloc_dnsbuf-crash-test.pkt [deleted file]
bin/tests/system/tcp/tests.sh [deleted file]
bin/tests/system/tcp/tests_sh_tcp.py [deleted file]
bin/tests/system/tcp/tests_tcp.py

diff --git a/bin/tests/system/tcp/1996-alloc_dnsbuf-crash-test.pkt b/bin/tests/system/tcp/1996-alloc_dnsbuf-crash-test.pkt
deleted file mode 100644 (file)
index 7520c3a..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-# Transaction ID
-0001
-# Standard query
-0000
-# Questions: 1, Additional: 1
-0001 0000 0000 0000
-# QNAME: www.isc.org
-03 697363 03 6F7267 00
-# Type: AXFR
-00fc
-# Class: IN
-0001
diff --git a/bin/tests/system/tcp/tests.sh b/bin/tests/system/tcp/tests.sh
deleted file mode 100644 (file)
index fc829f7..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/bin/sh
-
-# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
-#
-# SPDX-License-Identifier: MPL-2.0
-#
-# This Source Code Form is subject to the terms of the Mozilla Public
-# License, v. 2.0.  If a copy of the MPL was not distributed with this
-# file, you can obtain one at https://mozilla.org/MPL/2.0/.
-#
-# See the COPYRIGHT file distributed with this work for additional
-# information regarding copyright ownership.
-
-set -e
-
-# shellcheck source=../conf.sh
-. ../conf.sh
-
-dig_with_opts() {
-  "${DIG}" -p "${PORT}" "$@"
-}
-
-rndccmd() {
-  "${RNDC}" -p "${CONTROLPORT}" -c ../_common/rndc.conf -s "$@"
-}
-
-status=0
-n=0
-
-####################################################
-# NOTE: The next test resets the debug level to 1. #
-####################################################
-
-n=$((n + 1))
-echo_i "checking that BIND 9 doesn't crash on long TCP messages ($n)"
-ret=0
-# Avoid logging useless information.
-rndccmd 10.53.0.1 trace 1 || ret=1
-{ $PERL ../packet.pl -a "10.53.0.1" -p "${PORT}" -t tcp -r 300000 1996-alloc_dnsbuf-crash-test.pkt || ret=1; } | cat_i
-dig_with_opts +tcp @10.53.0.1 txt.example >dig.out.test$n || ret=1
-if [ $ret != 0 ]; then echo_i "failed"; fi
-status=$((status + ret))
-
-echo_i "exit status: $status"
-[ $status -eq 0 ] || exit 1
diff --git a/bin/tests/system/tcp/tests_sh_tcp.py b/bin/tests/system/tcp/tests_sh_tcp.py
deleted file mode 100644 (file)
index 8aba3c4..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
-#
-# SPDX-License-Identifier: MPL-2.0
-#
-# This Source Code Form is subject to the terms of the Mozilla Public
-# License, v. 2.0.  If a copy of the MPL was not distributed with this
-# file, you can obtain one at https://mozilla.org/MPL/2.0/.
-#
-# See the COPYRIGHT file distributed with this work for additional
-# information regarding copyright ownership.
-
-import pytest
-
-pytestmark = pytest.mark.extra_artifacts(
-    [
-        "dig.out.*",
-    ]
-)
-
-
-def test_tcp(run_tests_sh):
-    run_tests_sh()
index ef16ddcc09d4a4895828555c0ee48cd85dc10070..d69e857089aaa2d952405d58cb49d754155cc225 100644 (file)
 # See the COPYRIGHT file distributed with this work for additional
 # information regarding copyright ownership.
 
-from collections.abc import Iterable
+from collections.abc import Iterable, Iterator
 from types import TracebackType
 from typing import NamedTuple
 
 import asyncio
+import contextlib
 import socket
 import struct
 import time
@@ -241,6 +242,45 @@ def close_tcp_connections(connections: Iterable[socket.socket]) -> None:
         sock.close()
 
 
+async def send_long_tcp_stream(
+    host: str, port: int, message: dns.message.Message, min_bytes: int
+) -> None:
+    frame = message.to_wire(prepend_length=True)
+    chunk_frames = max(1, 65536 // len(frame))
+    frames_remaining = (min_bytes + len(frame) - 1) // len(frame)
+
+    async def discard_stream(reader: asyncio.StreamReader) -> None:
+        with contextlib.suppress(OSError):
+            while await reader.read(65535):
+                pass
+
+    async def run() -> None:
+        reader, writer = await asyncio.open_connection(host, port)
+        discard_task = asyncio.create_task(discard_stream(reader))
+        try:
+            with contextlib.suppress(ConnectionError):
+                remaining = frames_remaining
+                while remaining > 0:
+                    frames = min(chunk_frames, remaining)
+                    writer.write(frame * frames)
+                    await writer.drain()
+                    remaining -= frames
+                writer.write_eof()
+                await writer.drain()
+            writer.close()
+            with contextlib.suppress(ConnectionError, OSError):
+                await writer.wait_closed()
+            await discard_task
+        finally:
+            writer.close()
+            if not discard_task.done():
+                discard_task.cancel()
+            with contextlib.suppress(asyncio.CancelledError):
+                await discard_task
+
+    await asyncio.wait_for(run(), timeout=10 * TIMEOUT)
+
+
 def test_tcp_garbage(ns7: NamedInstance, named_port: int) -> None:
     with create_socket(ns7.ip, named_port) as sock:
         msg = isctest.query.create(
@@ -430,3 +470,43 @@ def test_tcp_high_water(ns5: NamedInstance, named_port: int) -> None:
             check_tcp_response(ns5.ip)
 
     asyncio.run(run())
+
+
+def debug_level(ns: NamedInstance) -> int:
+    status = ns.rndc("status").out
+    matches = status.grep("debug level:")
+    assert matches, f"'debug level' not found in rndc status:\n{status}"
+    return int(matches[0].string.partition(":")[2])
+
+
+@contextlib.contextmanager
+def temporary_trace_level(ns: NamedInstance, level: int) -> Iterator[None]:
+    """Lower the debug level for a noisy section, then restore the default."""
+    prev_level = debug_level(ns)
+    ns.rndc(f"trace {level}")
+    try:
+        yield
+    finally:
+        # Don't mask an in-flight test failure if named has died.
+        ns.rndc(f"trace {prev_level}", raise_on_exception=False)
+
+
+def test_long_tcp_messages(ns1: NamedInstance, named_port: int) -> None:
+    isctest.log.info("checking that BIND 9 doesn't crash on long TCP messages")
+    stream_bytes = 6 * 1024 * 1024
+    msg = isctest.query.create(
+        "isc.org.",
+        "AXFR",
+        dnssec=False,
+        use_edns=False,
+        rd=False,
+        ad=False,
+        message_id=1,
+    )
+
+    # Avoid logging the huge query stream at the default debug level.
+    with temporary_trace_level(ns1, 1):
+        asyncio.run(send_long_tcp_stream(ns1.ip, named_port, msg, stream_bytes))
+
+        msg = isctest.query.create("txt.example.", "A")
+        isctest.query.tcp(msg, ns1.ip)