Commit 9bd3b8bdb0 for bind
commit 9bd3b8bdb0ec2a8318b9b30e366e1a949d86a5da
Author: Alessio Podda <alessio@isc.org>
Date: Fri Oct 16 10:58:38 2026 +0200
Support hypothesis in unit tests
Wire up the unit test suite so that unit tests can be built with
protobuf support and can use hypothesis.
Also, add a Python helper to run a C server that communicates with
Python via protobuf, and an example unit test using hypothesis.
diff --git a/tests/hypothesis/accumulator.proto b/tests/hypothesis/accumulator.proto
new file mode 100644
index 0000000000..b81f5e5a3b
--- /dev/null
+++ b/tests/hypothesis/accumulator.proto
@@ -0,0 +1,16 @@
+// Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+// SPDX-License-Identifier: MPL-2.0
+syntax = "proto2";
+package accumulator;
+
+message Request {
+ oneof command {
+ uint64 reset = 1;
+ uint32 add = 2;
+ uint32 subtract = 3;
+ }
+}
+
+message Response {
+ required uint64 value = 1;
+}
diff --git a/tests/hypothesis/accumulator.py b/tests/hypothesis/accumulator.py
new file mode 100644
index 0000000000..4d935e62d2
--- /dev/null
+++ b/tests/hypothesis/accumulator.py
@@ -0,0 +1,62 @@
+# 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.
+
+"""
+A minimal model test for a stateful uint64 accumulator.
+"""
+
+import os
+import sys
+
+from isc_hypothesis import RestartingProtobufServer
+
+try:
+ from hypothesis import example, given, settings
+ from hypothesis import strategies as st
+
+ import accumulator_pb2 as proto # pylint: disable=import-error
+except ModuleNotFoundError as error:
+ print(f"skipping accumulator hypothesis tests: {error}", file=sys.stderr)
+ sys.exit(77)
+
+UINT64_MAX = 2**64 - 1
+server = None
+
+
+@settings(max_examples=300, deadline=None, derandomize=True)
+@given(
+ initial=st.integers(0, UINT64_MAX),
+ changes=st.lists(st.integers(-127, 127), max_size=100),
+)
+@example(initial=0, changes=[-1, 1])
+@example(initial=UINT64_MAX, changes=[1, -1])
+@example(initial=42, changes=[0, 10, -3, -7])
+def test_accumulator(initial, changes):
+ # Reset between examples, while retaining state between commands.
+ expected = initial
+ assert server.command(proto.Request(reset=initial)).value == expected
+ for change in changes:
+ request = (
+ proto.Request(add=change)
+ if change >= 0
+ else proto.Request(subtract=-change)
+ )
+ # Unsigned C arithmetic wraps modulo 2**64.
+ expected = (expected + change) & UINT64_MAX
+ assert server.command(request).value == expected
+
+
+if __name__ == "__main__":
+ with RestartingProtobufServer(
+ os.environ["ACCUMULATOR_SERVER"], proto.Response
+ ) as server:
+ test_accumulator() # pylint: disable=no-value-for-parameter
+ print("test_accumulator: passed", flush=True)
diff --git a/tests/hypothesis/accumulator_server.c b/tests/hypothesis/accumulator_server.c
new file mode 100644
index 0000000000..020d97a73a
--- /dev/null
+++ b/tests/hypothesis/accumulator_server.c
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+#include <arpa/inet.h>
+#include <stdint.h>
+#include <stdio.h>
+
+#include <isc/util.h>
+
+#include "accumulator.pb-c.h"
+
+int
+main(void) {
+ uint64_t value = 0;
+ uint32_t wire_length;
+ unsigned char data[64];
+
+ while (true) {
+ size_t n = fread(&wire_length, 1, sizeof(wire_length), stdin);
+ if (n == 0 && feof(stdin)) {
+ break;
+ }
+ RUNTIME_CHECK(n == sizeof(wire_length));
+ size_t length = ntohl(wire_length);
+ RUNTIME_CHECK(length > 0 && length <= sizeof(data));
+ RUNTIME_CHECK(fread(data, 1, length, stdin) == length);
+ Accumulator__Request *request =
+ accumulator__request__unpack(NULL, length, data);
+ RUNTIME_CHECK(request != NULL);
+ switch (request->command_case) {
+ case ACCUMULATOR__REQUEST__COMMAND_RESET:
+ value = request->reset;
+ break;
+ case ACCUMULATOR__REQUEST__COMMAND_ADD:
+ value += request->add;
+ break;
+ case ACCUMULATOR__REQUEST__COMMAND_SUBTRACT:
+ value -= request->subtract;
+ break;
+ default:
+ UNREACHABLE();
+ }
+ accumulator__request__free_unpacked(request, NULL);
+
+ Accumulator__Response response = {
+ .base = PROTOBUF_C_MESSAGE_INIT(
+ &accumulator__response__descriptor),
+ .value = value,
+ };
+ length = accumulator__response__get_packed_size(&response);
+ RUNTIME_CHECK(length <= sizeof(data));
+ accumulator__response__pack(&response, data);
+ wire_length = htonl(length);
+ RUNTIME_CHECK(fwrite(&wire_length, 1, sizeof(wire_length),
+ stdout) == sizeof(wire_length));
+ RUNTIME_CHECK(fwrite(data, 1, length, stdout) == length);
+ RUNTIME_CHECK(fflush(stdout) == 0);
+ }
+ return 0;
+}
diff --git a/tests/hypothesis/isc_hypothesis/__init__.py b/tests/hypothesis/isc_hypothesis/__init__.py
new file mode 100644
index 0000000000..2ef1609302
--- /dev/null
+++ b/tests/hypothesis/isc_hypothesis/__init__.py
@@ -0,0 +1,14 @@
+# 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.
+
+from .server import RestartingProtobufServer
+
+__all__ = ["RestartingProtobufServer"]
diff --git a/tests/hypothesis/isc_hypothesis/server.py b/tests/hypothesis/isc_hypothesis/server.py
new file mode 100644
index 0000000000..0c2089d572
--- /dev/null
+++ b/tests/hypothesis/isc_hypothesis/server.py
@@ -0,0 +1,142 @@
+# 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 contextlib
+import os
+import select
+import struct
+import subprocess
+
+TIMEOUT = 10
+MAX_RESPONSE_SIZE = 4 * 1024 * 1024
+LENGTH_PREFIX = struct.Struct("!I")
+
+
+class _ProtobufServer:
+ """
+ Drive a helper binary that reads length-prefixed protobuf requests on
+ stdin and writes length-prefixed protobuf responses on stdout.
+
+ The process starts on construction and must be torn down with
+ shutdown() or close().
+ """
+
+ def __init__(self, path, response_type):
+ self._path = path
+ self._response_type = response_type
+ # pylint: disable=consider-using-with
+ self._proc = subprocess.Popen(
+ [path],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ )
+
+ @property
+ def running(self):
+ return self._proc.poll() is None
+
+ def command(self, request):
+ """
+ Send one request and return the decoded response.
+ """
+ if not self.running:
+ self._fail("exited")
+
+ data = request.SerializeToString()
+ try:
+ self._proc.stdin.write(LENGTH_PREFIX.pack(len(data)) + data)
+ self._proc.stdin.flush()
+ except BrokenPipeError:
+ self._fail("broken pipe")
+
+ (length,) = LENGTH_PREFIX.unpack(self._read(LENGTH_PREFIX.size))
+ if length > MAX_RESPONSE_SIZE:
+ self._fail(f"response too large ({length} bytes)")
+ return self._response_type.FromString(self._read(length))
+
+ def shutdown(self):
+ """
+ Ask the server to exit and check that it did so cleanly.
+ """
+ try:
+ self._proc.stdin.close()
+ code = self._proc.wait(timeout=TIMEOUT)
+ if code != 0:
+ self._fail(f"exit status {code}")
+ finally:
+ self.close()
+
+ def close(self):
+ """
+ Tear down the process without checking its exit status.
+ """
+ if self.running:
+ self._proc.kill()
+ self._proc.wait()
+ # A failed write leaves the request buffered; closing flushes it.
+ with contextlib.suppress(BrokenPipeError):
+ self._proc.stdin.close()
+ self._proc.stdout.close()
+
+ def _fail(self, reason):
+ # Reap (or kill) the process, so that it is no longer running.
+ try:
+ self._proc.wait(timeout=TIMEOUT)
+ except subprocess.TimeoutExpired:
+ self._proc.kill()
+ self._proc.wait()
+ raise AssertionError(f"{self._path}: {reason}")
+
+ def _read(self, size):
+ # Bypass the buffered reader so that select() sees all pending data.
+ fd = self._proc.stdout.fileno()
+ data = bytearray()
+ while len(data) < size:
+ ready, _, _ = select.select([fd], [], [], TIMEOUT)
+ if not ready:
+ self._proc.kill()
+ self._fail("timed out")
+ chunk = os.read(fd, size - len(data))
+ if not chunk:
+ self._fail("EOF")
+ data.extend(chunk)
+ return bytes(data)
+
+
+class RestartingProtobufServer:
+ """
+ A _ProtobufServer that is replaced with a fresh one whenever it dies, so
+ that a single crash fails only the example that caused it.
+ """
+
+ def __init__(self, path, response_type):
+ self._args = (path, response_type)
+ self._server = None
+
+ def __enter__(self):
+ self._server = _ProtobufServer(*self._args)
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ _ = exc_value, traceback # Silence vulture spurious warnings
+ if exc_type is None:
+ self._server.shutdown()
+ else:
+ self._server.close()
+
+ def command(self, request):
+ """
+ Send one request, restarting the server first if it died.
+ """
+ if not self._server.running:
+ # Already reaped; dropping it closes its pipes.
+ self._server = _ProtobufServer(*self._args)
+ return self._server.command(request)
diff --git a/tests/hypothesis/meson.build b/tests/hypothesis/meson.build
new file mode 100644
index 0000000000..d37982152e
--- /dev/null
+++ b/tests/hypothesis/meson.build
@@ -0,0 +1,57 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+# SPDX-License-Identifier: MPL-2.0
+
+# Keep these dependencies independent of dnstap and cmocka.
+hypothesis_protoc = find_program('protoc', required: false)
+hypothesis_protoc_c = find_program('protoc-c', required: false)
+hypothesis_proto_dep = dependency('libprotobuf-c', required: false)
+if not python.found() or not hypothesis_protoc.found() or not hypothesis_protoc_c.found() or not hypothesis_proto_dep.found()
+ subdir_done()
+endif
+
+accumulator_proto_c = custom_target(
+ 'accumulator-protobuf-c',
+ input: 'accumulator.proto',
+ output: ['accumulator.pb-c.c', 'accumulator.pb-c.h'],
+ command: [
+ hypothesis_protoc_c,
+ '--proto_path=@CURRENT_SOURCE_DIR@',
+ '--c_out=@OUTDIR@',
+ '@INPUT@',
+ ],
+)
+accumulator_proto_py = custom_target(
+ 'accumulator-protobuf-python',
+ input: 'accumulator.proto',
+ output: 'accumulator_pb2.py',
+ command: [
+ hypothesis_protoc,
+ '--proto_path=@CURRENT_SOURCE_DIR@',
+ '--python_out=@OUTDIR@',
+ '@INPUT@',
+ ],
+)
+accumulator_server = executable(
+ 'accumulator_server',
+ 'accumulator_server.c',
+ accumulator_proto_c,
+ default_sanitize_options,
+ include_directories: test_inc,
+ dependencies: [libisc_dep, hypothesis_proto_dep],
+ build_by_default: false,
+ install: false,
+)
+accumulator_env = environment(test_env_vars)
+accumulator_env.set('ACCUMULATOR_SERVER', accumulator_server.full_path())
+accumulator_env.set('PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION', 'python')
+accumulator_env.prepend('PYTHONPATH', meson.current_build_dir())
+test(
+ 'accumulator-hypothesis',
+ python,
+ args: files('accumulator.py'),
+ depends: [accumulator_server, accumulator_proto_py],
+ env: accumulator_env,
+ suite: ['hypothesis'],
+ timeout: 300,
+ workdir: meson.current_build_dir(),
+)
diff --git a/tests/meson.build b/tests/meson.build
index 7b7cb9f9c8..01321b3188 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -48,6 +48,8 @@ libtest_qp_dep = declare_dependency(
include_directories: test_inc,
)
+subdir('hypothesis')
+
if not cmocka_dep.found()
subdir_done()
endif