diff --git a/.github/workflows/v3-package-validation.yml b/.github/workflows/v3-package-validation.yml index 6b3f3c3b..dd4b1bce 100644 --- a/.github/workflows/v3-package-validation.yml +++ b/.github/workflows/v3-package-validation.yml @@ -149,7 +149,7 @@ jobs: if-no-files-found: error freebsd: runs-on: ubuntu-24.04 - timeout-minutes: 180 + timeout-minutes: 240 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: freebsd_instance diff --git a/.github/workflows/v3-release-acceptance.yml b/.github/workflows/v3-release-acceptance.yml index e2fc38dd..8089ecc5 100644 --- a/.github/workflows/v3-release-acceptance.yml +++ b/.github/workflows/v3-release-acceptance.yml @@ -269,7 +269,7 @@ jobs: freebsd: runs-on: ubuntu-24.04 needs: prepare - timeout-minutes: 180 + timeout-minutes: 300 outputs: artifact_id: ${{ steps.proof.outputs.artifact-id }} artifact_digest: ${{ steps.proof.outputs.artifact-digest }} @@ -332,6 +332,12 @@ jobs: fi test "$(node --version)" = v24.15.0 python3 -c 'import subprocess; subprocess.run(["git","--version"],check=True)' + # The native peer-policy runtime test binds 127.0.0.2 as its + # admin source address; FreeBSD only configures 127.0.0.1 on + # lo0 by default, so the alias must exist before make check. + if ifconfig lo0 >/dev/null 2>&1; then + ifconfig lo0 alias 127.0.0.2 2>/dev/null || ifconfig lo0 | grep -q '127\.0\.0\.2' + fi python3 scripts/ci-freebsd-toolchain.py --output "$RUNNER_TEMP/platform-evidence/toolchain" . "$RUNNER_TEMP/platform-evidence/toolchain/toolchain.env" python3 scripts/prepare-v3-package-ci.py --output "$RUNNER_TEMP/prerequisites" --make gmake diff --git a/ChangeLog b/ChangeLog index 766cdb7e..870b1892 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,4 +1,24 @@ Version 3.0.0 - 2026-10-10 + Poll the QUIC interop endpoint and UDP fixture through the shared + cross-platform host helper on Windows. + Open the QUIC interop test keylog with Windows-compatible flags + instead of O_NOFOLLOW/O_CLOEXEC/fchmod. + Complete the first FreeBSD native make-check exposure: portable + process-memory measurement, GNU-make gate selection, the + loopback admin alias, per-test hang bounds and job budget. + Link the installed public TLS and HTTP/2 audit clients to Winsock on + Windows and name their executables with the platform suffix. + Resolve the Windows version-information system import in installed + package dependency audits. + Keep the HTTP/1 connection watchdog armed at a pending server drain + deadline so a stalled close handoff cannot outlive the drain budget. + Tolerate a concurrently closed borrowed registration in the Windows + reference host poll helper instead of failing the whole WSAPoll set. + Register each native executable once in Automake test evidence inventories. + Run Windows allocation-failure injection against the installed static + library and require ordinary readiness cases in both linkage lanes. + Keep PSK cancellation lookup held until owner release and avoid artificial + per-fragment scheduling delays in the TLS test transport. Avoid Windows DLL shutdown hangs by retaining the default timer service during process termination while preserving ordinary timer cleanup. Report the saved Winsock error code when host WSAPoll fails. diff --git a/docs/external-loop-contract.md b/docs/external-loop-contract.md index c2877ded..f193e039 100644 --- a/docs/external-loop-contract.md +++ b/docs/external-loop-contract.md @@ -121,6 +121,15 @@ the resulting protocol/transport outcome. Duplicate or spurious notifications, including events with all flags false, are permitted and do not imply additional completions. Empty batches are permitted for timers and pending library work. +A registration borrowed by a snapshot can be closed by library work at any +moment, because retaining a snapshot never extends a handle lifetime. A POSIX +host observes this as a per-entry `POLLNVAL` readiness (translated to `error` +and ignored as stale). Windows `WSAPoll` instead rejects the entire call with +`WSAENOTSOCK`; a host must treat that failure like an empty batch — dispatch, +acquire a fresh snapshot, and wait again — rather than stopping, because the +closed registration is absent from every later snapshot. The reference +`examples/host_poll.hpp` helper implements this tolerance. + Obtain an initial snapshot before waiting. After **every successful dispatch**, obtain and reconcile a fresh snapshot before waiting again. A failed dispatch may have consumed part of a batch unless the documented rejection rule says diff --git a/examples/host_poll.hpp b/examples/host_poll.hpp index b864968d..c30fbafa 100644 --- a/examples/host_poll.hpp +++ b/examples/host_poll.hpp @@ -102,6 +102,13 @@ inline std::vector wait( const auto ready = ::WSAPoll(fds.data(), static_cast(fds.size()), timeout(deadline)); if (ready < 0) { const int error = ::WSAGetLastError(); + // Snapshot handles are borrowed: the library may close one between + // snapshot acquisition and this call. POSIX poll reports that + // per entry as POLLNVAL; WSAPoll rejects the whole set with + // WSAENOTSOCK. An empty batch keeps the contract's spurious- + // notification allowance: the caller dispatches, re-acquires + // interests, and the closed registration is already gone. + if (error == WSAENOTSOCK) return {}; if (error != WSAEINTR) throw std::runtime_error("WSAPoll failed (WSA error " + std::to_string(error) + ")"); } #else diff --git a/scripts/audit-v3-installed-package.py b/scripts/audit-v3-installed-package.py index 260536f1..c2e5ea15 100644 --- a/scripts/audit-v3-installed-package.py +++ b/scripts/audit-v3-installed-package.py @@ -139,7 +139,8 @@ def parse_otool(output, artifact): WINDOWS_SYSTEM = {'kernel32.dll', 'kernelbase.dll', 'ntdll.dll', 'msvcrt.dll', 'ucrtbase.dll', 'ws2_32.dll', 'advapi32.dll', 'bcrypt.dll', 'bcryptprimitives.dll', 'crypt32.dll', 'secur32.dll', 'user32.dll', 'iphlpapi.dll', 'winmm.dll', 'ole32.dll', 'shell32.dll', - 'userenv.dll', 'ncrypt.dll', 'gdi32.dll', 'shlwapi.dll', 'comdlg32.dll'} + 'userenv.dll', 'ncrypt.dll', 'gdi32.dll', 'shlwapi.dll', 'comdlg32.dll', + 'version.dll'} def windows_system(name): diff --git a/scripts/check-v3-public-http2.py b/scripts/check-v3-public-http2.py index 2a186e25..38af737e 100755 --- a/scripts/check-v3-public-http2.py +++ b/scripts/check-v3-public-http2.py @@ -146,8 +146,15 @@ def run(args): flags = ['--libs'] + (['--static'] if linkage == 'static' else []) libraries = shlex.split(command(['pkg-config'] + flags + ['libhttpserver'], consumer, env)) if linkage == 'static': libraries = [str(prefix / 'lib/libhttpserver.a') if f == '-lhttpserver' else f for f in libraries] - libraries += ['-pthread', '-Wl,-rpath,' + str(prefix / 'lib'), '-Wl,-rpath,' + str(provider / 'lib')] + shlex.split(args.sanitize) - executable = consumer / linkage + # Winsock is a system library on Windows and lives in libc on the + # POSIX targets, whose runtime search needs the explicit rpath. + if os.name == 'nt': + libraries += ['-lws2_32', '-lmswsock', '-lbcrypt'] + else: + libraries += ['-pthread', '-Wl,-rpath,' + str(prefix / 'lib'), '-Wl,-rpath,' + str(provider / 'lib')] + libraries += shlex.split(args.sanitize) + suffix = '.exe' if os.name == 'nt' else '' + executable = consumer / (linkage + suffix) provenance = compile_probe(args.compiler, [consumer / 'http2_consumer.cpp'], cflags + shlex.split(args.sanitize), libraries, executable, command, env, source, build, prefix, False) validate_public(True, provenance['dependencies'], prefix, source, build) @@ -155,7 +162,7 @@ def run(args): violations = audit.graph_violations(graph, 'strict', args.tls, 'aggregate', audit.load_policy(), provider) require(not violations, 'dependency audit: ' + repr(violations)) if args.tls == 'yes': - example = consumer / (linkage + '-example') + example = consumer / (linkage + '-example' + suffix) example_compile = compile_probe(args.compiler, [consumer / 'http2.cpp'], cflags + shlex.split(args.sanitize), libraries, example, command, env, source, build, prefix, False) example_graph = audit.dependency_graph([str(example)]); check_linkage(example_graph, linkage, prefix) diff --git a/scripts/check-v3-public-tls.py b/scripts/check-v3-public-tls.py index b356db3d..1dd0d8d2 100755 --- a/scripts/check-v3-public-tls.py +++ b/scripts/check-v3-public-tls.py @@ -330,17 +330,21 @@ def run(args): if identity == 'client-valid': chain += (fixtures / 'client-intermediate.pem').read_bytes() (fixtures / (identity + '-chain.pem')).write_bytes(chain) - independent = consumer / 'independent-client' + independent = consumer / ('independent-client' + ('.exe' if os.name == 'nt' else '')) + # tls_client.cpp dials raw sockets: Winsock is a system library on + # Windows and lives in libc on the POSIX targets. + independent_system = ['-lws2_32'] if os.name == 'nt' else [] command_log([args.compiler, '-std=c++20', '-I' + str(args.provider_prefix / 'include'), consumer / 'tls_client.cpp', '-L' + str(args.provider_prefix / 'lib'), - '-Wl,-rpath,' + str(args.provider_prefix / 'lib'), '-lssl', '-lcrypto', '-o', independent], consumer, env) + '-Wl,-rpath,' + str(args.provider_prefix / 'lib'), '-lssl', '-lcrypto'] + + independent_system + ['-o', independent], consumer, env) receipt['independent_client'] = dict(executable=str(independent), sha256=hashlib.sha256(independent.read_bytes()).hexdigest(), dependencies=audit.dependency_graph([str(independent)])) check('libhttpserver' not in json.dumps(receipt['independent_client']['dependencies']), 'client links server library') command_log([independent, 'fixtures', fixtures], consumer, env, timeout=10) (fixtures / 'malformed.pem').write_text('malformed certificate') (fixtures / 'malformed-key.pem').write_text('malformed private key') - command_log([args.provider_prefix / 'bin/openssl', 'pkey', '-in', fixtures / 'acme-key.pem', '-aes-256-cbc', '-passout', 'pass:task194-test-only', '-out', fixtures / 'encrypted-key.pem'], consumer, env) + command_log([args.provider_prefix / 'bin' / ('openssl.exe' if os.name == 'nt' else 'openssl'), 'pkey', '-in', fixtures / 'acme-key.pem', '-aes-256-cbc', '-passout', 'pass:task194-test-only', '-out', fixtures / 'encrypted-key.pem'], consumer, env) (fixtures / 'extra.pem').write_bytes((fixtures / 'acme.pem').read_bytes() * 2) for name in ('mismatch-key', 'past-expiry', 'late-expiry'): shutil.copyfile(fixtures / 'acme.pem', fixtures / (name + '.pem')) diff --git a/scripts/lib/package_integration.py b/scripts/lib/package_integration.py index f2462e87..71ca28df 100644 --- a/scripts/lib/package_integration.py +++ b/scripts/lib/package_integration.py @@ -38,6 +38,11 @@ def run_suites(inventory, host, mode, linkage, directory, prefix, source, build, receipt = dict(name=name, tls=mode, linkage=linkage, execution=definition.get('execution', 'installed-library')) try: flags = cflags + ['-DHTTPSERVER_COMPILATION', '-I' + str(source / 'src'), '-I' + str(source / 'test')] + if host == 'Windows' and linkage == 'shared' and definition.get('windows_static_cases'): + # An executable's operator new cannot interpose PE DLL imports. + # The installed static lane retains every fault-injection case. + flags += ['-DLHS_TEST_DLL_LINKAGE'] + receipt['windows_static_cases'] = definition['windows_static_cases'] for macro, value in (('TLS_TEST_DIR', source / 'test'), ('PARITY_TRANSCRIPT_DIR', source / 'test/parity/transcripts'), ('PARITY_DATA_ROOT', source / 'test'), ('HTTP2_CORPUS_DIR', source / 'test/conformance/http2')): flags += ['-D' + macro + '="' + value.as_posix() + '"'] @@ -61,9 +66,7 @@ def run_suites(inventory, host, mode, linkage, directory, prefix, source, build, started = time.monotonic() output = command([str(executable)], source / 'test', env, timeout=180, allow_failure=True) receipt.update(matrix.suite_result(output, command.records[-1]['exit'])) - missing = set(definition.get('required_cases', [])) - set(receipt['case_names']) - if missing: - raise ValueError('required suite cases absent: ' + ', '.join(sorted(missing))) + matrix.check_suite_cases(definition, host, linkage, receipt['case_names']) receipt.update(duration=time.monotonic() - started, log=command.records[-1]['log']) if name in ('tls_io_loopback', 'native_http1_tls_parity'): matrix.check_tls_witnesses(output, inventory['platforms'][host]) diff --git a/scripts/lib/package_matrix.py b/scripts/lib/package_matrix.py index 3a86b850..3cfae3e3 100644 --- a/scripts/lib/package_matrix.py +++ b/scripts/lib/package_matrix.py @@ -150,6 +150,18 @@ def suites(inventory, platform, tls): return {name: inventory['suites'][name] for name in names} +def check_suite_cases(definition, host, linkage, observed): + required = set(definition.get('required_cases', [])) + windows_static = set(definition.get('windows_static_cases', [])) + if not windows_static <= required: + raise ValueError('Windows static cases must also be required suite cases') + if host == 'Windows' and linkage == 'shared': + required -= windows_static + missing = required - set(observed) + if missing: + raise ValueError('required suite cases absent: ' + ', '.join(sorted(missing))) + + def suite_result(output, status): output = output.replace('\r\n', '\n') tests = re.findall(r'^(\d+) tests executed$', output, re.M) diff --git a/scripts/lib/release_platform.py b/scripts/lib/release_platform.py index 6b44e1c1..766841d9 100644 --- a/scripts/lib/release_platform.py +++ b/scripts/lib/release_platform.py @@ -153,7 +153,7 @@ def package_origin(root, origin, read_json, read_text, expected, platform, read_ receipt = suites[name] observed = matrix.suite_result(read_text(receipt['log']), 0) require(all(receipt.get(k) == v for k, v in observed.items()), 'stale platform suite summary') - require(set(definition.get('required_cases', [])) <= set(observed['case_names']), 'pruned platform suite') + matrix.check_suite_cases(definition, platform, linkage, observed['case_names']) return result diff --git a/scripts/run-v3-http3-full-gates.py b/scripts/run-v3-http3-full-gates.py index 3d9fb1fb..44c22235 100644 --- a/scripts/run-v3-http3-full-gates.py +++ b/scripts/run-v3-http3-full-gates.py @@ -20,6 +20,9 @@ ROOT = Path(__file__).resolve().parents[1] INVENTORY = ROOT / 'test/conformance/http3/full-gates.json' +# Generated Makefiles need GNU make; FreeBSD's default `make` is BSD +# make, so prefer gmake where it exists and fall back to `make`. +MAKE = shutil.which('gmake') or 'make' OFF_SUITES = '''http3_frame http3_settings http3_connection http3_headers http3_exchange http3_streaming http3_cancellation http3_qpack http3_qpack_scheduling http3_drain http3_fuzz_replay qpack_primitives qpack_field_section qpack_dynamic_table @@ -374,10 +377,10 @@ def build_configuration(build, tls, sanitized): def native_stage(build, suites, directory, fixtures=False): directory.mkdir(mode=0o700) - commands = [checked(['make', '-j4', '-C', build / 'src'], ROOT, directory / 'build')] + commands = [checked([MAKE, '-j4', '-C', build / 'src'], ROOT, directory / 'build')] targets = suites + (['http3_udp_fixture', 'http3_semantic_parity_fixture', 'quic_interop_endpoint'] if fixtures else []) selection = [name + '=' + ' '.join(targets) for name in ('TESTS', 'check_PROGRAMS', 'noinst_PROGRAMS')] - commands.append(checked(['make', '-j4', '-C', build / 'test', *selection, *targets], ROOT, directory / 'test-build')) + commands.append(checked([MAKE, '-j4', '-C', build / 'test', *selection, *targets], ROOT, directory / 'test-build')) results = run_native_suites(build, suites, directory / 'tests') results['commands'] = commands tls = '#define NATIVE_V3_TLS' in (build / 'config.h').read_text() diff --git a/scripts/test-v3-installed-consumer.py b/scripts/test-v3-installed-consumer.py index 977611e8..2f4aceb7 100644 --- a/scripts/test-v3-installed-consumer.py +++ b/scripts/test-v3-installed-consumer.py @@ -1580,5 +1580,32 @@ def test_nonzero_shutdown_is_failure(self): self.assertEqual(8, json.loads(receipt.read_text())['exit']) +class SuiteCaseCoverageTest(unittest.TestCase): + def setUp(self): + self.definition = dict(required_cases=['ordinary', 'allocation_failure'], + windows_static_cases=['allocation_failure']) + + def test_windows_dll_requires_all_ordinary_cases(self): + matrix.check_suite_cases(self.definition, 'Windows', 'shared', ['ordinary']) + with self.assertRaisesRegex(ValueError, 'ordinary'): + matrix.check_suite_cases(self.definition, 'Windows', 'shared', []) + + def test_static_proof_requires_all_allocation_failure_cases(self): + for host in ('Windows', 'Linux', 'Darwin', 'FreeBSD'): + with self.subTest(host=host), self.assertRaisesRegex(ValueError, 'allocation_failure'): + matrix.check_suite_cases(self.definition, host, 'static', ['ordinary']) + matrix.check_suite_cases(self.definition, host, 'static', ['ordinary', 'allocation_failure']) + + def test_other_shared_platforms_retain_allocation_failure_coverage(self): + for host in ('Linux', 'Darwin', 'FreeBSD'): + with self.subTest(host=host), self.assertRaisesRegex(ValueError, 'allocation_failure'): + matrix.check_suite_cases(self.definition, host, 'shared', ['ordinary']) + + def test_windows_static_cases_must_also_be_required(self): + self.definition['required_cases'] = ['ordinary'] + with self.assertRaisesRegex(ValueError, 'static.*required'): + matrix.check_suite_cases(self.definition, 'Windows', 'static', ['ordinary']) + + if __name__ == '__main__': unittest.main() diff --git a/specs/tasks/FEATURE-ci-v3-runtime-green.md b/specs/tasks/FEATURE-ci-v3-runtime-green.md index 3117565e..a7d5b0f7 100644 --- a/specs/tasks/FEATURE-ci-v3-runtime-green.md +++ b/specs/tasks/FEATURE-ci-v3-runtime-green.md @@ -1,6 +1,6 @@ # FEATURE-ci-v3-runtime-green -Status: Stages 1 and 3 implemented with focused proof; complete CI and remaining supported runtime repairs are pending. Groundwork validation is explicitly prohibited until FULL CI IS GREEN. +Status: Stages 1, 3, 4, 5 and 6 implemented with focused proof; complete CI and remaining supported runtime repairs are pending. Groundwork validation is explicitly prohibited until FULL CI IS GREEN. Base: `v3` at `8f3083977f49706e66cefbb3ee8b6f6685469719`. Working branch: `task/FEATURE-ci-v3-runtime-green`. User requested integration into `v3` before full CI completion; retain incomplete verification explicitly. Plan: primary checkout `.groundwork-plans/FEATURE-ci-v3-runtime-green-plan.md`. @@ -70,3 +70,119 @@ Integrate the focused repairs and ChangeLog update now. Full CI remains incomple Groundwork validation has not run and stays deferred until full CI is green. This integration does not mark the overall CI repair complete or reopen closed diagnostic batches. Draft PR #382 carries the original working-branch runs. + +## Stage 4: PSK test fixture scheduling (2026-10-27) + +- [x] Read the completed, previously authorized diagnostic receipts; keep their budgets closed. +- [x] Preserve the native Darwin RED: 14 tests/313 checks, three failures; held TLS1.3 cancellation callback already returned, and certificate-default TLS1.3 handshake timed out. +- [x] Release the held cancellation lookup explicitly after assertions and close; bound the callback wait to five seconds for failure cleanup. +- [x] Drain ready fragmented transport work and yield while waiting for provider work, preserving the original fragments and every deadline/assertion. +- [x] Reproduce the real certificate fixture timeout under coarse scheduling, then pass the same regression and all 14 PSK tests/313 checks. +- [x] Pass changed-file cpplint and diff checks. +- [ ] Native current-source CI, including Darwin selected archive acceptance. +- [ ] Resolve the remaining Windows external-loop failure from supported evidence. +- [ ] Close the FreeBSD selected-archive timeout gate with actual evidence. + +Evidence and commands: primary checkout `.debug/v3-ci-native-repairs-PJFEkn/journal.md`. +Current c1 installed matrix passed Linux, Darwin, FreeBSD; Windows failed. Native/fuzz, CodeQL and Verify Build passed. Source-equivalent selected archive remains failed; no current-source green claim. + +The diagnostic Windows four-test program failed one of 52 checks in 0.741 seconds with WSAPoll error10038 before stop was requested. Two bounded new socket-only probes on local Windows11 returned POLLNVAL successfully; they do not establish the Server2022 failure cause. No poll error suppression or guessed production fix was applied. Previous capture budgets remain closed. + +Fixture root cause: the cancellation rescue unconditionally released after250ms, before the owner could assert cancellation under slower scheduling. The transport also slept after every41/73-byte fragment, charging scheduler delays to real handshake deadlines. A minimal real TLS1.3 certificate fixture with1ms sleeps delayed to15ms failed at2089ms/outcome8(timeout); draining progress first passed at625ms. An intermediate full coarse-scheduling check still failed two held/capacity assertions because idle sleeps could expire the400ms pre-lookup deadline. Yielding while idle preserved that deadline and passed all313 checks in1.029seconds, with zero skips. The100ms owner-responsiveness assertion, cancellation/timeout/capacity/late-result assertions, bounded drains and all protocol settings remain unchanged. Only test source changed; production TLS is unchanged. + +Setup evidence is retained honestly: system OpenSSL was rejected before selecting the existing3.5.9 provider; the first suite launch used build-root cwd instead of test cwd and failed fixture path lookup. Correcting cwd, as required by the generated relative TLS_TEST_DIR, produced the meaningful behavioral check. Neither setup failure justifies a source workaround. No broad baseline or Groundwork validation was run. + +## Stage 5: Windows allocator fault-injection fixture linkage + +- [x] Identify the current PR383 failed program from one bounded selected artifact assessment (3,891,726 range bytes; no full ZIP or original execution). +- [x] Prove that executable operator-new replacement does not affect Windows DLL allocation, and that static linkage delivers the intended fault. +- [x] Keep all 22 ordinary readiness cases against the installed Windows DLL; require all 27 cases, including the five unchanged allocation-failure cases, against the installed static archive. +- [x] Make the required case names explicit in the inventory and reject every missing static fault case. Other platforms retain their original fault coverage. +- [x] Pass native Windows focused DLL22/438 and static27/486 gates, Linux28/497, 28 package coverage/matrix checks, changed-file lint and diff checks. +- [ ] Full exact-source CI with the corrected installed linkage profiles. + +Current PR383 package job122630892885 passed native62 in4.812276s and external4 in0.572681s, then failed external_readiness_adapter27/486 with13 failures, all inside five allocation-failure cases. The assertion expecting bad_alloc was false and regular work completed. The PSK change cannot affect these allocation boundaries; this repair is within the accepted overall CI goal. Member CRC/SHA are verified; the full212MB ZIP digest is metadata-only. Assessment is closed. + +A tiny native PE fixture reproduces the cause: executable allocation throws under its replacement operator new, DLL-owned allocation does not (exit1/0.362s). Linking the same owner object into the executable makes both throw (exit0/0.309s). Microsoft documents per-module DLL import binding: https://learn.microsoft.com/en-us/cpp/build/linking-an-executable-to-a-dll?view=msvc-170 . The chosen correction scopes allocation injection to linkage where it can actually reach the owner. Artificially throwing in the caller would not exercise the backend; a new production allocator hook or loader patch is unnecessary. + +Only Windows shared compilation selects LHS_TEST_DLL_LINKAGE; this omits allocator replacement and the five fault registrations from that executable. All five original assertion bodies remain unchanged and mandatory in the installed static lane. The inventory names all27 ordinary/static cases, and the receipt validator requires the22 DLL cases or all27 static cases. No test skip or failed/empty receipt is accepted. Every original C++ assertion line is preserved. The other platforms keep all five fault cases in both linkage lanes. + +Fresh current-source focused Windows backend DLL (16 owning translation units) passed22/438 in0.899s; matching static executable passed27/486 in1.237s, both zero skips and retired. Linux passes28/497 including its descriptor-zero case. Removing any one of the five fault names from the static receipt is rejected. Python coverage/matrix tests pass28 cases. Native setup initially missed net_address.cpp at link; adding that existing owning translation unit corrected the test build without a production workaround. Context is qualified local Windows11/cached LLVM23.1.4/GCC15.2 headers and retained runtime DLLs, not Server2022/MSYS2 CI. Evidence: primary `.debug/v3-ci-native-repairs-PJFEkn/allocator-linkage/`. + +## Approved Windows capture closure + +User approved exact430fa17e publication and ONE1/60 Windows original external-loop capture. Run40635371672 completed once:4tests/52checks/zero skips, exit0 in0.4701966s, retirement confirmed, stderr empty. Budget is CLOSED; no retry or remaining-time authority. This establishes nonreproduction in that context, not a fix or independence of the earlier WSAPoll10038 failure. The diagnostic branch remains NEVER MERGE. Only a proven repair may change production behavior. + +Stage5 receipt-import closure: source review found the selected-archive importer independently required all cases in each linkage. A new real receipt-bundle regression was RED with `valid DLL/static split rejected: pruned platform suite`. Reusing the same coverage validator fixes this producer/consumer mismatch while still rejecting missing static fault cases. All14 platform-bundle tests and changed Python syntax/diff checks pass. This is part of the same proven setup repair; native binaries are unchanged, so their focused evidence is carried without repetition. Current-source CI remains pending. + +## Stage 6: unique native Automake inventory + +- [x] Preserve Darwin archive failure `duplicate/empty native suite inventory` after its native commands completed. +- [x] Reproduce the exact producer inventory command against a real configured Makefile: TLSyes271 entries/266 unique, five duplicated executables. +- [x] Remove only four redundant TESTS additions; keep each executable in check_PROGRAMS, which already feeds TESTS recursively. +- [x] Regenerate actual Automake/configured Makefiles and verify unique inventories in both TLS modes: yes266/266, no235/235. All five affected executables remain registered exactly once. +- [ ] Full current-source archive evidence with this registration correction. + +The duplicated names are http_cookie, consumer_v3_cookie, consumer_v3_cookie_wire, protocol_limits and protocol_compression. TESTS already equals $(check_PROGRAMS); the extra TESTS += lines counted them twice. The strict receipt duplicate rejection is unchanged. No executable, assertion, script test, protocol setting or failure deadline was removed. This four-line configuration repair is proved by the actual producer command, not a source-text mirror test or a broad baseline rerun. + +Evidence: primary `.debug/v3-ci-native-repairs-PJFEkn/native-inventory/`. Autoreconf succeeded with existing AC_OUTPUT/.PHONY warnings. Initial config.status invocation used the source directory instead of its configured build directory and failed before any native execution; rechecking/regenerating in the correct build directory fixed setup. No unrelated source or generated tracked file changed. + +Current9c92 Windows package job122646921257 fails earlier at the public shared external consumer: exit6, captured stderr WSAPoll error10038. The selected820-byte process receipt was verified within a closed2,581,790-byte range assessment; no full artifact or original program ran. It contains no failed-call slot/state values. The earlier approved external4 capture remains CLOSED/PASS4/52/nonreproduction, and does not authorize running the distinct public consumer. No guessed polling or shutdown suppression was applied. A fresh consumer-specific bounded capture proposal is needed if further native diagnosis is required. + +## Stage 7: Windows WSAPoll borrowed-closure tolerance and HTTP/1 drain-deadline watchdog (2026-10-30) + +- [x] Correlate all retained Windows failures (external four-test suite twice, public shared consumer exit6) with the approved DIAG capture: the host loop dies on "WSAPoll failed (WSA error 10038)" while dispatch never failed and stopping was false. +- [x] Root-cause by contract analysis, not a new capture: `interest_snapshot` handles are borrowed and "retaining a snapshot does not extend any handle lifetime"; the library closes registrations under its registry mutex and never republishes dead records, so WSAENOTSOCK can only mean a snapshot handle closed between snapshot and WSAPoll entry (handle-value reuse makes it WSAENOTSOCK on busy runners and POLLNVAL on idle probes, reconciling the closed local probes). +- [x] Repair `examples/host_poll.hpp`: WSAENOTSOCK returns an empty batch (the contract's spurious-notification allowance); every reference caller re-acquires interests after each dispatch, and dead records are pruned from the next snapshot, so the loop provably progresses. All other WSAPoll failures still throw; POSIX POLLNVAL handling is unchanged. Document the host obligation in `docs/external-loop-contract.md`. +- [x] Root-cause the 2026-10-28 macOS/Darwin external4 drain stall (idle keep-alive connection never closed after `begin_drain`; host silent for the full 10s budget; remaining 1): `watchdog_terminal_locked()` treated the quiesce close mark as terminal, but HTTP/1 enforces the server drain deadline only through the watchdog (`enforce_timeout` is the sole HTTP/1 `scope_.expire_if_due` caller), so a stalled close handoff had no deadline enforcement left. +- [x] Repair: the close mark no longer retires the watchdog while a server drain deadline is pending; the deadline joins `plan_watchdog_locked` candidates through the existing `add_protocol_deadlines_locked` entry and fires through the unchanged global-expiry path. Deadline-less closes (request_close, response-Connection: close) stay terminal as before. +- [x] RED/GREEN regression `quiesce_with_drain_deadline_keeps_watchdog_armed` (unstarted engine, deterministic plan sample; deadline-less case pins unchanged terminal behavior) plus behavioral `quiesce_with_drain_deadline_still_closes_promptly`. RED verified against unfixed source, GREEN against the repair, five consecutive clean runs. +- [x] Linux focused gates: connection_engine 42/0 (x5), external_http1_e2e 4/0, websocket_drain 3/0, http1_websocket_upgrade 17/0, native_http1_e2e 62/0, external_readiness_adapter 28/0, examples/external_event_loop builds and serves; changed-file cpplint and `git diff --check` pass. +- [ ] Full exact-source CI on the working branch (mingw64/macOS/FreeBSD lanes remain nonlocal and deferred to CI). + +## Stage 8: FreeBSD job timeout (2026-10-30) + +- [x] Evidence: the 2026-10-26 acceptance FreeBSD job completed its native proof at 2h59m45s (57 seconds under the 180-minute job timeout); the 2026-10-28 package-validation FreeBSD job was cancelled by the 180-minute timeout at 3h3m mid-run. The workload (libc++ 20.1.8 runtime build + provider + both TLS lanes on a 2-CPU VM) legitimately straddles the limit. +- [x] Raise only the freebsd job `timeout-minutes` from 180 to 240 in both `v3-package-validation.yml` and `v3-release-acceptance.yml`; every other job keeps its existing timeout. `test-v3-package-workflow.py` (4/4) and `test-v3-ci-workflows.py` (31/31) pass. +- [ ] Full FreeBSD CI evidence with the corrected budget. + +The 2026-10-26 FreeBSD tls-yes `check-v3-public-http2.py` shared-external failure ("no retired connection generation captured", one occurrence) is retained as an open flake: the consumer's `stale` check depends on the single-point `snapshot` having captured the soon-to-retire H2 connection's published registration, and the saved pair in that run was still live at `stale` time. No consumer change is made without a recurrence; the 2026-10-30 runs will decide. + +Full local `make check -j1` (TLS=no, g++ 15.2, WSL2): 232/235 pass, 2 platform skips (iocp/kqueue), 1 FAIL `quic_flow_control` (12 checks expecting ceiling-rejection throws). Byte-identical failures with stage 7 reverted and at b71192c9, so pre-existing and unrelated to this repair; CI builds this test with clang where it passes. Recorded as a local g++ environment observation, not a regression. + +## Stage 9: Windows audit client closure (2026-10-30) + +- [x] The 2026-10-30 mingw64 package run (job c8dc2d64) passed every external/installed suite in both linkages for the first time (stage 7 repair holding) and advanced to `check-v3-public-tls.py`, which failed walking the Python TLS client: `AuditError: unresolved PE import: version.dll` from `_ssl.pyd` -> `libpython3.14.dll` (its import table lists VERSION.dll; api-ms-win-* names already match the existing pattern rule). +- [x] Verified against the real MSYS2 mingw64 packages (python 3.14.9-1, openssl 3.6.5-1, libgcc 16.2.0-6, winpthreads, libstdc++): after adding `version.dll` to `WINDOWS_SYSTEM`, the full `_ssl` client closure resolves -- remaining names are WINDOWS_SYSTEM entries or MSYS2 runtime DLLs that the same run's own recorded suite graphs already resolve through `V3_PACKAGE_DLL_DIRS`. The mingw-built integration closures (from the run's suites.json) import only allowlisted system DLLs. +- [x] One-line allowlist addition in `scripts/audit-v3-installed-package.py`; `version.dll` is a documented System32 component (version-information APIs). `test-v3-installed-package.py` 60/60; `test-v3-installed-consumer.py` 97/97 (one unrelated compile-timing flake on the first local run, clean on rerun). +- [ ] Full mingw64 CI evidence past the client audit. + +## Stage 10: Windows public audit client linkage (2026-10-30) + +- [x] Evidence: the 2026-10-30 mingw64 package job (124062430922) and the Windows acceptance job (124062636691) both advanced past every earlier gate (installed suites, traffic cells, compiled operations and the Python TLS client audit of stage 9) and failed at the same place: linking the independent TLS client in `check-v3-public-tls.py`, with `undefined reference to __imp_WSAStartup/__imp_socket/__imp_htons/__imp_htonl/__imp_connect/__imp_closesocket` (tls_client.cpp already carries its own `_WIN32` Winsock path; only the link line lacked the system library). +- [x] Repair in `scripts/check-v3-public-tls.py`: the independent client is named with the platform `.exe` suffix and linked with `-lws2_32` on `os.name == 'nt'` (POSIX targets unchanged), and the provider `openssl` tool is invoked with its explicit `.exe` name on Windows, matching `run-v3-release-ci.py`. +- [x] Same-class parity repair in `scripts/check-v3-public-http2.py` (preemptive, code-audit driven, not yet exercised on Windows): consumers and examples get the `.exe` suffix and the `ws2_32/mswsock/bcrypt` system libraries replace the POSIX-only `-pthread`/`-rpath` line, matching the `check-v3-installed-consumer.py`/`check-v3-public-tls.py` convention; runtime DLL resolution stays on the `runtime_environment` PATH/V3_PACKAGE_DLL_DIRS mechanism. Local gates: `test-v3-public-http2.py` 6/6, `py_compile` on both scripts. +- [ ] Full mingw64/Windows CI evidence past the public TLS client link. + +## Stage 11: first FreeBSD native make-check exposure (2026-10-30) + +- [x] Evidence (5.1GB failure artifact 12993618134, run 41055662147, analyzed in full): the FreeBSD acceptance lane passed the entire installed matrix (both TLS lanes incl. public-tls and public-http2) in ~18 minutes, then reached `gmake check` for the first time ever (every earlier acceptance run died earlier: the Oct-26 runs at the http2 stale flake/copyback, the Oct-28 runs at the 180-min timeout). The first `make check` ran the full 7200s command deadline and was killed with only `response_sources` still unfinished: test 24 `threaded_pipe_sends_single_winner` started and never printed a result. Four real test failures were also recorded: `response_definition` (checks 846/847, `file_send_truncated_mid_send_fails`, completed in 2.58ms with `done==0`), `native_protocol_memory` ("process memory measurement unavailable" — no FreeBSD branch), `consumer_v3_peer_policy_runtime` (line 86 = bind/connect of the 127.0.0.2 admin source: FreeBSD has no 127/8 loopback alias by default), and `integ/http3_full_gate_runner_test.py` (`make -j4` in the gate runner is BSD make on FreeBSD; needs gmake). +- [x] Repairs with proven causes: `native_protocol_memory_test.cpp` gains FreeBSD (kinfo_proc sysctl) and Windows (GetProcessMemoryInfo) resident-memory branches — the Windows acceptance make check needs the same branch; `run-v3-http3-full-gates.py` selects gmake when present (GNU-make Makefiles, BSD default make); the freebsd acceptance VM run script adds-or-verifies the `lo0` 127.0.0.2 alias (no-op where `ifconfig lo0` is absent, e.g. the Linux workflow tests). +- [x] Hang bound: `test/timeout-wrapper.sh` (EXTRA_DIST, `LOG_COMPILER`) wraps every native test with `timeout -k 60 600` where timeout(1) exists, so a recurrence cannot consume a whole CI make-check budget; hosts without timeout (macOS default) run exactly as before. +- [x] Budget: freebsd acceptance `timeout-minutes` 240 → 300 (observed: ~95 min prepare with the pinned-Node source build + ~18 min matrix + make check ×2 + evidence recovery). +- [x] Local gates: fresh worktree configure/build; `native_protocol_memory` passes under the new LOG_COMPILER wrapper; `test-v3-ci-workflows.py` 31/31, `test-v3-platform-ci.py` 20/20, `test-v3-http3-full-gates.py` 1/1; changed-file cpplint and YAML parse pass. +- [ ] Open flakes (no repair without a recurrence, per the standing http2-stale precedent): `response_sources` test 24 hang (one occurrence; the claim/close guard is a single atomic CAS and `pull_handle`'s fread cannot block on a pipe whose write end is closed — mechanism unexplained) and `response_definition` checks 846/847 (coroutine did not complete after the mid-send truncation). Both pass on Linux/macOS; the next FreeBSD run with bounded hangs decides. +- [ ] Full FreeBSD acceptance CI evidence. + +## Stage 12: Windows TLS-on interop keylog portability (2026-10-30) + +- [x] Evidence (mingw64 job 124232738670, Windows acceptance job 124233097485, both at b080f744): with stage 10 in place the mingw64 package lane printed `PASS installed consumer native TLS=no` — the entire installed tls-no matrix including public-tls and public-http2 passed on Windows for the first time. The tls-yes lane then failed building `libquic_interop_provider_a`: `src/detail/tls_session.cpp:127/129` use `O_NOFOLLOW`, `O_CLOEXEC` and `fchmod`, none of which exist on Windows. +- [x] Repair: `open_keylog()` gains a `_WIN32` branch using `_O_NOINHERIT` (the close-on-exec analog) and dropping `fchmod` (the creating open already owns the file); the POSIX branch is unchanged. This is the only site in `src/` using those identifiers (`quic_tls_session.cpp` is clean). POSIX-branch syntax check with the interop define passes locally; cpplint clean. +- [ ] Full Windows tls-yes CI evidence past the interop provider build (SNI/rotation scenarios and the tls-yes public audits remain first-exposure). + +## Stage 13: Windows QUIC fixture polls (2026-10-30) + +- [x] Evidence (mingw64 job at db7cefec, 41112829610): with the stage-12 keylog fix the tls-yes `make -j2` advanced past `libquic_interop_provider_a` and failed at the next noinst program: `test/integ/quic_interop_endpoint.cpp:3: 'poll.h' file not found`. +- [x] Repair: the interop endpoint's hand-rolled poll loop now goes through the shared `examples/host_poll.hpp` helper (WSAPoll on Windows, the same contract every external-loop consumer uses), preserving the connection-deadline folding and the one-second park bound; `chmod` of the log directory is POSIX-only. `test/integ/http3_udp_fixture.cpp` — the next `poll.h` includer in the same build set — takes a `_WIN32` branch on the same helper while its POSIX stdin-poll path stays byte-identical (the stdin command channel is driven only by the POSIX parity lanes). +- [x] Local gates: both files pass cpplint and `g++ -std=c++20 -fsyntax-only` with the full build flags; `integ/http3_full_gate_runner_test.py` 27/27 (it drives the gate runner that builds and runs these fixtures). +- [ ] Full Windows tls-yes CI evidence past the QUIC fixtures. diff --git a/src/detail/connection_engine_websocket.cpp b/src/detail/connection_engine_websocket.cpp index dc0d7914..f805a7ef 100644 --- a/src/detail/connection_engine_websocket.cpp +++ b/src/detail/connection_engine_websocket.cpp @@ -227,7 +227,13 @@ connection_engine::io_posture connection_engine::writer_posture( return io_posture::park; } bool connection_engine::watchdog_terminal_locked() const { - return shutdown_ || phase_ == stream_phase::terminal || (close_after_drain_ && !websocket_); + // The close mark alone must not retire the watchdog while a server + // drain deadline is pending: HTTP/1 enforces that deadline's hard + // stop only here (enforce_timeout's scope expiry), so a stalled + // close handoff may not outlive the drain budget. The transport's + // release still ends this loop with connection_closed. + return shutdown_ || phase_ == stream_phase::terminal + || (close_after_drain_ && !websocket_ && !server_drain_deadline_); } bool connection_engine::transport_output_pending_locked() const { if (outbox_.queued_bytes() > 0) return true; diff --git a/src/detail/tls_session.cpp b/src/detail/tls_session.cpp index baa20cc9..9de2cbc7 100644 --- a/src/detail/tls_session.cpp +++ b/src/detail/tls_session.cpp @@ -124,11 +124,21 @@ struct tls_session::impl { void open_keylog() { const char* path = std::getenv("SSLKEYLOGFILE"); if (!quic || !interop_mode() || !path || !*path) return; +#if defined(_WIN32) + // Windows has no O_NOFOLLOW/O_CLOEXEC/fchmod; _O_NOINHERIT is the + // close-on-exec analog and the creating open already owns the file. + keylog_fd = ::open(path, O_WRONLY | O_CREAT | O_APPEND | _O_NOINHERIT, 0600); + struct stat info{}; + if (keylog_fd < 0 || fstat(keylog_fd, &info) != 0 || !S_ISREG(info.st_mode)) { + throw std::runtime_error("test keylog unavailable"); + } +#else keylog_fd = ::open(path, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW | O_CLOEXEC, 0600); struct stat info{}; if (keylog_fd < 0 || fstat(keylog_fd, &info) != 0 || !S_ISREG(info.st_mode) || fchmod(keylog_fd, 0600) != 0) { throw std::runtime_error("test keylog unavailable"); } +#endif } static void keylog(const SSL* ssl, const char* line) noexcept { auto* self = state(const_cast(ssl)); diff --git a/test/Makefile.am b/test/Makefile.am index c8df9354..c42628fd 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -727,6 +727,12 @@ consumer_v3_features_CPPFLAGS = -I$(top_srcdir)/src consumer_v3_features_LDADD = $(top_builddir)/src/libhttpserver_v3core.la AM_TESTS_ENVIRONMENT = V3_TLS_MODE="$(enable_v3_tls)"; export V3_TLS_MODE; +# Bound every native test program so one hung binary cannot consume a +# whole CI make-check command budget; the wrapper degrades to a plain +# exec where timeout(1) is unavailable (macOS by default). +LOG_COMPILER = $(top_srcdir)/test/timeout-wrapper.sh +EXTRA_DIST += timeout-wrapper.sh + basic_auth_corpus_LDFLAGS = $(V3_TLS_LDFLAGS) $(AM_LDFLAGS) body_reader_LDFLAGS = $(V3_TLS_LDFLAGS) $(AM_LDFLAGS) @@ -1588,7 +1594,6 @@ consumer_v3_peer_policy_runtime_LDADD = $(top_builddir)/src/libhttpserver_v3core consumer_v3_peer_policy_runtime_LDFLAGS = $(V3_TLS_LDFLAGS) $(AM_LDFLAGS) check_PROGRAMS += http_cookie consumer_v3_cookie -TESTS += http_cookie consumer_v3_cookie http_cookie_SOURCES = unit/http_cookie_test.cpp http_cookie_LDADD = $(top_builddir)/src/libhttpserver.la $(V3_TLS_LIBS) http_cookie_LDFLAGS = $(V3_TLS_LDFLAGS) $(AM_LDFLAGS) @@ -1598,7 +1603,6 @@ consumer_v3_cookie_LDADD = $(top_builddir)/src/libhttpserver.la $(V3_TLS_LIBS) consumer_v3_cookie_LDFLAGS = $(V3_TLS_LDFLAGS) $(AM_LDFLAGS) check_PROGRAMS += consumer_v3_cookie_wire -TESTS += consumer_v3_cookie_wire consumer_v3_cookie_wire_SOURCES = integ/consumer_v3_cookie_wire.cpp consumer_v3_cookie_wire_CPPFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/test -I$(top_srcdir)/examples consumer_v3_cookie_wire_LDADD = $(top_builddir)/src/libhttpserver.la $(V3_TLS_LIBS) @@ -1646,11 +1650,9 @@ compiled_operations_CPPFLAGS = -I$(top_srcdir)/src compiled_operations_LDADD = $(top_builddir)/src/libhttpserver.la check_PROGRAMS += protocol_limits -TESTS += protocol_limits protocol_limits_SOURCES = unit/protocol_limits_test.cpp check_PROGRAMS += protocol_compression -TESTS += protocol_compression protocol_compression_SOURCES = unit/protocol_compression_test.cpp TESTS += integ/release_ci_test.py integ/release_platform_bundle_test.py diff --git a/test/installed/v3-package-matrix.json b/test/installed/v3-package-matrix.json index 210a368c..3811a44c 100644 --- a/test/installed/v3-package-matrix.json +++ b/test/installed/v3-package-matrix.json @@ -73,6 +73,42 @@ "external_readiness_adapter": { "sources": [ "unit/external_readiness_adapter_test.cpp" + ], + "required_cases": [ + "host_drives_read_write_and_snapshot_is_owned", + "spurious_unknown_and_wrong_generation_do_not_consume", + "omitted_then_republished_registration_rejects_old_callbacks", + "reopen_same_id_and_descriptor_rejects_every_old_flag_combination", + "detached_read_cannot_rearm_into_reopened_id", + "detached_accept_cannot_rearm_into_reopened_id", + "exclusive_retirement_expires_real_timer_without_host_dispatch", + "timers_use_host_clock_order_and_reject_decreasing_samples", + "timer_cancel_expiry_race_is_exactly_once", + "wake_is_doorbell_and_post_snapshot_work_survives_acknowledgement", + "overlapping_dispatch_rejects_without_consuming_valid_read", + "host_accept_and_close_deliver_once", + "wake_published_during_dispatch_survives_and_recursive_overlap_reject", + "release_during_completion_defers_close_and_cannot_step_replacement", + "hard_wake_failure_stops_and_unwinds_pending_work", + "decreasing_clock_rejects_a_valid_socket_event_before_io", + "exhausted_identity_never_wraps_and_closes_unregistered_socket", + "buffered_read_precedes_combined_close_error_and_duplicate_callback", + "complete_snapshots_prune_retired_connection_records", + "allocation_failure_read_batch_remains_owned_until_close", + "allocation_failure_write_batch_remains_owned_until_close", + "allocation_failure_accept_batch_remains_owned_until_close", + "allocation_failure_second_direction_keeps_first_direction_owned", + "allocation_failure_due_timers_remain_owned_until_close", + "exhausted_generation_fails_snapshot_and_shutdown_completes_once", + "successful_accept_after_listener_retirement_closes_fresh_handle", + "successful_accept_after_backend_close_closes_fresh_handle" + ], + "windows_static_cases": [ + "allocation_failure_read_batch_remains_owned_until_close", + "allocation_failure_write_batch_remains_owned_until_close", + "allocation_failure_accept_batch_remains_owned_until_close", + "allocation_failure_second_direction_keeps_first_direction_owned", + "allocation_failure_due_timers_remain_owned_until_close" ] }, "readiness_contract": { diff --git a/test/integ/http3_udp_fixture.cpp b/test/integ/http3_udp_fixture.cpp index 39becd58..9d73546f 100644 --- a/test/integ/http3_udp_fixture.cpp +++ b/test/integ/http3_udp_fixture.cpp @@ -1,5 +1,9 @@ /* Copyright (C) 2026 Sebastiano Merlino; SPDX-License-Identifier: LGPL-2.1-or-later */ +#if defined(_WIN32) +#include "../../examples/host_poll.hpp" +#else #include +#endif #include #include @@ -376,14 +380,22 @@ struct fixture { // its successful completion is committed in the next owner turn. if (executor.pending() || (send && send->state()->applied()) || receive->state()->applied()) return true; auto snapshot = backend.interests(); - if (snapshot.wake) snapshot.sockets.push_back(*snapshot.wake); - std::vector descriptors{{STDIN_FILENO, POLLIN, 0}}; - for (auto& interest : snapshot.sockets) - descriptors.push_back({static_cast(interest.handle.value), static_cast((interest.readable ? POLLIN : 0) | (interest.writable ? POLLOUT : 0)), 0}); auto due = lifetime; for (auto& [id, connection] : connections) if (auto deadline = connection->deadline()) due = std::min(due, *deadline); if (snapshot.next_deadline) due = std::min(due, *snapshot.next_deadline); +#if defined(_WIN32) + // The stdin command channel is driven only by the POSIX parity + // lanes; on Windows the fixture builds but never serves commands. + const auto park = std::min(due, clock_type::now() + std::chrono::milliseconds(1000)); + if (!backend.dispatch(v3_host::wait(snapshot, park), clock_type::now()).ok()) + throw std::runtime_error("backend dispatch failed"); + return true; +#else + if (snapshot.wake) snapshot.sockets.push_back(*snapshot.wake); + std::vector descriptors{{STDIN_FILENO, POLLIN, 0}}; + for (auto& interest : snapshot.sockets) + descriptors.push_back({static_cast(interest.handle.value), static_cast((interest.readable ? POLLIN : 0) | (interest.writable ? POLLOUT : 0)), 0}); int timeout = std::max(std::int64_t{0}, std::chrono::ceil(due - clock_type::now()).count()); if (poll(descriptors.data(), descriptors.size(), timeout) < 0) throw std::runtime_error("poll failed"); if (descriptors[0].revents & (POLLIN | POLLHUP)) { @@ -401,6 +413,7 @@ struct fixture { } if (!backend.dispatch(events, clock_type::now()).ok()) throw std::runtime_error("backend dispatch failed"); return true; +#endif } }; } // namespace diff --git a/test/integ/native_protocol_memory_test.cpp b/test/integ/native_protocol_memory_test.cpp index d799643a..0f722359 100644 --- a/test/integ/native_protocol_memory_test.cpp +++ b/test/integ/native_protocol_memory_test.cpp @@ -21,6 +21,14 @@ #if defined(__APPLE__) #include +#elif defined(__FreeBSD__) +#include +#include +#include +#include +#elif defined(_WIN32) +#include +#include #else #include #include @@ -46,6 +54,22 @@ std::size_t resident_bytes() { check(task_info(mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast(&info), &count) == KERN_SUCCESS, "process memory measurement unavailable"); return info.resident_size; +#elif defined(__FreeBSD__) + // FreeBSD ships no procfs by default; the native source is the + // per-process sysctl MIB (kinfo_proc's resident page count). + int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, static_cast(::getpid())}; + kinfo_proc info{}; + size_t size = sizeof info; + check(::sysctl(mib, 4, &info, &size, nullptr, 0) == 0 + && size >= offsetof(kinfo_proc, ki_rssize) + sizeof info.ki_rssize, + "process memory measurement unavailable"); + return static_cast(info.ki_rssize) + * static_cast(::sysconf(_SC_PAGESIZE)); +#elif defined(_WIN32) + PROCESS_MEMORY_COUNTERS counters{}; + check(::GetProcessMemoryInfo(::GetCurrentProcess(), &counters, sizeof counters) != 0, + "process memory measurement unavailable"); + return counters.WorkingSetSize; #else std::ifstream stat("/proc/self/statm"); std::size_t total = 0, resident = 0; diff --git a/test/integ/quic_interop_endpoint.cpp b/test/integ/quic_interop_endpoint.cpp index eff667dc..7883777b 100644 --- a/test/integ/quic_interop_endpoint.cpp +++ b/test/integ/quic_interop_endpoint.cpp @@ -1,6 +1,5 @@ /* Copyright (C) 2026 Sebastiano Merlino; SPDX-License-Identifier: LGPL-2.1-or-later */ #include -#include #include #include #include @@ -20,6 +19,7 @@ #include #include #include "../support/http3_network_owner.hpp" +#include "../../examples/host_poll.hpp" #include "../unit/io_udp_backend_contract.hpp" namespace { volatile std::sig_atomic_t stopping = 0; @@ -294,30 +294,16 @@ struct fixture { if (executor.pending() || (send && send->state()->applied()) || receive->state()->applied()) return true; auto snapshot = backend.interests(); - if (snapshot.wake) - snapshot.sockets.push_back(*snapshot.wake); - std::vector descriptors; - for (auto& interest : snapshot.sockets) - descriptors.push_back( - {static_cast(interest.handle.value), static_cast((interest.readable ? POLLIN : 0) | (interest.writable ? POLLOUT : 0)), 0}); auto due = lifetime; for (auto& [id, connection] : connections) if (auto deadline = connection->deadline()) due = std::min(due, *deadline); if (snapshot.next_deadline) due = std::min(due, *snapshot.next_deadline); - int timeout = std::min(1000, std::max(std::int64_t{0}, std::chrono::ceil(due - clock_type::now()).count())); - if (poll(descriptors.data(), descriptors.size(), timeout) < 0 && !stopping) - throw std::runtime_error("poll failed"); - std::vector events; - for (unsigned i = 0; i < snapshot.sockets.size(); ++i) - if (descriptors[i].revents) { - auto& interest = snapshot.sockets[i]; - auto revents = descriptors[i].revents; - events.push_back({interest.key, interest.generation, static_cast(revents & POLLIN), static_cast(revents & POLLOUT), - static_cast(revents & POLLHUP), static_cast(revents & (POLLERR | POLLNVAL))}); - } - if (!backend.dispatch(events, clock_type::now()).ok()) + // Park at most one second past the earliest due work; the shared + // host helper owns the platform poll (WSAPoll on Windows). + const auto park = std::min(due, clock_type::now() + std::chrono::milliseconds(1000)); + if (!backend.dispatch(v3_host::wait(snapshot, park), clock_type::now()).ok()) throw std::runtime_error("backend dispatch failed"); return true; } @@ -333,7 +319,9 @@ int main() { hqnet::files root_check(env("LIBHTTPSERVER_INTEROP_WWW", "/www")); const auto logs = env("LIBHTTPSERVER_INTEROP_LOGS", "/logs"); std::filesystem::create_directories(logs); +#if !defined(_WIN32) if (chmod(logs.c_str(), 0700) != 0) throw std::runtime_error("log permissions failed"); +#endif const auto pcap = logs + "/packets.pcap"; std::signal(SIGTERM, stop); std::signal(SIGINT, stop); diff --git a/test/integ/release_platform_bundle_test.py b/test/integ/release_platform_bundle_test.py index c646844c..2e8a6600 100755 --- a/test/integ/release_platform_bundle_test.py +++ b/test/integ/release_platform_bundle_test.py @@ -132,7 +132,7 @@ def test_valid_archive_import_rejects_duplicate_members_and_namespace_collisions stream.add(root/'Darwin/catalog.json',arcname='Darwin/catalog.json') with self.assertRaises(ValueError): module.unpack(duplicate,root/'duplicate') - def platform_origin_fixture(self): + def platform_origin_fixture(self, platform='Darwin'): module = self.module() import package_matrix as matrix import release_artifact as identity @@ -147,7 +147,7 @@ def write(path, value): path.write_text(json.dumps(value)) header = root / 'provider.h'; header.write_bytes(b'selected OpenSSL\r\n') selected = dict(archive_sha256='a'*64, snapshot_sha256='b'*64, extracted_sha256=hashlib.sha256(json.dumps({},sort_keys=True).encode()).hexdigest(), tls='both', sanitizer='none') - write(package/'summary.json', dict(status='passed', host='Darwin', lanes={'no':{},'yes':{}}, + write(package/'summary.json', dict(status='passed', host=platform, lanes={'no':{},'yes':{}}, revision='d'*40, provider_header_sha256=identity.sha256(header), release_artifact={'archive':{'sha256':'a'*64}, 'source':{'snapshot_sha256':'b'*64,'head':'d'*40}})) for mode in ('no','yes'): @@ -160,7 +160,7 @@ def write(path, value): public[linkage]=dict(dependencies=graph, compile={'dependencies':[]}, cells=[{'configurations':inventory['cases']},{'configurations':inventory['cases']}]) suites={} - for name, definition in matrix.suites(inventory, 'Darwin', mode).items(): + for name, definition in matrix.suites(inventory, platform, mode).items(): cases=definition.get('required_cases',[]) or ['fixture_case'] log=lane/('integration-'+linkage)/(name+'.log') log.parent.mkdir(parents=True, exist_ok=True) @@ -171,28 +171,56 @@ def write(path, value): write(lane/'audit.json',dict(violations=[],tls_mode=mode,public=public)) origin=dict(package=str(package), source_root=str(root/'source'), source_commit='d'*40, provider_header=str(header), native={}) # This test checks installed matrix origin independently; native origin is mandatory in complete bundles. - counts=module.package_origin(ROOT, origin, lambda p: json.loads(Path(p).read_text()), lambda p: Path(p).read_text(), selected, 'Darwin') + counts=module.package_origin(ROOT, origin, lambda p: json.loads(Path(p).read_text()), lambda p: Path(p).read_text(), selected, platform) self.assertGreater(counts['tests'],0) wrong_origin=dict(origin,source_commit='e'*40) with self.assertRaises(ValueError): - module.package_origin(ROOT,wrong_origin,lambda p:json.loads(Path(p).read_text()),lambda p:Path(p).read_text(),selected,'Darwin') + module.package_origin(ROOT,wrong_origin,lambda p:json.loads(Path(p).read_text()),lambda p:Path(p).read_text(),selected,platform) for mode in ('no','yes'): build=root/('native-'+mode); (build/'test').mkdir(parents=True) names=['protocol_fuzz_replay','hpack_corpus','http2_fuzz_replay','quic_fuzz_replay', - 'quic_recovery_fuzz_replay','http3_fuzz_replay','qpack_fuzz_replay','io_kqueue_backend'] + 'quic_recovery_fuzz_replay','http3_fuzz_replay','qpack_fuzz_replay','io_' + inventory['platforms'][platform] + '_backend'] if mode=='yes': names.append('quic_tls_callbacks_fuzz_replay') for name in names: (build/'test'/(name+'.trs')).write_text(':test-result: PASS\n') (build/'test/test-suite.log').write_text('# TOTAL: %d\n# PASS: %d\n# SKIP: 0\n# XFAIL: 0\n# FAIL: 0\n# XPASS: 0\n# ERROR: 0\n'%(len(names),len(names))) origin['native'][mode]=dict(build=str(build), tests=names) counts=checks.counts(counts['tests']+len(names),counts['checks']+len(names)) artifacts=[dict(path=str(header),sha256=identity.sha256(header),bytes=header.stat().st_size)] - row=dict(id='Darwin',identity=selected,archive_sha256='a'*64,origin=origin,status='passed',artifacts=artifacts, - receipt=dict(status='passed',gate='Darwin',identity=selected,counts=counts,artifacts=artifacts)) + row=dict(id=platform,identity=selected,archive_sha256='a'*64,origin=origin,status='passed',artifacts=artifacts, + receipt=dict(status='passed',gate=platform,identity=selected,counts=counts,artifacts=artifacts)) retained=root/'retained' - catalog=module.retain(retained,'Darwin',row,[p for p in root.rglob('*') if p.is_file()]) + catalog=module.retain(retained,platform,row,[p for p in root.rglob('*') if p.is_file()]) module.validate_origin(ROOT,row,catalog) return module, root, package, header, selected, origin, row, catalog + def test_windows_origin_retains_static_fault_proof_and_accepts_dll_profile(self): + import package_matrix as matrix + module, _, package, _, selected, origin, _, _ = self.platform_origin_fixture('Windows') + definition = matrix.load()['suites']['external_readiness_adapter'] + + def rewrite(linkage, cases): + directory = package / 'tls-no' / ('integration-' + linkage) + suites_path = directory / 'suites.json' + suites = json.loads(suites_path.read_text()) + receipt = suites['external_readiness_adapter'] + log = Path(receipt['log']) + log.write_text(''.join('Running test (%d): %s\n' % (i, case) for i, case in enumerate(cases)) + + '%d tests executed\n%d checks\n-> 0 failures\n-> 0 skipped\n' % (len(cases), len(cases))) + receipt.update(matrix.suite_result(log.read_text(), 0)) + suites_path.write_text(json.dumps(suites)) + + ordinary = [name for name in definition['required_cases'] if name not in definition['windows_static_cases']] + rewrite('shared', ordinary) + try: + module.package_origin(ROOT, origin, lambda p: json.loads(Path(p).read_text()), + lambda p: Path(p).read_text(), selected, 'Windows') + except ValueError as error: + self.fail('valid DLL/static split rejected: ' + str(error)) + rewrite('static', ordinary) + with self.assertRaisesRegex(ValueError, 'required suite cases absent'): + module.package_origin(ROOT, origin, lambda p: json.loads(Path(p).read_text()), + lambda p: Path(p).read_text(), selected, 'Windows') + def test_platform_origin_rejects_pruned_mandatory_native_suite(self): import release_checks as checks module, root, _, _, _, _, row, catalog = self.platform_origin_fixture() diff --git a/test/timeout-wrapper.sh b/test/timeout-wrapper.sh new file mode 100755 index 00000000..7b962efc --- /dev/null +++ b/test/timeout-wrapper.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Bound one native test program so a single hung binary cannot consume +# a whole CI make-check command budget. Hosts without timeout(1) +# (macOS by default) run tests exactly as before. +if command -v timeout >/dev/null 2>&1; then + exec timeout -k 60 600 "$@" +fi +exec "$@" diff --git a/test/unit/connection_engine_test.cpp b/test/unit/connection_engine_test.cpp index 5eecea11..13931924 100644 --- a/test/unit/connection_engine_test.cpp +++ b/test/unit/connection_engine_test.cpp @@ -68,6 +68,7 @@ #include #include "./io_loopback.hpp" +#include "./websocket_engine_helpers.hpp" #include "./littletest.hpp" namespace { @@ -301,7 +302,7 @@ struct scenario { explicit scenario(srv::timeout_options timeouts_in) : scenario(srv::budget_limits(), timeouts_in) { } - bool start_engine() { + bool create_engine() { if (!pair.ok()) return false; backend.adopt_connection(kConnId, pair.detach_local()); engine = std::make_shared(backend, pool, registry, @@ -310,6 +311,11 @@ struct scenario { [this] { stopped.store(true); }); + return true; + } + + bool start_engine() { + if (!create_engine()) return false; engine->start(); return true; } @@ -1215,6 +1221,45 @@ LT_BEGIN_AUTO_TEST(connection_engine_suite, quiesce_idle_connection_closes_promp LT_CHECK(reaches_eof(s.pair.peer())); LT_END_AUTO_TEST(quiesce_idle_connection_closes_promptly) +// TASK-110 pin: a quiesce that carries a server drain deadline keeps the +// watchdog armed at that deadline. HTTP/1 enforces the drain budget's +// hard stop only through the watchdog (enforce_timeout's scope expiry), +// so the close mark alone must not retire it while the deadline is +// pending -- a stalled close handoff may not outlive the drain budget. +// Loops stay unstarted so the plan is sampled before any close path can +// complete; a deadline-less quiesce stays terminal exactly as before. +LT_BEGIN_AUTO_TEST(connection_engine_suite, quiesce_with_drain_deadline_keeps_watchdog_armed) + srv::timeout_options timeouts; + timeouts.header = std::chrono::milliseconds(10000); + scenario s(timeouts); + LT_CHECK(s.create_engine()); + const auto drain_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + s.engine->quiesce(drain_deadline); + const auto armed = httpserver::detail::connection_engine_test_access::deadline(*s.engine); + LT_CHECK(armed.has_value()); + if (armed) LT_CHECK(*armed <= drain_deadline); + scenario plain; + LT_CHECK(plain.create_engine()); + plain.engine->quiesce(); + LT_CHECK(!httpserver::detail::connection_engine_test_access::deadline(*plain.engine).has_value()); +LT_END_AUTO_TEST(quiesce_with_drain_deadline_keeps_watchdog_armed) + +// The armed drain deadline must not delay the ordinary close chain: a +// started engine quiesced with a far drain budget still ends (and the +// transport closes) promptly, as in the deadline-less case above. +LT_BEGIN_AUTO_TEST(connection_engine_suite, quiesce_with_drain_deadline_still_closes_promptly) + srv::timeout_options timeouts; + timeouts.header = std::chrono::milliseconds(10000); + scenario s(timeouts); + LT_CHECK(s.start_engine()); + const auto began = std::chrono::steady_clock::now(); + s.engine->quiesce(std::chrono::steady_clock::now() + std::chrono::seconds(10)); + LT_CHECK(wait_until([&s] { return s.stopped.load(); })); + const auto elapsed = std::chrono::steady_clock::now() - began; + LT_CHECK(elapsed < std::chrono::milliseconds(5000)); + LT_CHECK(reaches_eof(s.pair.peer())); +LT_END_AUTO_TEST(quiesce_with_drain_deadline_still_closes_promptly) + // TASK-110: the counted units bracket the exchange -- the handler runs // between the connection unit and its own exchange unit (2 live), and // the count returns to zero once the engine finalized. diff --git a/test/unit/external_readiness_adapter_test.cpp b/test/unit/external_readiness_adapter_test.cpp index e10a216e..70ee2601 100644 --- a/test/unit/external_readiness_adapter_test.cpp +++ b/test/unit/external_readiness_adapter_test.cpp @@ -39,6 +39,7 @@ #include "./io_backend_contract.hpp" #include "./littletest.hpp" +#ifndef LHS_TEST_DLL_LINKAGE // Fault injection is confined to this test executable and the dispatching // thread. Restore allocation before asserting or shutting down the backend. namespace { @@ -79,6 +80,7 @@ void* operator new(std::size_t size) { void operator delete(void* memory) noexcept { std::free(memory); } void operator delete(void* memory, std::size_t) noexcept { std::free(memory); } #endif +#endif // LHS_TEST_DLL_LINKAGE namespace srv = httpserver::server; namespace { @@ -203,6 +205,7 @@ void detached_reopen(littletest::test_runner* __lt_tr__, const char* __lt_name__ LT_CHECK_EQ(fx.backend.pending_count(), std::size_t{0}); } +#ifndef LHS_TEST_DLL_LINKAGE void failed_direction_collection(littletest::test_runner* __lt_tr__, const char* __lt_name__, hd::io_op_kind kind) { fixture fx; @@ -253,6 +256,8 @@ void failed_direction_collection(littletest::test_runner* __lt_tr__, LT_CHECK_EQ(second.delivered.load(), 1); } +#endif // LHS_TEST_DLL_LINKAGE + void accepted_after_teardown(littletest::test_runner* __lt_tr__, const char* __lt_name__, bool shutdown) { fixture fx; @@ -855,6 +860,9 @@ LT_BEGIN_AUTO_TEST(external_adapter_suite, complete_snapshots_prune_retired_conn } LT_END_AUTO_TEST(complete_snapshots_prune_retired_connection_records) +#ifndef LHS_TEST_DLL_LINKAGE +// PE DLL allocations bind their own allocator imports. These cases run against +// the installed static library, where this executable's override reaches them. LT_BEGIN_AUTO_TEST(external_adapter_suite, allocation_failure_read_batch_remains_owned_until_close) failed_direction_collection(__lt_tr__, __lt_name__, hd::io_op_kind::read); LT_END_AUTO_TEST(allocation_failure_read_batch_remains_owned_until_close) @@ -923,6 +931,7 @@ LT_BEGIN_AUTO_TEST(external_adapter_suite, allocation_failure_due_timers_remain_ LT_CHECK_EQ(first.delivered.load(), 1); LT_CHECK_EQ(second.delivered.load(), 1); LT_END_AUTO_TEST(allocation_failure_due_timers_remain_owned_until_close) +#endif // LHS_TEST_DLL_LINKAGE LT_BEGIN_AUTO_TEST(external_adapter_suite, exhausted_generation_fails_snapshot_and_shutdown_completes_once) fixture fx; diff --git a/test/unit/tls_psk_fixture.hpp b/test/unit/tls_psk_fixture.hpp index c8ca74d6..3c478476 100644 --- a/test/unit/tls_psk_fixture.hpp +++ b/test/unit/tls_psk_fixture.hpp @@ -135,17 +135,20 @@ struct connection : mtls_test::adapter_connection { if (host) SSL_set_tlsext_host_name(peer.get(), host); handshake = hd::tls_handshake_operation(owner, 1, std::chrono::steady_clock::now() + timeout); } - void tick() { - ex.run_pending(); - for (const auto& op : raw.pending_ops()) transfer(op); + bool tick() { + bool progressed = ex.run_pending() != 0; + for (const auto& op : raw.pending_ops()) progressed = transfer(op) || progressed; advance_peer(); + return progressed || ex.pending() != 0; } template bool until(Predicate predicate, std::chrono::milliseconds timeout = std::chrono::seconds(3)) { const auto deadline = std::chrono::steady_clock::now() + timeout; while (std::chrono::steady_clock::now() < deadline) { - tick(); + const bool progressed = tick(); if (predicate()) return true; - std::this_thread::sleep_for(std::chrono::milliseconds(1)); + // Fragmentation is deliberate; adding a scheduling delay to every + // ready fragment can consume the real handshake deadline. + if (!progressed) std::this_thread::yield(); } return false; } diff --git a/test/unit/tls_psk_test.cpp b/test/unit/tls_psk_test.cpp index 72a642fb..e59e24d3 100644 --- a/test/unit/tls_psk_test.cpp +++ b/test/unit/tls_psk_test.cpp @@ -119,23 +119,23 @@ LT_BEGIN_AUTO_TEST(psk_suite, blocking_lookup_leaves_owner_responsive_and_cancel auto released = release.get_future().share(); auto config = psk_test::credentials(runtime, [&](auto, const auto&) { entered = true; - released.wait(); + // The owner releases lookup only after checking cancellation. + // A broken blocking implementation must still unwind the fixture. + released.wait_for(5s); returned = true; return hd::psk_lookup_result{hd::psk_lookup_status::accepted, hd::secure_bytes(psk_test::bytes("secret"))}; }); hd::tls_credentials_registry registry; LT_ASSERT(registry.replace(config).ok()); psk_test::connection peer(registry.acquire()->select_default(), version, "identity", psk_test::bytes("secret")); - // Bounded rescue ensures RED cannot leave a fixture hanging in lookup. - auto rescue = std::async(std::launch::async, [&] { std::this_thread::sleep_for(250ms); release.set_value(); }); peer.handshake.submit(*peer.tls); std::chrono::steady_clock::duration longest{}; const auto until = std::chrono::steady_clock::now() + 1s; while (!entered && std::chrono::steady_clock::now() < until) { const auto before = std::chrono::steady_clock::now(); - peer.tick(); + const bool progressed = peer.tick(); longest = std::max(longest, std::chrono::steady_clock::now() - before); - std::this_thread::sleep_for(1ms); + if (!progressed) std::this_thread::yield(); } const auto cancelled = peer.tls->request_cancel(*peer.handshake.state()); peer.ex.run_pending(); @@ -147,7 +147,7 @@ LT_BEGIN_AUTO_TEST(psk_suite, blocking_lookup_leaves_owner_responsive_and_cancel LT_CHECK(peer.handshake.state()->stored_result().code == hh::outcome_code::cancelled); LT_CHECK(!peer.tls->peer_metadata()); peer.close(); - rescue.get(); + release.set_value(); runtime->stop(); LT_CHECK(runtime->drain(std::chrono::steady_clock::now() + 1s)); peer.ex.run_pending();