diff --git a/.github/workflows/portability.yml b/.github/workflows/portability.yml index 0f238496..b78a656a 100644 --- a/.github/workflows/portability.yml +++ b/.github/workflows/portability.yml @@ -51,22 +51,19 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-dev.txt - - name: DIAG 487 microbench (temporary) - if: matrix.python-version == '3.12' - run: python diag_487.py - - - name: DIAG 487 profile (temporary) - if: matrix.python-version == '3.12' - run: python diag_487_profile.py tests.test_database tests.test_review_schema tests.test_api_admin tests.test_lora_utils + # The runner's TEMP is on C:, the OS disk, where a flush took about 6 ms + # and a test database's create-to-delete about 111 ms; on the D: work + # disk the same took 0.5 ms and 23 ms (macOS: 19 ms). Tests write their + # databases under TEMP, so it moves to the runner's temp folder on D: + # (measured in #487, run 40915866444). + - name: Keep test files on the work disk (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + "TEMP=$env:RUNNER_TEMP" >> $env:GITHUB_ENV + "TMP=$env:RUNNER_TEMP" >> $env:GITHUB_ENV - name: Run tests env: PM_TEST_SLOWEST: "40" run: python -m tests.run_suite -v - - - name: DIAG 487 microbench without Defender realtime (temporary) - if: always() && matrix.python-version == '3.12' && runner.os == 'Windows' - shell: pwsh - run: | - Set-MpPreference -DisableRealtimeMonitoring $true - python diag_487.py diff --git a/TESTING.md b/TESTING.md index 7b1cb16d..4e04e629 100644 --- a/TESTING.md +++ b/TESTING.md @@ -251,6 +251,17 @@ the process exits non-zero. stacks. - When the watchdog fires under coverage, that run's coverage data is lost; the job fails anyway and the dump is what matters. +- `PM_TEST_SLOWEST=N` (off by default) lists the N slowest tests after the + summary, each timed from its `start:` line to the next one like the hang + limit. The Windows and macOS jobs (`portability.yml`) set it to 40, so every + run shows where its time went (#487). +- On the Windows runners `TEMP` sits on the OS disk C:, where a flush took about + 6 ms and a test database's create-to-delete about 111 ms (D: 0.5 ms and 23 ms, + macOS 19 ms). `portability.yml` points `TEMP`/`TMP` at `RUNNER_TEMP` on D: + before the suite. A test that waits on a fixed busy timeout, a network name + (`\\server\share`: use `\\localhost\`) or a default TLS + context (it reads the Windows certificate store) costs seconds there; the + slowest list shows it. Every job in `test.yml`, `code-quality.yml` and `viewerjs-check.yml` has `timeout-minutes` (`tests/vendor/ci-workflow.test.mjs` checks it). The rule is diff --git a/diag_487.py b/diag_487.py deleted file mode 100644 index 12c58b33..00000000 --- a/diag_487.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Temporary #487 diagnostics (removed before merge).""" - -import logging -import os -import shutil -import sqlite3 -import statistics -import sys -import tempfile -import time - -logging.disable(logging.CRITICAL) -sys.path.insert(0, os.getcwd()) -from database import models # noqa: E402 -from database.models import PromptModel # noqa: E402 -from database.operations import PromptDatabase # noqa: E402 - - -def bench(label, fn, n): - times = [] - for i in range(n): - t = time.perf_counter() - fn(i) - times.append((time.perf_counter() - t) * 1000) - print( - f" {label:44s} median {statistics.median(times):8.2f} ms" - f" mean {statistics.mean(times):8.2f} ms" - ) - - -def run(base): - d = tempfile.mkdtemp(dir=base) - print(f"== base {base}") - - def small(i): - p = os.path.join(d, f"f{i}.txt") - with open(p, "wb") as h: - h.write(b"x" * 4096) - os.unlink(p) - - def fsync(i): - p = os.path.join(d, f"s{i}.txt") - with open(p, "wb") as h: - h.write(b"x" * 4096) - h.flush() - os.fsync(h.fileno()) - os.unlink(p) - - def raw(sync): - def f(i): - p = os.path.join(d, f"r{sync}{i}.db") - c = sqlite3.connect(p) - c.execute("PRAGMA journal_mode=WAL") - c.execute(f"PRAGMA synchronous={sync}") - c.execute("CREATE TABLE t(x)") - c.execute("INSERT INTO t VALUES(1)") - c.commit() - c.close() - for s in ("", "-wal", "-shm"): - if os.path.exists(p + s): - os.unlink(p + s) - - return f - - def pdb(i): - p = os.path.join(d, f"p{i}.db") - db = PromptDatabase(p) - db.save_prompt( - text=f"t{i}", - category=None, - tags=[], - rating=None, - notes=None, - prompt_hash=str(i), - ) - PromptModel.close_all_instances() - for s in ("", "-wal", "-shm"): - if os.path.exists(p + s): - os.unlink(p + s) - - def mkrm(i): - t = tempfile.mkdtemp(dir=d) - open(os.path.join(t, "a"), "w").close() - shutil.rmtree(t) - - bench("create+write+close+unlink 4k file", small, 100) - bench("same + fsync", fsync, 50) - bench("mkdtemp + file + rmtree", mkrm, 100) - bench("raw sqlite WAL db NORMAL", raw("NORMAL"), 30) - bench("raw sqlite WAL db OFF", raw("OFF"), 30) - bench("PromptDatabase create+save+close+unlink", pdb, 20) - orig = models.configure_connection - - def off(conn): - orig(conn) - conn.execute("PRAGMA synchronous = OFF") - - models.configure_connection = off - bench(" same with synchronous=OFF", lambda i: pdb(1000 + i), 20) - models.configure_connection = orig - shutil.rmtree(d, ignore_errors=True) - - -bases = [tempfile.gettempdir()] -if os.environ.get("RUNNER_TEMP"): - bases.append(os.environ["RUNNER_TEMP"]) -for b in bases: - run(b) diff --git a/diag_487_profile.py b/diag_487_profile.py deleted file mode 100644 index e0fd1da3..00000000 --- a/diag_487_profile.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Temporary #487 profile of a few modules (removed before merge).""" - -import cProfile -import logging -import pstats -import sys -import unittest - -logging.disable(logging.CRITICAL) -names = sys.argv[1:] -suite = unittest.defaultTestLoader.loadTestsFromNames(names) -pr = cProfile.Profile() -pr.enable() -unittest.TextTestRunner(stream=open(__import__("os").devnull, "w")).run(suite) -pr.disable() -st = pstats.Stats(pr) -st.sort_stats("tottime").print_stats(30) -st.sort_stats("cumtime").print_stats(60) diff --git a/tests/test_api_diagnostics_report.py b/tests/test_api_diagnostics_report.py index 113d6c42..8b6ef318 100644 --- a/tests/test_api_diagnostics_report.py +++ b/tests/test_api_diagnostics_report.py @@ -45,7 +45,9 @@ SEEDED_PATHS = ( "/srv/someone/renders", "C:\\Users\\someone\\Documents\\renders", "D:/Users/someone/prompts.db", - "\\\\nas\\share\\someone\\renders", + # A share on this machine: on Windows the report resolves every source, + # and an unknown server name made that wait about 2.9 s (#487). + "\\\\localhost\\pm-no-such-share\\someone\\renders", ) _WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:[\\/]") TOP_KEYS = { diff --git a/tests/test_lora_manager_layout.py b/tests/test_lora_manager_layout.py index 6abe760b..8af50902 100644 --- a/tests/test_lora_manager_layout.py +++ b/tests/test_lora_manager_layout.py @@ -844,8 +844,12 @@ class FolderGuardTests(unittest.TestCase): def test_missing_folders_add_nothing(self): self.assertIsNone(layout_module._guard_folder(str(self.tmp / "x"), "/home")) + # A missing share on this machine: an unknown server name made + # Windows wait about 2.8 s for the network (#487). self.assertIsNone( - layout_module._guard_folder("\\\\server\\share\\loras", str(self.tmp)) + layout_module._guard_folder( + "\\\\localhost\\pm-no-such-share\\loras", str(self.tmp) + ) ) self.assertEqual( layout_module._guard_folder(str(self.tmp), "/nonexistent-home"), diff --git a/tests/test_lora_utils.py b/tests/test_lora_utils.py index d271c771..18476cfd 100644 --- a/tests/test_lora_utils.py +++ b/tests/test_lora_utils.py @@ -9,6 +9,7 @@ import email import json import os import shutil +import ssl import sys import tempfile import threading @@ -669,6 +670,15 @@ class TestStaleDownloadSweep(unittest.TestCase): self.assertTrue(old.exists()) +def _unused_tls_context(): + """A TLS context for the fake handlers, which never open a connection. + + HTTPSHandler() builds a default context, and on Windows that reads the + system certificate store: about 0.14 s per handler (#487). + """ + return ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + + class _FakeHTTPSHandler(urllib.request.HTTPSHandler): """Serves canned responses through a real urllib opener, recording requests. @@ -677,7 +687,7 @@ class _FakeHTTPSHandler(urllib.request.HTTPSHandler): """ def __init__(self, redirect_to): - super().__init__() + super().__init__(context=_unused_tls_context()) self.redirect_to = redirect_to self.requests = [] @@ -745,7 +755,7 @@ class _ChainHTTPSHandler(urllib.request.HTTPSHandler): urllib opener, recording every request; an unknown URL answers 404.""" def __init__(self, routes): - super().__init__() + super().__init__(context=_unused_tls_context()) self.routes = routes self.requests = []